@alwatr/fsm 9.32.0 → 9.33.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@alwatr/fsm",
3
- "version": "9.32.0",
3
+ "version": "9.33.1",
4
4
  "description": "A tiny, type-safe, declarative, and reactive finite state machine (FSM) library for modern TypeScript applications, built on top of Alwatr Signals.",
5
5
  "license": "MPL-2.0",
6
6
  "author": "S. Ali Mihandoost <ali.mihandoost@gmail.com> (https://ali.mihandoost.com)",
@@ -15,35 +15,39 @@
15
15
  "exports": {
16
16
  ".": {
17
17
  "types": "./dist/main.d.ts",
18
+ "development": "./dist/dev/main.js",
18
19
  "import": "./dist/main.js",
19
20
  "default": "./dist/main.js"
20
21
  }
21
22
  },
22
23
  "sideEffects": false,
23
24
  "dependencies": {
24
- "@alwatr/logger": "9.31.0",
25
- "@alwatr/signal": "9.32.0"
25
+ "@alwatr/delay": "9.33.1",
26
+ "@alwatr/logger": "9.33.1",
27
+ "@alwatr/signal": "9.33.1"
26
28
  },
27
29
  "devDependencies": {
28
- "@alwatr/nano-build": "9.25.0",
29
- "@alwatr/standard": "9.16.0",
30
- "@alwatr/type-helper": "9.14.0",
30
+ "@alwatr/nano-build": "9.33.1",
31
+ "@alwatr/standard": "9.33.0",
32
+ "@alwatr/type-helper": "9.33.1",
31
33
  "typescript": "^6.0.3"
32
34
  },
33
35
  "scripts": {
34
36
  "b": "bun run build",
35
37
  "build": "bun run build:ts && bun run build:es",
36
- "build:es": "nano-build --preset=module src/main.ts",
38
+ "build:es": "bun run build:es:dev && bun run build:es:prod",
39
+ "build:es:dev": "nano-build --preset=module --outdir=dist/dev src/main.ts",
40
+ "build:es:prod": "NODE_ENV=production nano-build --preset=module --outdir=dist src/main.ts",
37
41
  "build:ts": "tsc --build",
38
42
  "cl": "bun run clean",
39
43
  "clean": "rm -rfv dist *.tsbuildinfo",
40
44
  "format": "prettier --write \"src/**/*.ts\"",
41
45
  "lint": "eslint src/ --ext .ts",
42
46
  "t": "bun run test",
43
- "test": "ALWATR_DEBUG=0 bun test",
47
+ "test": "bun test",
44
48
  "w": "bun run watch",
45
49
  "watch": "bun run watch:ts & bun run watch:es",
46
- "watch:es": "bun run build:es --watch",
50
+ "watch:es": "bun run build:es:dev --watch",
47
51
  "watch:ts": "bun run build:ts --watch --preserveWatchOutput"
48
52
  },
49
53
  "files": [
@@ -65,5 +69,5 @@
65
69
  "state",
66
70
  "typescript"
67
71
  ],
68
- "gitHead": "ac7ea45eecea81a216886612f1cd7dbc4b0dc106"
72
+ "gitHead": "84fba5b7b428188be17aaaaf062b0b7eaae96555"
69
73
  }
package/src/facade.ts CHANGED
@@ -1,8 +1,6 @@
1
- import {createPersistentStateSignal, createStateSignal} from '@alwatr/signal';
2
-
3
1
  import {FsmService} from './fsm-service.js';
4
2
 
5
- import type {MachineEvent, MachineState, StateMachineConfig} from './type.js';
3
+ import type {MachineEvent, StateMachineConfig} from './type.js';
6
4
 
7
5
  /**
8
6
  * A simple and clean factory function for creating an `FsmService` instance.
@@ -69,23 +67,5 @@ export function createFsmService<
69
67
  TEvent extends MachineEvent,
70
68
  TContext extends Record<string, unknown> = Record<string, never>,
71
69
  >(config: StateMachineConfig<TState, TEvent, TContext>): FsmService<TState, TEvent, TContext> {
72
- const initialValue: MachineState<TState, TContext> = {
73
- name: config.initial,
74
- context: config.context,
75
- };
76
-
77
- const stateSignal =
78
- config.persistent ?
79
- createPersistentStateSignal<MachineState<TState, TContext>>({
80
- name: `fsm-state-${config.name}`,
81
- storageKey: config.persistent.storageKey ?? config.name,
82
- initialValue,
83
- schemaVersion: config.persistent.schemaVersion,
84
- })
85
- : createStateSignal<MachineState<TState, TContext>>({
86
- name: `fsm-state-${config.name}`,
87
- initialValue: initialValue,
88
- });
89
-
90
- return new FsmService(config, stateSignal);
70
+ return new FsmService(config);
91
71
  }
@@ -1,10 +1,11 @@
1
- import type {SingleOrArray} from '@alwatr/type-helper';
2
1
  import {createLogger, type AlwatrLogger} from '@alwatr/logger';
2
+ import {queueMicrotask} from '@alwatr/delay';
3
+ import type {SingleOrArray} from '@alwatr/type-helper';
3
4
  import {
4
- createEventSignal,
5
+ createPersistentStateSignal,
6
+ createStateSignal,
5
7
  type StateSignal,
6
8
  type PersistentStateSignal,
7
- EventSignal,
8
9
  type IReadonlySignal,
9
10
  } from '@alwatr/signal';
10
11
 
@@ -26,135 +27,218 @@ export class FsmService<
26
27
  > {
27
28
  protected readonly logger_: AlwatrLogger;
28
29
 
29
- /** The private event signal for sending events to the FSM. */
30
- private readonly eventSignal__: EventSignal<TEvent>;
31
-
32
30
  /** The public, read-only state signal. Subscribe to react to state changes. */
33
31
  public readonly stateSignal: IReadonlySignal<MachineState<TState, TContext>>;
34
32
 
35
- /** The set of cleanup functions for currently active state actors. */
36
- private readonly activeActorCleanups__ = new Set<() => void>();
33
+ /**
34
+ * The FIFO event mailbox. Events are processed strictly in dispatch order.
35
+ */
36
+ private readonly mailbox__: TEvent[] = [];
37
+
38
+ /**
39
+ * RTC re-entrancy guard. While `true`, an active loop is draining the mailbox;
40
+ * re-entrant dispatches just enqueue and return.
41
+ */
42
+ private processing__ = true;
43
+
44
+ /** Set once by `destroy()`. All dispatches after destruction are ignored (and logged). */
45
+ private destroyed__ = false;
46
+
47
+ /**
48
+ * Cleanup callbacks for currently active state actors, in spawn order.
49
+ * Executed in REVERSE (LIFO) order on state exit — standard resource semantics
50
+ * (last acquired, first released).
51
+ */
52
+ private readonly activeActorCleanups__: (() => void)[] = [];
53
+
54
+ private readonly stateSignal__:
55
+ | StateSignal<MachineState<TState, TContext>>
56
+ | PersistentStateSignal<MachineState<TState, TContext>>;
37
57
 
38
58
  constructor(
39
59
  protected readonly config_: StateMachineConfig<TState, TEvent, TContext>,
40
- private readonly stateSignal__:
41
- | StateSignal<MachineState<TState, TContext>>
42
- | PersistentStateSignal<MachineState<TState, TContext>>,
60
+ stateSignal?: StateSignal<MachineState<TState, TContext>> | PersistentStateSignal<MachineState<TState, TContext>>,
43
61
  ) {
44
62
  this.logger_ = createLogger(`fsm:${this.config_.name}`);
45
- this.logger_.logMethodArgs?.('constructor', config_);
63
+ DEV_MODE && this.logger_.logMethodArgs?.('constructor', config_);
64
+
65
+ const initialValue: MachineState<TState, TContext> = {
66
+ name: config_.initial,
67
+ context: config_.context,
68
+ };
69
+ this.stateSignal__ =
70
+ stateSignal
71
+ ?? (config_.persistent ?
72
+ createPersistentStateSignal<MachineState<TState, TContext>>({
73
+ name: `fsm-state-${config_.name}`,
74
+ storageKey: config_.persistent.storageKey ?? config_.name,
75
+ initialValue,
76
+ schemaVersion: config_.persistent.schemaVersion,
77
+ })
78
+ : createStateSignal<MachineState<TState, TContext>>({
79
+ name: `fsm-state-${config_.name}`,
80
+ initialValue,
81
+ }));
46
82
 
47
83
  this.stateSignal = this.stateSignal__.asReadonly();
48
- this.eventSignal__ = createEventSignal<TEvent>({
49
- name: `fsm-event-${this.config_.name}`,
50
- });
51
- this.eventSignal__.subscribe((event) => this.processTransition__(event), {receivePrevious: false});
52
84
 
53
- // Execute initial state entry effects and actors.
54
- this.start_();
85
+ // Execute initial/rehydrated state entry effects and spawn its actors.
86
+ queueMicrotask(() => this.start_());
87
+ }
88
+
89
+ /**
90
+ * Synchronous accessor for the current machine state.
91
+ * Prefer `stateSignal.subscribe()` for reactive consumers; use this getter for
92
+ * imperative checks inside controllers/services.
93
+ */
94
+ public get state(): MachineState<TState, TContext> {
95
+ return this.stateSignal__.get();
96
+ }
97
+
98
+ /**
99
+ * Convenience predicate: returns true if the current finite state matches any
100
+ * of the given names. Sugar for `service.state.name === 'x' || ...`.
101
+ */
102
+ public matches(...names: TState[]): boolean {
103
+ return names.includes(this.stateSignal__.get().name);
55
104
  }
56
105
 
57
106
  /**
58
107
  * Dispatches an event to the FSM mailbox.
59
108
  *
109
+ * Events are processed with Run-to-Completion semantics: if dispatched while a
110
+ * transition is in flight (re-entrant dispatch from a guard/effect/actor), the
111
+ * event is enqueued and processed deterministically right after the current
112
+ * transition completes — in the same call stack, in FIFO order, with no loss.
113
+ *
60
114
  * @param event The event to process.
61
115
  */
62
116
  public readonly dispatch = (event: TEvent): void => {
63
- this.logger_.logMethodArgs?.('dispatch', {event});
64
- this.eventSignal__.dispatch(event);
117
+ DEV_MODE && this.logger_.logMethodArgs?.('dispatch', {event});
118
+ this.mailbox__.push(event);
119
+ this.processMailbox__();
65
120
  };
66
121
 
122
+ private processMailbox__(): void {
123
+ // RTC guard: an active loop is already draining the mailbox; it will pick
124
+ // this event up after the current transition finishes.
125
+ if (this.processing__) return;
126
+ DEV_MODE && this.logger_.logMethod?.('processMailbox__');
127
+ if (this.destroyed__) {
128
+ DEV_MODE && this.logger_.incident?.('dispatch', 'dispatch_after_destroy');
129
+ return;
130
+ }
131
+ this.processing__ = true;
132
+ try {
133
+ // Do NOT cache length. New events may be added during processing, and they MUST be processed in the same order (FIFO).
134
+ for (let index = 0; index < this.mailbox__.length; index++) {
135
+ this.processTransition__(this.mailbox__[index]);
136
+ if (this.destroyed__) break;
137
+ }
138
+ } finally {
139
+ this.processing__ = false;
140
+ this.mailbox__.length = 0;
141
+ }
142
+ }
143
+
67
144
  /**
68
145
  * The core FSM logic that processes a single event and transitions the machine to a new state.
69
- * This process is atomic and follows the Run-to-Completion (RTC) model.
146
+ * This step is atomic: exit effects -> assigners -> state commit -> entry effects -> actors.
70
147
  *
71
148
  * @param event The event to process.
72
149
  */
73
150
  private processTransition__(event: TEvent): void {
74
151
  const currentState = this.stateSignal__.get();
75
- this.logger_.logMethodArgs?.('processTransition__', {state: currentState.name, event});
152
+ DEV_MODE && this.logger_.logMethodArgs?.('processTransition__', {state: currentState.name, event});
76
153
 
77
- const transition = this.findTransition__(event, currentState.context);
154
+ const transition = this.findTransition__(event, currentState);
78
155
 
79
156
  if (!transition) {
80
- this.logger_.incident?.('processTransition__', 'ignored_event', 'No valid transition found for event', {
81
- state: currentState.name,
82
- event,
83
- });
157
+ DEV_MODE
158
+ && this.logger_.incident?.('processTransition__', 'ignored_event', 'No valid transition found for event', {
159
+ state: currentState.name,
160
+ event,
161
+ });
84
162
  return; // Event ignored, no transition occurs.
85
163
  }
86
164
 
87
165
  const targetStateName = transition.target ?? currentState.name;
88
166
  const isExternalTransition = transition.target !== undefined;
89
167
 
90
- // 1. Execute exit effects and cleanup actors of the current state if it's an external transition.
168
+ // 1. External transition: run exit effects (with the OLD context, per SCXML semantics) and tear down the current state's actors.
91
169
  if (isExternalTransition) {
92
170
  this.executeEffects__(event, currentState.context, this.config_.states[currentState.name]?.exit);
93
171
  this.cleanupActors__();
94
172
  }
95
173
 
96
- // 2. Apply assigners to compute the next context. This is a pure function.
97
- const nextContext = this.applyAssigners__(event, currentState.context, transition.assigners);
174
+ // 2. Apply assigners to compute the next context (pure, atomic).
175
+ const nextContext = this.applyAssigners__(event, currentState.context, transition.assigner);
98
176
 
99
- // 3. Create the final next state object.
177
+ // 3. Commit the new state, notifying all subscribers (async via signal layer).
100
178
  const nextState: MachineState<TState, TContext> = {
101
179
  name: targetStateName,
102
180
  context: nextContext,
103
181
  };
104
-
105
- // 4. Set the new state, notifying all subscribers.
106
182
  this.stateSignal__.set(nextState);
107
183
 
108
- // 5. Execute entry effects and spawn actors of the new state if it's an external transition.
184
+ // 4. External transition: run entry effects (with the NEW context) and spawn the target state's actors.
109
185
  if (isExternalTransition) {
110
186
  this.executeEffects__(event, nextState.context, this.config_.states[nextState.name]?.entry);
111
- this.spawnActors__(event, nextState.context, this.config_.states[nextState.name]?.actors);
187
+ this.spawnActors__(event, nextState.context, this.config_.states[nextState.name]?.actor);
112
188
  }
113
189
  }
114
190
 
115
191
  /**
116
- * Finds the first valid transition for the given event and context by evaluating guards.
192
+ * Finds the first valid transition for the given event by evaluating guards in declaration order. A guard-less transition acts as an unconditional fallback.
117
193
  *
118
194
  * @param event The triggering event.
119
- * @param context The current machine context.
195
+ * @param currentState The current state of the machine.
120
196
  * @returns The first matching transition or `undefined` if none are found.
121
197
  */
122
198
  private findTransition__(
123
199
  event: TEvent,
124
- context: Readonly<TContext>,
200
+ currentState: MachineState<TState, TContext>,
125
201
  ): Transition<TState, TEvent, TContext> | undefined {
126
- this.logger_.logMethod?.('findTransition__');
202
+ DEV_MODE && this.logger_.logMethod?.('findTransition__');
127
203
 
128
- const currentStateName = this.stateSignal__.get().name;
129
- const currentStateConfig = this.config_.states[currentStateName];
204
+ const currentStateConfig = this.config_.states[currentState.name];
130
205
  const transitions = currentStateConfig?.on?.[event.type as TEvent['type']] as
131
206
  | SingleOrArray<Transition<TState, TEvent, TContext>>
132
207
  | undefined;
133
208
 
134
209
  if (!transitions) return undefined;
135
210
 
136
- const transitionsArray = Array.isArray(transitions) ? transitions : [transitions];
137
-
138
- for (let index = 0; index < transitionsArray.length; index++) {
139
- const transition = transitionsArray[index];
140
- if (!transition.guard) return transition;
211
+ if (!Array.isArray(transitions)) {
212
+ if (!transitions.guard) return transitions; // Unconditional fallback branch.
141
213
  try {
142
- const guardMet = transition.guard({event, context});
143
- this.logger_.logStep?.('findTransition__', 'check_guard', {
144
- state: currentStateName,
214
+ if (transitions.guard(event, currentState.context)) {
215
+ return transitions;
216
+ }
217
+ } catch (error) {
218
+ this.logger_.error('findTransition__', 'guard_failed', error, {
219
+ state: currentState.name,
145
220
  eventType: event.type,
146
- transitionIndex: index,
147
- guard: transition.guard.name || 'anonymous',
148
- result: guardMet,
149
221
  });
150
- if (guardMet) return transition;
222
+ }
223
+ return undefined;
224
+ }
225
+
226
+ // else if transitions is an array
227
+
228
+ for (let index = 0; index < transitions.length; index++) {
229
+ const transition = transitions[index];
230
+ if (!transition.guard) return transition; // Unconditional fallback branch.
231
+ try {
232
+ if (transition.guard(event, currentState.context)) {
233
+ return transition;
234
+ }
151
235
  } catch (error) {
152
236
  this.logger_.error('findTransition__', 'guard_failed', error, {
153
- state: currentStateName,
237
+ state: currentState.name,
154
238
  eventType: event.type,
155
- transitionIndex: index,
156
- guard: transition.guard.name || 'anonymous',
239
+ index,
157
240
  });
241
+ // Treated as guard === false: continue evaluating the next branch.
158
242
  }
159
243
  }
160
244
 
@@ -162,7 +246,7 @@ export class FsmService<
162
246
  }
163
247
 
164
248
  /**
165
- * Sequentially executes a list of effects (side-effects).
249
+ * Sequentially executes a list of synchronous effects (side-effects).
166
250
  * Errors are caught and logged without stopping the FSM.
167
251
  *
168
252
  * @param event The event that triggered these effects.
@@ -175,41 +259,45 @@ export class FsmService<
175
259
  effects?: SingleOrArray<Effect<TEvent, TContext>>,
176
260
  ): void {
177
261
  if (!effects) {
178
- this.logger_.logMethodArgs?.('executeEffects__//skipped', {count: 0});
262
+ DEV_MODE && this.logger_.logMethod?.('executeEffects__.skipped');
179
263
  return;
180
264
  }
181
- const effectsArray = Array.isArray(effects) ? effects : [effects];
182
265
 
183
- this.logger_.logMethodArgs?.('executeEffects__', {count: effectsArray.length});
266
+ DEV_MODE && this.logger_.logMethod?.('executeEffects__');
184
267
 
185
- for (const effect of effectsArray) {
268
+ if (!Array.isArray(effects)) {
186
269
  try {
187
- const result = effect({event, context});
188
- if (result instanceof Promise) {
189
- result.catch((error) => {
190
- this.logger_.error('executeEffects__', 'effect_failed', error, {
191
- effect: effect.name || 'anonymous',
192
- state: this.stateSignal__.get().name,
193
- event,
194
- context,
195
- });
196
- });
197
- }
270
+ effects(event, context);
198
271
  } catch (error) {
199
272
  this.logger_.error('executeEffects__', 'effect_failed', error, {
200
- effect: effect.name || 'anonymous',
201
- state: this.stateSignal__.get().name,
202
273
  event,
203
274
  context,
204
275
  });
205
276
  }
277
+ return;
278
+ }
279
+
280
+ // else if effects is an array
281
+
282
+ for (let index = 0; index < effects.length; index++) {
283
+ const effect = effects[index];
284
+ try {
285
+ effect(event, context);
286
+ } catch (error) {
287
+ this.logger_.error('executeEffects__', 'effect_failed', error, {
288
+ event,
289
+ context,
290
+ index,
291
+ });
292
+ }
206
293
  }
207
294
  }
208
295
 
209
296
  /**
210
297
  * Applies all assigner functions to the context to produce a new, updated context.
211
- * This process is atomic (all-or-nothing). If any assigner fails, the original
212
- * context is returned, and all updates are discarded.
298
+ *
299
+ * This process is atomic (all-or-nothing): if any assigner throws, the original
300
+ * context is returned and all updates are discarded.
213
301
  *
214
302
  * @param event The event that triggered the transition.
215
303
  * @param context The current context.
@@ -222,49 +310,55 @@ export class FsmService<
222
310
  assigners?: SingleOrArray<Assigner<TEvent, TContext>>,
223
311
  ): TContext {
224
312
  if (!assigners) {
225
- this.logger_.logMethodArgs?.('applyAssigners__//skipped', {count: 0});
313
+ DEV_MODE && this.logger_.logMethod?.('applyAssigners__.skipped');
226
314
  return context;
227
315
  }
228
316
 
229
- const assignersArray = Array.isArray(assigners) ? assigners : [assigners];
317
+ DEV_MODE && this.logger_.logMethod?.('applyAssigners__');
230
318
 
231
- this.logger_.logMethodArgs?.('applyAssigners__', {count: assignersArray.length});
319
+ if (!Array.isArray(assigners)) {
320
+ try {
321
+ return assigners(event, context) ?? context;
322
+ } catch (error) {
323
+ this.logger_.error('applyAssigners__', 'assigner_failed_atomic', error, {event, context});
324
+ }
325
+ return context;
326
+ }
327
+
328
+ // else if assigners is an array
232
329
 
233
330
  try {
234
331
  let accContext = context;
235
- for (const assigner of assignersArray) {
236
- const nextContext = assigner({event, context: accContext});
237
- this.logger_.logMethodFull?.(
238
- `event.${event.type}.action.${assigner.name || 'anonymous'}`,
239
- {event, accContext},
240
- nextContext,
241
- );
242
- if (nextContext !== undefined && nextContext !== null) {
332
+ for (let index = 0; index < assigners.length; index++) {
333
+ const assigner = assigners[index];
334
+ const nextContext = assigner(event, accContext);
335
+ if (nextContext) {
243
336
  accContext = nextContext;
244
337
  }
245
338
  }
246
339
  return accContext;
247
340
  } catch (error) {
248
- this.logger_.error('applyAssigners__', 'assigner_failed_atomic', error, {
249
- event,
250
- context, // Log the original context for debugging.
251
- });
341
+ this.logger_.error('applyAssigners__', 'assigner_failed_atomic', error, {event, context});
252
342
  // On ANY failure, discard all changes and return the original context.
253
343
  return context;
254
344
  }
255
345
  }
256
346
 
257
347
  /**
258
- * Starts the FSM by executing the entry effects and spawning the actors
259
- * of the initial/current state.
348
+ * Starts the FSM by executing the entry effects and spawning the actors of the
349
+ * initial (or rehydrated) state, using the synthetic `{type: '__init__'}` event.
260
350
  */
261
351
  protected start_(): void {
262
- if (this.eventSignal__.isDestroyed) return;
263
- this.logger_.logMethod?.('start_');
352
+ if (this.destroyed__) return;
353
+ DEV_MODE && this.logger_.logMethod?.('start_');
264
354
  const currentState = this.stateSignal__.get();
265
355
  const initEvent = {type: '__init__'} as unknown as TEvent;
266
356
  this.executeEffects__(initEvent, currentState.context, this.config_.states[currentState.name]?.entry);
267
- this.spawnActors__(initEvent, currentState.context, this.config_.states[currentState.name]?.actors);
357
+ this.spawnActors__(initEvent, currentState.context, this.config_.states[currentState.name]?.actor);
358
+ this.processing__ = false; // Allow processing of dispatched events after the initial setup is complete.
359
+ if (this.mailbox__.length > 0) {
360
+ this.processMailbox__(); // Process any events that were dispatched during the initial setup.
361
+ }
268
362
  }
269
363
 
270
364
  /**
@@ -276,57 +370,68 @@ export class FsmService<
276
370
  actors?: SingleOrArray<Actor<TEvent, TContext>>,
277
371
  ): void {
278
372
  if (!actors) {
279
- this.logger_.logMethodArgs?.('spawnActors__//skipped', {count: 0});
373
+ DEV_MODE && this.logger_.logMethod?.('spawnActors__.skipped');
280
374
  return;
281
375
  }
282
- const actorsArray = Array.isArray(actors) ? actors : [actors];
283
376
 
284
- this.logger_.logMethodArgs?.('spawnActors__', {count: actorsArray.length});
377
+ DEV_MODE && this.logger_.logMethod?.('spawnActors__');
285
378
 
286
- for (const actor of actorsArray) {
379
+ if (!Array.isArray(actors)) {
287
380
  try {
288
- const cleanup = actor({
289
- event,
290
- context,
291
- dispatch: this.dispatch,
292
- });
381
+ const cleanup = actors(context, this.dispatch);
293
382
  if (typeof cleanup === 'function') {
294
- this.activeActorCleanups__.add(cleanup);
383
+ this.activeActorCleanups__.push(cleanup);
295
384
  }
296
385
  } catch (error) {
297
- this.logger_.error('spawnActors__', 'actor_failed', error, {
298
- actor: actor.name || 'anonymous',
299
- state: this.stateSignal__.get().name,
300
- event,
301
- context,
302
- });
386
+ this.logger_.error('spawnActors__', 'actor_failed', error, {event, context});
387
+ }
388
+ return;
389
+ }
390
+
391
+ // else if actors is an array
392
+
393
+ for (let index = 0; index < actors.length; index++) {
394
+ const actor = actors[index];
395
+ try {
396
+ const cleanup = actor(context, this.dispatch);
397
+ if (typeof cleanup === 'function') {
398
+ this.activeActorCleanups__.push(cleanup);
399
+ }
400
+ } catch (error) {
401
+ this.logger_.error('spawnActors__', 'actor_failed', error, {event, context, index});
303
402
  }
304
403
  }
305
404
  }
306
405
 
307
406
  /**
308
- * Cleans up (destroys) all currently active state actors.
407
+ * Cleans up (destroys) all currently active state actors in REVERSE (LIFO) spawn order — standard resource-release semantics.
309
408
  */
310
409
  private cleanupActors__(): void {
311
- this.logger_.logMethodArgs?.('cleanupActors__', {count: this.activeActorCleanups__.size});
312
- for (const cleanup of this.activeActorCleanups__) {
410
+ DEV_MODE && this.logger_.logMethodArgs?.('cleanupActors__', {count: this.activeActorCleanups__.length});
411
+ for (let index = this.activeActorCleanups__.length - 1; index >= 0; index--) {
313
412
  try {
314
- cleanup();
413
+ this.activeActorCleanups__[index]();
315
414
  } catch (error) {
316
- this.logger_.error('cleanupActors__', 'cleanup_failed', error);
415
+ this.logger_.error('cleanupActors__', 'cleanup_failed', error, {index});
317
416
  }
318
417
  }
319
- this.activeActorCleanups__.clear();
418
+ this.activeActorCleanups__.length = 0;
320
419
  }
321
420
 
322
421
  /**
323
- * Destroys the service, cleaning up all internal signals and subscriptions
324
- * to prevent memory leaks.
422
+ * Destroys the service, cleaning up actors, the mailbox, and owned signals to
423
+ * prevent memory leaks. Idempotent — safe to call multiple times.
424
+ *
425
+ * @param destroyState If `true` (default), also destroys the state signal, preventing any future subscriptions or updates. Set to `false` to preserve the last state value for late subscribers even after destruction.
325
426
  */
326
- public destroy(): void {
327
- this.logger_.logMethod?.('destroy');
427
+ public destroy(destroyState = true): void {
428
+ if (this.destroyed__) return;
429
+ DEV_MODE && this.logger_.logMethod?.('destroy');
430
+ this.destroyed__ = true;
431
+ this.mailbox__.length = 0;
328
432
  this.cleanupActors__();
329
- this.eventSignal__.destroy();
330
- this.stateSignal__.destroy();
433
+ if (destroyState) {
434
+ this.stateSignal__.destroy();
435
+ }
331
436
  }
332
437
  }