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

@@ -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.'
}

View File

@@ -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<TStateType> {
dispatch<T>(action: StateAction<TStateType, T>, payload: T): Promise<TStateType>;
}
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 { 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<TPayload> = (
@@ -28,6 +28,8 @@ function fromAbortSignal(signal: AbortSignal): Observable<void> {
}
export class StatePart<TStatePartName, TStatePayload> {
private static readonly MAX_NESTED_DISPATCH_DEPTH = 10;
public name: TStatePartName;
public state = new plugins.smartrx.rxjs.Subject<TStatePayload>();
public stateStore: TStatePayload | undefined;
@@ -249,6 +251,28 @@ export class StatePart<TStatePartName, TStatePayload> {
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
*/
@@ -256,11 +280,13 @@ export class StatePart<TStatePartName, TStatePayload> {
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);
},
);