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