feat(actions): add action context for safe nested dispatch with depth limit to prevent deadlocks
This commit is contained in:
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user