feat(actions): add action context for safe nested dispatch with depth limit to prevent deadlocks

This commit is contained in:
2026-03-02 19:11:44 +00:00
parent d45e1188b1
commit 9ba75f6f98
5 changed files with 159 additions and 5 deletions

View File

@@ -764,4 +764,114 @@ tap.test('getStatePart should accept 0 as initial value', async () => {
expect(part.getState()).toEqual(0);
});
// ============================
// Action context re-entrancy tests
// ============================
tap.test('context.dispatch should not deadlock on same StatePart', async () => {
type TParts = 'reentrantAction';
const state = new smartstate.Smartstate<TParts>();
const part = await state.getStatePart<{ count: number }>('reentrantAction', { count: 0 });
const innerIncrement = part.createAction<void>(async (sp) => {
return { count: sp.getState().count + 1 };
});
const outerAction = part.createAction<void>(async (sp, _payload, context) => {
// This would deadlock without the context.dispatch() mechanism
await context.dispatch(innerIncrement, undefined);
return sp.getState();
});
const result = await part.dispatchAction(outerAction, undefined);
expect(result.count).toEqual(1);
});
tap.test('deeply nested context.dispatch should work (3 levels)', async () => {
type TParts = 'deepNested';
const state = new smartstate.Smartstate<TParts>();
const part = await state.getStatePart<{ steps: string[] }>('deepNested', { steps: [] });
const appendStep = part.createAction<string>(async (sp, step) => {
return { steps: [...sp.getState().steps, step] };
});
const level2 = part.createAction<void>(async (sp, _payload, context) => {
await context.dispatch(appendStep, 'level-2');
return sp.getState();
});
const level1 = part.createAction<void>(async (sp, _payload, context) => {
await context.dispatch(appendStep, 'level-1');
await context.dispatch(level2, undefined);
await context.dispatch(appendStep, 'level-1-after');
return sp.getState();
});
await part.dispatchAction(level1, undefined);
expect(part.getState().steps).toEqual(['level-1', 'level-2', 'level-1-after']);
});
tap.test('circular context.dispatch should throw max depth error', async () => {
type TParts = 'circular';
const state = new smartstate.Smartstate<TParts>();
const part = await state.getStatePart<{ count: number }>('circular', { count: 0 });
// Create a self-referencing action that will loop forever
const circularAction: smartstate.StateAction<{ count: number }, void> = part.createAction<void>(
async (sp, _payload, context) => {
const current = sp.getState();
if (current.count < 100) {
// This should eventually hit the depth limit
await context.dispatch(circularAction, undefined);
}
return sp.getState();
}
);
let error: Error | null = null;
try {
await part.dispatchAction(circularAction, undefined);
} catch (e) {
error = e as Error;
}
expect(error).not.toBeNull();
expect(error?.message).toMatch(/Maximum nested action dispatch depth/);
});
tap.test('actions without context arg should still work (backward compat)', async () => {
type TParts = 'backwardCompat';
const state = new smartstate.Smartstate<TParts>();
const part = await state.getStatePart<{ value: number }>('backwardCompat', { value: 0 });
// Old-style action that doesn't use the context parameter
const simpleAction = part.createAction<number>(async (sp, payload) => {
return { value: payload };
});
await part.dispatchAction(simpleAction, 42);
expect(part.getState().value).toEqual(42);
});
tap.test('concurrent dispatches still serialize correctly with context feature', async () => {
type TParts = 'concurrentWithContext';
const state = new smartstate.Smartstate<TParts>();
const part = await state.getStatePart<{ count: number }>('concurrentWithContext', { count: 0 });
const increment = part.createAction<void>(async (sp) => {
const current = sp.getState();
return { count: current.count + 1 };
});
// Fire 10 concurrent dispatches — must still serialize
const promises: Promise<any>[] = [];
for (let i = 0; i < 10; i++) {
promises.push(part.dispatchAction(increment, undefined));
}
await Promise.all(promises);
expect(part.getState().count).toEqual(10);
});
export default tap.start();