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

@@ -1,5 +1,14 @@
# Changelog # Changelog
## 2026-03-02 - 2.2.0 - feat(actions)
add action context for safe nested dispatch with depth limit to prevent deadlocks
- Introduce IActionContext to allow actions to dispatch sub-actions inline via context.dispatch
- Update IActionDef signature to accept an optional context parameter for backward compatibility
- Add StatePart.createActionContext and MAX_NESTED_DISPATCH_DEPTH to track and limit nested dispatch depth (throws on circular dispatchs)
- Pass a created context into dispatchAction so actionDefs can safely perform nested dispatches without deadlocking the mutation queue
- Add tests covering re-entrancy, deeply nested dispatch, circular dispatch depth detection, backward compatibility with actions that omit context, and concurrent dispatch serialization
## 2026-02-28 - 2.1.1 - fix(core) ## 2026-02-28 - 2.1.1 - fix(core)
serialize state mutations, fix batch flushing/reentrancy, handle falsy initial values, dispose old StatePart on force, and improve notification/error handling serialize state mutations, fix batch flushing/reentrancy, handle falsy initial values, dispose old StatePart on force, and improve notification/error handling

View File

@@ -764,4 +764,114 @@ tap.test('getStatePart should accept 0 as initial value', async () => {
expect(part.getState()).toEqual(0); 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(); export default tap.start();

View File

@@ -3,6 +3,6 @@
*/ */
export const commitinfo = { export const commitinfo = {
name: '@push.rocks/smartstate', name: '@push.rocks/smartstate',
version: '2.1.1', version: '2.2.0',
description: 'A TypeScript-first reactive state management library with middleware, computed state, batching, persistence, and Web Component Context Protocol support.' description: 'A TypeScript-first reactive state management library with middleware, computed state, batching, persistence, and Web Component Context Protocol support.'
} }

View File

@@ -1,7 +1,16 @@
import { StatePart } from './smartstate.classes.statepart.js'; import { StatePart } from './smartstate.classes.statepart.js';
/**
* Context object passed to action definitions, enabling safe nested dispatch.
* Use `context.dispatch()` to dispatch sub-actions inline (bypasses the mutation queue).
* Direct `statePart.dispatchAction()` from within an action will deadlock.
*/
export interface IActionContext<TStateType> {
dispatch<T>(action: StateAction<TStateType, T>, payload: T): Promise<TStateType>;
}
export interface IActionDef<TStateType, TActionPayloadType> { export interface IActionDef<TStateType, TActionPayloadType> {
(stateArg: StatePart<any, TStateType>, actionPayload: TActionPayloadType): Promise<TStateType>; (stateArg: StatePart<any, TStateType>, actionPayload: TActionPayloadType, context?: IActionContext<TStateType>): Promise<TStateType>;
} }
/** /**

View File

@@ -1,6 +1,6 @@
import * as plugins from './smartstate.plugins.js'; import * as plugins from './smartstate.plugins.js';
import { Observable, shareReplay, takeUntil } from 'rxjs'; import { Observable, shareReplay, takeUntil } from 'rxjs';
import { StateAction, type IActionDef } from './smartstate.classes.stateaction.js'; import { StateAction, type IActionDef, type IActionContext } from './smartstate.classes.stateaction.js';
import type { Smartstate } from './smartstate.classes.smartstate.js'; import type { Smartstate } from './smartstate.classes.smartstate.js';
export type TMiddleware<TPayload> = ( export type TMiddleware<TPayload> = (
@@ -28,6 +28,8 @@ function fromAbortSignal(signal: AbortSignal): Observable<void> {
} }
export class StatePart<TStatePartName, TStatePayload> { export class StatePart<TStatePartName, TStatePayload> {
private static readonly MAX_NESTED_DISPATCH_DEPTH = 10;
public name: TStatePartName; public name: TStatePartName;
public state = new plugins.smartrx.rxjs.Subject<TStatePayload>(); public state = new plugins.smartrx.rxjs.Subject<TStatePayload>();
public stateStore: TStatePayload | undefined; public stateStore: TStatePayload | undefined;
@@ -249,6 +251,28 @@ export class StatePart<TStatePartName, TStatePayload> {
return new StateAction(this, actionDef); return new StateAction(this, actionDef);
} }
/**
* creates a depth-tracked action context for safe nested dispatch.
* Using context.dispatch() within an actionDef bypasses the mutation queue
* and executes the sub-action inline, preventing deadlocks.
*/
private createActionContext(depth: number): IActionContext<TStatePayload> {
const self = this;
return {
dispatch: async <U>(action: StateAction<TStatePayload, U>, payload: U): Promise<TStatePayload> => {
if (depth >= StatePart.MAX_NESTED_DISPATCH_DEPTH) {
throw new Error(
`Maximum nested action dispatch depth (${StatePart.MAX_NESTED_DISPATCH_DEPTH}) exceeded. ` +
`Check for circular action dispatches.`
);
}
const innerContext = self.createActionContext(depth + 1);
const newState = await action.actionDef(self, payload, innerContext);
return self.applyState(newState);
},
};
}
/** /**
* dispatches an action on the statepart level * dispatches an action on the statepart level
*/ */
@@ -256,11 +280,13 @@ export class StatePart<TStatePartName, TStatePayload> {
await this.cumulativeDeferred.promise; await this.cumulativeDeferred.promise;
return this.mutationQueue = this.mutationQueue.then( return this.mutationQueue = this.mutationQueue.then(
async () => { async () => {
const newState = await stateAction.actionDef(this, actionPayload); const context = this.createActionContext(0);
const newState = await stateAction.actionDef(this, actionPayload, context);
return this.applyState(newState); return this.applyState(newState);
}, },
async () => { async () => {
const newState = await stateAction.actionDef(this, actionPayload); const context = this.createActionContext(0);
const newState = await stateAction.actionDef(this, actionPayload, context);
return this.applyState(newState); return this.applyState(newState);
}, },
); );