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,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);
},
);