From 9ba75f6f982f688998c00b8a545024b5ab249aed Mon Sep 17 00:00:00 2001 From: Juergen Kunz Date: Mon, 2 Mar 2026 19:11:44 +0000 Subject: [PATCH] feat(actions): add action context for safe nested dispatch with depth limit to prevent deadlocks --- changelog.md | 9 +++ test/test.initialization.ts | 110 +++++++++++++++++++++++++++ ts/00_commitinfo_data.ts | 2 +- ts/smartstate.classes.stateaction.ts | 11 ++- ts/smartstate.classes.statepart.ts | 32 +++++++- 5 files changed, 159 insertions(+), 5 deletions(-) diff --git a/changelog.md b/changelog.md index eb32b45..f42d3c1 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,14 @@ # 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) serialize state mutations, fix batch flushing/reentrancy, handle falsy initial values, dispose old StatePart on force, and improve notification/error handling diff --git a/test/test.initialization.ts b/test/test.initialization.ts index 29dd6fa..d8fa71a 100644 --- a/test/test.initialization.ts +++ b/test/test.initialization.ts @@ -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(); + const part = await state.getStatePart<{ count: number }>('reentrantAction', { count: 0 }); + + const innerIncrement = part.createAction(async (sp) => { + return { count: sp.getState().count + 1 }; + }); + + const outerAction = part.createAction(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(); + const part = await state.getStatePart<{ steps: string[] }>('deepNested', { steps: [] }); + + const appendStep = part.createAction(async (sp, step) => { + return { steps: [...sp.getState().steps, step] }; + }); + + const level2 = part.createAction(async (sp, _payload, context) => { + await context.dispatch(appendStep, 'level-2'); + return sp.getState(); + }); + + const level1 = part.createAction(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(); + 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( + 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(); + const part = await state.getStatePart<{ value: number }>('backwardCompat', { value: 0 }); + + // Old-style action that doesn't use the context parameter + const simpleAction = part.createAction(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(); + const part = await state.getStatePart<{ count: number }>('concurrentWithContext', { count: 0 }); + + const increment = part.createAction(async (sp) => { + const current = sp.getState(); + return { count: current.count + 1 }; + }); + + // Fire 10 concurrent dispatches — must still serialize + const promises: Promise[] = []; + 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(); diff --git a/ts/00_commitinfo_data.ts b/ts/00_commitinfo_data.ts index 9b1ca90..b9214bf 100644 --- a/ts/00_commitinfo_data.ts +++ b/ts/00_commitinfo_data.ts @@ -3,6 +3,6 @@ */ export const commitinfo = { 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.' } diff --git a/ts/smartstate.classes.stateaction.ts b/ts/smartstate.classes.stateaction.ts index 8846a31..625b609 100644 --- a/ts/smartstate.classes.stateaction.ts +++ b/ts/smartstate.classes.stateaction.ts @@ -1,7 +1,16 @@ 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 { + dispatch(action: StateAction, payload: T): Promise; +} + export interface IActionDef { - (stateArg: StatePart, actionPayload: TActionPayloadType): Promise; + (stateArg: StatePart, actionPayload: TActionPayloadType, context?: IActionContext): Promise; } /** diff --git a/ts/smartstate.classes.statepart.ts b/ts/smartstate.classes.statepart.ts index a55442b..88dd20c 100644 --- a/ts/smartstate.classes.statepart.ts +++ b/ts/smartstate.classes.statepart.ts @@ -1,6 +1,6 @@ import * as plugins from './smartstate.plugins.js'; 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'; export type TMiddleware = ( @@ -28,6 +28,8 @@ function fromAbortSignal(signal: AbortSignal): Observable { } export class StatePart { + private static readonly MAX_NESTED_DISPATCH_DEPTH = 10; + public name: TStatePartName; public state = new plugins.smartrx.rxjs.Subject(); public stateStore: TStatePayload | undefined; @@ -249,6 +251,28 @@ export class StatePart { 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 { + const self = this; + return { + dispatch: async (action: StateAction, payload: U): Promise => { + 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 */ @@ -256,11 +280,13 @@ export class StatePart { await this.cumulativeDeferred.promise; return this.mutationQueue = this.mutationQueue.then( 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); }, 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); }, );