@xmachines/play-xstate 1.0.0 → 1.1.0

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.
Files changed (54) hide show
  1. package/README.md +32 -14
  2. package/dist/errors.d.ts +46 -32
  3. package/dist/errors.d.ts.map +1 -1
  4. package/dist/errors.js +69 -32
  5. package/dist/errors.js.map +1 -1
  6. package/dist/guards/compose.d.ts +8 -0
  7. package/dist/guards/compose.d.ts.map +1 -1
  8. package/dist/guards/compose.js +7 -2
  9. package/dist/guards/compose.js.map +1 -1
  10. package/dist/guards/helpers.d.ts +6 -2
  11. package/dist/guards/helpers.d.ts.map +1 -1
  12. package/dist/guards/helpers.js +6 -2
  13. package/dist/guards/helpers.js.map +1 -1
  14. package/dist/guards/index.d.ts +7 -0
  15. package/dist/guards/index.d.ts.map +1 -1
  16. package/dist/guards/index.js +7 -0
  17. package/dist/guards/index.js.map +1 -1
  18. package/dist/guards/types.d.ts +4 -5
  19. package/dist/guards/types.d.ts.map +1 -1
  20. package/dist/player-actor.d.ts +105 -39
  21. package/dist/player-actor.d.ts.map +1 -1
  22. package/dist/player-actor.js +312 -318
  23. package/dist/player-actor.js.map +1 -1
  24. package/dist/routing/build-url.d.ts +2 -7
  25. package/dist/routing/build-url.d.ts.map +1 -1
  26. package/dist/routing/build-url.js +21 -25
  27. package/dist/routing/build-url.js.map +1 -1
  28. package/dist/routing/derive-current-route.d.ts +32 -0
  29. package/dist/routing/derive-current-route.d.ts.map +1 -1
  30. package/dist/routing/derive-current-route.js +20 -19
  31. package/dist/routing/derive-current-route.js.map +1 -1
  32. package/dist/routing/derive-initial-route.d.ts +0 -3
  33. package/dist/routing/derive-initial-route.d.ts.map +1 -1
  34. package/dist/routing/derive-initial-route.js +0 -3
  35. package/dist/routing/derive-initial-route.js.map +1 -1
  36. package/dist/routing/index.d.ts +1 -1
  37. package/dist/routing/index.d.ts.map +1 -1
  38. package/dist/routing/index.js +1 -1
  39. package/dist/routing/index.js.map +1 -1
  40. package/dist/types.d.ts +86 -13
  41. package/dist/types.d.ts.map +1 -1
  42. package/dist/view/derive-current-view.d.ts +54 -0
  43. package/dist/view/derive-current-view.d.ts.map +1 -0
  44. package/dist/view/derive-current-view.js +167 -0
  45. package/dist/view/derive-current-view.js.map +1 -0
  46. package/package.json +11 -14
  47. package/dist/define-player.typecheck.d.ts +0 -2
  48. package/dist/define-player.typecheck.d.ts.map +0 -1
  49. package/dist/define-player.typecheck.js +0 -48
  50. package/dist/define-player.typecheck.js.map +0 -1
  51. package/dist/player-actor.typecheck.d.ts +0 -2
  52. package/dist/player-actor.typecheck.d.ts.map +0 -1
  53. package/dist/player-actor.typecheck.js +0 -27
  54. package/dist/player-actor.typecheck.js.map +0 -1
@@ -1,125 +1,77 @@
1
- import { createActor, } from "xstate";
1
+ import { Actor, } from "xstate";
2
2
  import { AbstractActor } from "@xmachines/play-actor";
3
3
  import { Signal } from "@xmachines/play-signals";
4
- import { InvalidEventError, InvalidMachineError } from "./errors.js";
4
+ import { ActorThrewNonErrorError, InvalidEventError, InvalidMachineError } from "./errors.js";
5
5
  import { deriveCurrentRoute, deriveInitialRoute } from "./routing/index.js";
6
- const hasSnapshotStatus = (snapshot) => {
7
- if (!snapshot || typeof snapshot !== "object") {
8
- return false;
9
- }
10
- if (!("status" in snapshot)) {
11
- return false;
12
- }
13
- return typeof snapshot.status === "string";
14
- };
6
+ import { deriveCurrentView } from "./view/derive-current-view.js";
15
7
  /**
16
- * A snapshot worth propagating to signals: an active snapshot or a "done"
17
- * snapshot (top-level final state reached). Error and stopped snapshots are
18
- * skipped so signals keep the last observable state instead of surfacing
19
- * teardown/error artifacts.
8
+ * An `Error` by identity or by brand: `instanceof` misses errors constructed
9
+ * in another realm (an iframe, `node:vm`). Prefer `Error.isError` where the
10
+ * runtime has it (Node >= 24, Baseline-2025 browsers) — it rejects
11
+ * `Symbol.toStringTag` spoofs — and fall back to the brand check elsewhere.
12
+ * Typed structurally: the repo's lib target predates the API.
20
13
  */
21
- const isObservableSnapshot = (snapshot) => {
22
- return (hasSnapshotStatus(snapshot) && (snapshot.status === "active" || snapshot.status === "done"));
23
- };
24
- const resolveViewMeta = (meta) => {
25
- // Iterate last-to-first: snapshot.getMeta() orders keys ancestors-first, so
26
- // scanning from the end makes the deepest active state's meta.view win over
27
- // an ancestor's instead of being shadowed by it.
28
- const values = Object.values(meta);
29
- for (let i = values.length - 1; i >= 0; i--) {
30
- const stateMeta = values[i]; // nosemgrep: gitlab.eslint.detect-object-injection
31
- const maybeView = stateMeta && typeof stateMeta === "object"
32
- ? stateMeta.view
33
- : undefined;
34
- if (maybeView &&
35
- typeof maybeView === "object" &&
36
- "root" in maybeView &&
37
- "elements" in maybeView) {
38
- return maybeView;
39
- }
40
- }
41
- return null;
14
+ const isRealError = (value) => {
15
+ if (value instanceof Error)
16
+ return true;
17
+ const isError = Error.isError;
18
+ if (isError)
19
+ return isError(value);
20
+ return Object.prototype.toString.call(value) === "[object Error]";
42
21
  };
43
22
  /**
44
- * Merge route parameters and an explicit allowlist of context fields into a single
45
- * element's props.
46
- *
47
- * Priority rule (highest → lowest):
48
- * 1. Explicit non-`undefined` spec prop — always wins (static values are authoritative).
49
- * 2. URL route params (`context.params`) — fills `undefined` slots from the URL path.
50
- * 3. Allowlisted context fields (`contextProps`) — fills remaining `undefined` slots from
51
- * the machine context (e.g. `context.username` on a state with no URL username param).
52
- *
53
- * The `contextProps` allowlist is the key safety mechanism: only fields the machine
54
- * explicitly opts in to are ever exposed to components.
55
- *
56
- * @param urlParams - Extracted URL path parameters from `context.params`.
57
- * @param contextValues - Object containing only the allowlisted context fields.
58
- * @param existingProps - The element's props as declared in the machine spec.
59
- * @returns Merged props object with URL params and allowlisted context values filling
60
- * undefined slots.
23
+ * Normalize an actor failure for `onError`.
61
24
  *
62
- * @example
63
- * ```ts
64
- * // spec: { section: undefined, username: undefined, title: "Dashboard" }
65
- * // urlParams: { section: "profile" } ← from URL /:section
66
- * // contextValues: { username: "alice" } ← from contextProps: ["username"]
67
- * // result: { section: "profile", username: "alice", title: "Dashboard" }
68
- * ```
25
+ * An `Error` is handed over unchanged so the machine's own error keeps its
26
+ * identity (a consumer's `instanceof` check still works, and the no-`onError`
27
+ * path rethrows that same object). Anything else is ours to construct, so it
28
+ * becomes a coded `PlayError` carrying the thrown value as `cause`.
69
29
  */
70
- function mergeRouteParamsIntoProps(urlParams, contextValues, existingProps) {
71
- // Layer 3 (lowest priority): allowlisted context values
72
- const merged = { ...contextValues };
73
- // Layer 2: URL params override context values for the same key
74
- for (const [k, v] of Object.entries(urlParams)) {
75
- merged[k] = v; // nosemgrep: gitlab.eslint.detect-object-injection
30
+ const toError = (value) => {
31
+ try {
32
+ if (isRealError(value)) {
33
+ return value;
34
+ }
76
35
  }
77
- // Layer 1 (highest priority): explicit non-undefined spec props always win
78
- for (const [k, v] of Object.entries(existingProps)) {
79
- if (v !== undefined)
80
- merged[k] = v; // nosemgrep: gitlab.eslint.detect-object-injection
36
+ catch {
37
+ // Classification itself can throw for hostile values (a revoked Proxy
38
+ // traps both instanceof and brand inspection) — fall through and wrap.
81
39
  }
82
- return merged;
83
- }
40
+ return new ActorThrewNonErrorError(value);
41
+ };
42
+ /** Own-key shallow equality with `Object.is`, ignoring `except` on both sides. */
43
+ const shallowEqualExcept = (a, b, except) => {
44
+ const aEntries = Object.entries(a).filter(([key]) => key !== except);
45
+ const bKeyCount = Object.keys(b).filter((key) => key !== except).length;
46
+ if (aEntries.length !== bKeyCount)
47
+ return false;
48
+ const bRecord = b;
49
+ for (const [key, value] of aEntries) {
50
+ // nosemgrep: gitlab.eslint.detect-object-injection
51
+ if (!Object.hasOwn(b, key) || !Object.is(value, bRecord[key]))
52
+ return false;
53
+ }
54
+ return true;
55
+ };
84
56
  /**
85
- * Compare two derived `PlaySpec` objects for rendered-view equivalence.
86
- *
87
- * `deriveCurrentView` returns a fresh object on every call, so reference equality
88
- * cannot tell whether the view actually changed. This helper performs a cheap
89
- * structural comparison over the inputs that determine the rendered output:
90
- *
91
- * - Top-level spec fields (`root`, `contextProps`, …) by identity — they are
92
- * primitives or references copied unchanged from the machine's stable `meta.view`.
93
- * - Per-element fields other than `props` by identity — same reasoning.
94
- * - Element `props` by shallow `Object.is` per key — this is where URL params and
95
- * allowlisted context values are merged in, so a param/context change that alters
96
- * the rendered spec is detected here.
57
+ * Bounded structural equality for derived view specs.
97
58
  *
98
- * Used by `PlayerActor.validateAndCacheView` to skip re-emitting the view signal
99
- * for snapshots that do not change the rendered view (e.g. context-only assigns).
59
+ * Walks exactly the shape `deriveCurrentView` builds — spec fields, then
60
+ * `elements`, then each element's `props` — comparing every leaf with
61
+ * `Object.is`. Prop VALUES are never entered: a changed reference re-emits
62
+ * even when contents are equal, which keeps container props (Map/Set/class
63
+ * instances, invisible to structural comparison) and cyclic values (unbounded
64
+ * recursion for it) correct by construction.
100
65
  */
101
- const areViewSpecsEquivalent = (a, b) => {
66
+ const viewSpecsEquivalent = (a, b) => {
102
67
  if (a === b)
103
68
  return true;
104
- if (a === null || b === null)
105
- return false;
106
- const aRecord = a;
107
- const bRecord = b;
108
- const topKeys = Object.keys(aRecord);
109
- if (topKeys.length !== Object.keys(bRecord).length)
69
+ if (!a || !b)
110
70
  return false;
111
- for (const key of topKeys) {
112
- if (key === "elements")
113
- continue;
114
- if (!Object.is(aRecord[key], bRecord[key]))
115
- return false; // nosemgrep: gitlab.eslint.detect-object-injection
116
- }
117
- const aElements = (aRecord.elements ?? null);
118
- const bElements = (bRecord.elements ?? null);
119
- if (aElements === bElements)
120
- return true;
121
- if (aElements === null || bElements === null)
71
+ if (!shallowEqualExcept(a, b, "elements"))
122
72
  return false;
73
+ const aElements = a.elements ?? {};
74
+ const bElements = b.elements ?? {};
123
75
  const elementKeys = Object.keys(aElements);
124
76
  if (elementKeys.length !== Object.keys(bElements).length)
125
77
  return false;
@@ -130,126 +82,29 @@ const areViewSpecsEquivalent = (a, b) => {
130
82
  continue;
131
83
  if (!aElement || !bElement)
132
84
  return false;
133
- const fieldKeys = Object.keys(aElement);
134
- if (fieldKeys.length !== Object.keys(bElement).length)
85
+ if (!shallowEqualExcept(aElement, bElement, "props"))
135
86
  return false;
136
- for (const field of fieldKeys) {
137
- if (field === "props")
138
- continue;
139
- if (!Object.is(aElement[field], bElement[field]))
140
- return false; // nosemgrep: gitlab.eslint.detect-object-injection
141
- }
142
- const aProps = (aElement.props ?? {});
143
- const bProps = (bElement.props ?? {});
144
- const propKeys = Object.keys(aProps);
145
- if (propKeys.length !== Object.keys(bProps).length)
87
+ if (!shallowEqualExcept(aElement.props ?? {}, bElement.props ?? {}))
146
88
  return false;
147
- for (const prop of propKeys) {
148
- if (!Object.is(aProps[prop], bProps[prop]))
149
- return false; // nosemgrep: gitlab.eslint.detect-object-injection
150
- }
151
89
  }
152
90
  return true;
153
91
  };
154
92
  /**
155
- * Derive the actor's current view from state metadata.
156
- *
157
- * Always returns a **fresh** `PlaySpec` object so a genuine view change is never
158
- * suppressed by `Signal.State`'s `Object.is` equality check. Whether that fresh
159
- * object is actually emitted is decided by `PlayerActor.validateAndCacheView`,
160
- * which compares against the last emitted spec (see {@link areViewSpecsEquivalent})
161
- * and skips emission when the rendered view is unchanged — preventing downstream
162
- * providers from remounting the UI on every event.
163
- *
164
- * ### Prop enrichment
165
- *
166
- * Each spec element's `props` are enriched before the view is emitted using two sources:
167
- *
168
- * **1. URL route params** (`context.params`, populated by `formatPlayRouteTransitions`):
169
- * Makes `:section?` / `:username` URL path parameters automatically available as component
170
- * props without manual wiring.
171
- *
172
- * **2. Context fields** (`spec.contextProps` allowlist):
173
- * Exposes selected context fields as component props for states where no URL param covers
174
- * the data (e.g. `context.username` on a `/dashboard` route). Only fields explicitly named
175
- * in `spec.contextProps: string[]` are ever exposed — nothing leaks from context implicitly.
176
- *
177
- * Merge priority (see `mergeRouteParamsIntoProps`):
178
- * 1. Explicit non-`undefined` spec prop — always wins (authoritative static value).
179
- * 2. URL route param (`context.params`) — fills `undefined` slots from the URL path.
180
- * 3. Allowlisted context field (`contextProps`) — fills remaining `undefined` slots.
181
- *
182
- * @param snapshot - Current XState machine snapshot.
183
- * @returns Enriched `PlaySpec`, or `null` if the current state has no view metadata.
184
- *
185
- * @example
186
- * ```ts
187
- * // spec: { contextProps: ["username"], elements: { root: { props: { username: undefined } } } }
188
- * // context.username = "alice", context.params = {}
189
- * // Derived props: { username: "alice" }
190
- *
191
- * // spec: { contextProps: ["username"], elements: { root: { props: { username: undefined } } } }
192
- * // context.username = "alice", context.params = { username: "demo" }
193
- * // Derived props: { username: "demo" } ← URL param wins
194
- * ```
93
+ * A snapshot worth propagating to signals: an active snapshot or a "done"
94
+ * snapshot (top-level final state reached). Error and stopped snapshots are
95
+ * skipped so signals keep the last observable state instead of surfacing
96
+ * teardown/error artifacts.
195
97
  */
196
- const deriveCurrentView = (snapshot) => {
197
- if (!snapshot) {
198
- return null;
199
- }
200
- const meta = snapshot.getMeta();
201
- if (!meta || typeof meta !== "object") {
202
- return null;
203
- }
204
- const viewMeta = resolveViewMeta(meta);
205
- if (!viewMeta) {
206
- return null;
207
- }
208
- // Extract params from context (set by formatPlayRouteTransitions on play.route events)
209
- const context = snapshot.context !== null &&
210
- snapshot.context !== undefined &&
211
- typeof snapshot.context === "object"
212
- ? snapshot.context
213
- : null;
214
- const urlParams = context !== null && typeof context.params === "object" && context.params !== null
215
- ? context.params
216
- : {};
217
- // Extract the allowlisted context fields declared in contextProps.
218
- // Only fields explicitly named in the allowlist are ever exposed to components.
219
- const contextPropsAllowlist = viewMeta?.contextProps ?? [];
220
- const contextValues = {};
221
- if (context !== null && contextPropsAllowlist.length > 0) {
222
- for (const key of contextPropsAllowlist) {
223
- // nosemgrep: gitlab.eslint.detect-object-injection
224
- if (key in context && context[key] !== null && context[key] !== undefined) {
225
- contextValues[key] = context[key]; // nosemgrep: gitlab.eslint.detect-object-injection
226
- }
227
- }
228
- }
229
- // Enrich element props when there is anything to merge.
230
- if (viewMeta?.elements) {
231
- const enrichedElements = Object.fromEntries(Object.entries(viewMeta.elements).map(([key, el]) => {
232
- const element = el;
233
- const existingProps = element.props ?? {};
234
- return [
235
- key,
236
- {
237
- ...element,
238
- props: mergeRouteParamsIntoProps(urlParams, contextValues, existingProps),
239
- },
240
- ];
241
- }));
242
- return { ...viewMeta, elements: enrichedElements };
243
- }
244
- return viewMeta;
245
- };
98
+ const isObservableSnapshot = (snapshot) => snapshot.status === "active" || snapshot.status === "done";
246
99
  /**
247
100
  * Concrete XState actor implementing Play Architecture signal protocol
248
101
  *
249
- * Extends {@link @xmachines/play-actor!AbstractActor} to provide XState v5 integration
250
- * while maintaining ecosystem compatibility (XState inspection, devtools). This actor
251
- * wraps an internal XState actor and exposes TC39 Signal-based reactive state for
252
- * Infrastructure observation.
102
+ * Extends {@link @xmachines/play-actor!AbstractActor} — and so XState's own `Actor` —
103
+ * to provide XState v5 integration while maintaining ecosystem compatibility (XState
104
+ * inspection, devtools). The machine is handed to the base constructor, so a
105
+ * `PlayerActor` **is** the XState actor rather than a wrapper around one: everything
106
+ * XState's `Actor` exposes operates on this instance's own state, and the class adds
107
+ * TC39 Signal-based reactive state for Infrastructure observation on top.
253
108
  *
254
109
  * **Capabilities:** Implements both {@link @xmachines/play-actor!Routable} and
255
110
  * {@link @xmachines/play-actor!Viewable} interfaces, providing routing and view
@@ -272,7 +127,14 @@ const deriveCurrentView = (snapshot) => {
272
127
  * initial: 'idle',
273
128
  * states: {
274
129
  * idle: {
275
- * meta: { route: '/', view: { component: 'HomePage' } }
130
+ * meta: {
131
+ * route: '/',
132
+ * // A view spec needs `root` and `elements` — other shapes derive null.
133
+ * view: {
134
+ * root: 'home',
135
+ * elements: { home: { type: 'HomePage', props: {}, children: [] } },
136
+ * },
137
+ * },
276
138
  * }
277
139
  * }
278
140
  * });
@@ -282,8 +144,8 @@ const deriveCurrentView = (snapshot) => {
282
144
  * actor.start();
283
145
  *
284
146
  * // Observe signals
285
- * console.log(actor.currentRoute.get()); // '/'
286
- * console.log(actor.currentView.get()); // { component: 'HomePage' }
147
+ * console.log(actor.currentRoute.get()); // '/'
148
+ * console.log(actor.currentView.get()?.root); // 'home'
287
149
  * ```
288
150
  *
289
151
  * @example
@@ -319,18 +181,42 @@ const deriveCurrentView = (snapshot) => {
319
181
  * cached and updated at state entry, not computed on every read.
320
182
  */
321
183
  export class PlayerActor extends AbstractActor {
322
- xstateActor;
323
184
  playerOptions;
324
- viewSignal;
325
- /** Last spec emitted on the view signal — used to skip no-change re-emissions. */
326
- lastEmittedView = null;
185
+ /**
186
+ * The caller's live options bag, or an empty one during construction.
187
+ *
188
+ * XState hands this actor to a context factory as `self` and to an
189
+ * `inspect` observer as `actorRef` from inside its own constructor, before
190
+ * any field here is assigned. Reading hooks through this accessor keeps
191
+ * that window from throwing — a throw would be caught by XState's
192
+ * initialization and parked as an error snapshot — while preserving the
193
+ * read-at-delivery-time behaviour the bag's own docs promise.
194
+ *
195
+ * The window-sensitive fields below are `declare`d for the same reason.
196
+ * This target compiles class fields to `Object.defineProperty`, which runs
197
+ * after `super()` returns: a plain declaration — initializer or not — would
198
+ * reset anything the window had written back to `undefined`. `declare`
199
+ * emits nothing, so those writes survive.
200
+ */
201
+ get hooks() {
202
+ return this.playerOptions ?? {};
203
+ }
204
+ /**
205
+ * The last snapshot the view pipeline processed. XState notifies observers
206
+ * on EVERY processed event — an ignored event redelivers the identical
207
+ * snapshot — and deriveCurrentView is pure in the snapshot, so an identical
208
+ * reference cannot change the outcome. Seeded with undefined (never a
209
+ * snapshot): the construction-time snapshot is reference-identical to the
210
+ * one start() replays, and seeding with it would suppress the initial view.
211
+ */
212
+ lastViewSnapshot = undefined;
327
213
  // AbstractActor protocol requirements
328
214
  state;
329
215
  /**
330
216
  * Returns whether the actor's current state can accept the given event.
331
217
  *
332
218
  * Typed to the machine's event union — passing an unknown event type is a
333
- * compile error. Delegates to the underlying XState actor's snapshot.
219
+ * compile error. Evaluated against the current snapshot signal.
334
220
  *
335
221
  * @example
336
222
  * ```typescript
@@ -338,23 +224,28 @@ export class PlayerActor extends AbstractActor {
338
224
  * ```
339
225
  */
340
226
  can(event) {
341
- return this.state.get().can(event);
227
+ // Reading the signal keeps can() reactive: a Signal.Computed over it
228
+ // recomputes on transitions. Two states have no answer to give: the
229
+ // construction window, where no snapshot is readable yet, and an actor
230
+ // whose initialization failed — XState parks an error snapshot there,
231
+ // which is a truthy object with no `can` on it.
232
+ const snapshot = this.state?.get();
233
+ return typeof snapshot?.can === "function" ? snapshot.can(event) : false;
342
234
  }
343
235
  /**
344
236
  * A TC39 `Signal.Computed` that derives the current URL path from the active
345
237
  * machine state's `meta.route` template and the actor's context.
346
238
  *
347
239
  * Returns `null` when the current state has no `meta.route`, or when the route
348
- * template cannot be fully resolved (e.g. a required parameter is absent from
349
- * context).
350
- *
351
- * @throws {MissingRouteParamError} When a required `:param` placeholder in the
352
- * route template has no matching value in the actor's context. Import the class
353
- * from `@xmachines/play-xstate/errors`.
240
+ * template cannot be fully resolved — a required `:param` absent from context
241
+ * is caught internally (`MissingRouteParamError` never escapes `get()`): the
242
+ * condition is transient mid-transition and the signal recomputes on the next
243
+ * snapshot.
354
244
  *
355
245
  * @example
356
246
  * ```typescript
357
- * // Returns "/profile/alice" when context.userId === "alice"
247
+ * // Returns "/profile/alice" when context.params.userId === "alice",
248
+ * // and null while the param is still missing.
358
249
  * const route = actor.currentRoute.get();
359
250
  * ```
360
251
  */
@@ -391,6 +282,11 @@ export class PlayerActor extends AbstractActor {
391
282
  *
392
283
  * Returns `null` when the current state has no `meta.view` metadata.
393
284
  *
285
+ * Two states declaring separate but structurally identical `meta.view`
286
+ * literals emit distinct references on a transition between them (a
287
+ * provider remount); hoist the shared literal into one `typedSpec` constant
288
+ * to deduplicate by identity.
289
+ *
394
290
  * @example
395
291
  * ```typescript
396
292
  * const view = actor.currentView.get();
@@ -400,96 +296,155 @@ export class PlayerActor extends AbstractActor {
400
296
  * }
401
297
  * ```
402
298
  */
403
- currentView;
299
+ currentView = new Signal.State(null);
404
300
  constructor(machine, options, input, restoredSnapshot) {
405
- // Defensive check: Validate machine before passing to createActor
301
+ // Defensive check before super(): a non-object machine fails deep inside
302
+ // XState's constructor with an opaque TypeError instead of a coded error.
406
303
  if (!machine || typeof machine !== "object") {
407
304
  throw new InvalidMachineError();
408
305
  }
409
- // Create XState actor
410
- // XState 5.28.0: createActor() options has a conditional type constraint on `input` that
411
- // TypeScript cannot resolve against an unbound generic TMachine parameter.
412
- // Option B: Cast to ActorOptions<TMachine> — narrower than `as any` because we remain
413
- // within the XState type system. The `input` parameter is typed as InputFrom<TMachine>
414
- // at the constructor call site, so consumers receive compile-time validation.
306
+ // THIS is the actor. The machine and its runtime options go straight to
307
+ // XState's Actor constructor — the same arguments `createActor` forwards
308
+ // — so there is no second instance to answer for the real one, and every
309
+ // Actor member we do not override operates on real state.
310
+ //
311
+ // XState 5.28.0: the options bag has a conditional type constraint on
312
+ // `input` that TypeScript cannot resolve against an unbound generic
313
+ // TMachine. The cast stays inside the XState type system, and `input` is
314
+ // typed on this constructor's own signature, so callers keep their
315
+ // compile-time validation.
415
316
  // Track XState typing improvements: https://github.com/statelyai/xstate/issues
416
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
417
- const xstateActor = createActor(machine, {
317
+ super(machine, {
418
318
  input,
419
319
  snapshot: restoredSnapshot,
320
+ inspect: options?.inspect,
420
321
  });
421
- // Call AbstractActor constructor with actor logic
422
- super(xstateActor.logic);
423
- // Derive the machine's initial route for restore-vs-deeplink detection.
424
- // Statically computed from the machine definition + this actor's input via
425
- // XState's pure `initialTransition` helper — no extra actor, and independent
426
- // of any restored snapshot, so bridges always see the machine's DEFAULT
427
- // initial route rather than the restored state's.
428
- this.initialRoute = deriveInitialRoute(machine, input);
429
- this.xstateActor = xstateActor;
322
+ // Derive the machine's initial route for restore-vs-deeplink detection —
323
+ // always the machine's DEFAULT initial route, never the restored state's.
324
+ // Without a restored snapshot this actor's own pre-start snapshot IS that
325
+ // default initial state, so derive from it directly; only a restore needs
326
+ // XState's pure `initialTransition` helper, whose inert actor scope runs
327
+ // the machine's initial transition twice more (once with `input`
328
+ // undefined) — an XState quirk worth paying only when required.
329
+ this.initialRoute =
330
+ restoredSnapshot === undefined
331
+ ? deriveCurrentRoute(this.getSnapshot())
332
+ : deriveInitialRoute(machine, input);
430
333
  this.playerOptions = options || {};
431
334
  // Initialize state signal. Updates are synchronous (no microtask batching):
432
335
  // XState already coalesces multiple transitions within a single send() into
433
336
  // one subscription callback, and synchronous updates ensure guard-triggered
434
337
  // redirects are immediately visible to router bridges.
435
- this.state = new Signal.State(xstateActor.getSnapshot());
436
- // Initialize view signal
437
- this.viewSignal = new Signal.State(null);
338
+ this.state = new Signal.State(this.getSnapshot());
438
339
  // Initialize currentRoute computed signal
439
340
  this.currentRoute = new Signal.Computed(() => {
440
341
  const snapshot = this.state.get();
441
342
  return deriveCurrentRoute(snapshot);
442
343
  });
443
- // Expose view signal directly for proper watcher propagation
444
- this.currentView = this.viewSignal;
445
- // Subscribe to XState actor transitions
446
- this.xstateActor.subscribe((snapshot) => {
447
- // Only update on stable states per CONTEXT.md: active snapshots and the
448
- // "done" snapshot from a top-level final state. Error/stopped snapshots
449
- // are skipped so signals never freeze on teardown artifacts.
450
- if (isObservableSnapshot(snapshot)) {
451
- // State updates are synchronous so router bridges see guard redirects immediately.
452
- this.state.set(snapshot);
453
- // Validate and cache the view after state/currentRoute are current, before hooks.
454
- // Hook ordering is intentional:
455
- // 1. state/currentRoute updated
456
- // 2. currentView cached
457
- // 3. onStateChange hook
458
- // 4. send() then invokes onTransition
459
- this.validateAndCacheView(snapshot);
460
- // Call onStateChange hook
461
- if (this.playerOptions.onStateChange) {
462
- this.playerOptions.onStateChange(this, snapshot);
344
+ // Observe our own transitions. `super` rather than `this` so the
345
+ // next-only bookkeeping in the subscribe override stays about userland
346
+ // subscriptions only.
347
+ super.subscribe({
348
+ next: (snapshot) => {
349
+ // Only update on stable states: active snapshots and the
350
+ // "done" snapshot from a top-level final state. Error/stopped snapshots
351
+ // are skipped so signals never freeze on teardown artifacts.
352
+ if (isObservableSnapshot(snapshot)) {
353
+ // State updates are synchronous so router bridges see guard redirects immediately.
354
+ this.state.set(snapshot);
355
+ // Validate and cache the view after state/currentRoute are current, before hooks.
356
+ // Hook ordering is intentional:
357
+ // 1. state/currentRoute updated
358
+ // 2. currentView cached
359
+ // 3. onStateChange hook
360
+ // 4. send() then invokes onTransition
361
+ this.validateAndCacheView(snapshot);
362
+ // Call onStateChange hook
363
+ const onStateChange = this.hooks.onStateChange;
364
+ if (onStateChange) {
365
+ onStateChange(this, snapshot);
366
+ }
463
367
  }
464
- }
368
+ },
369
+ // Always registered, handler read at DELIVERY time: the options bag is
370
+ // shared by reference, so an onError attached after construction still
371
+ // receives actor errors, and one removed later stops swallowing them.
372
+ // Without a handler the listener rethrows, which XState's observer
373
+ // dispatch routes to its global unhandled rethrow — the same loud
374
+ // default as not registering an error listener at all.
375
+ error: (error) => {
376
+ const handler = this.hooks.onError;
377
+ if (handler) {
378
+ handler(this, toError(error));
379
+ return;
380
+ }
381
+ // A userland next-only subscription already makes XState report
382
+ // this delivery globally; rethrowing here too would double it.
383
+ if ((this.nextOnlySubscriptions ?? 0) > 0) {
384
+ return;
385
+ }
386
+ throw error;
387
+ },
465
388
  });
389
+ // Everything above is reachable from XState's own constructor callbacks;
390
+ // past this point the instance is whole.
391
+ this.constructed = true;
466
392
  }
467
393
  /**
468
- * Start the actor
394
+ * Start the actor.
469
395
  *
470
- * Per RESEARCH.md Pitfall 1: Always call start() after creation
396
+ * Fires `onStart` on each real start — every transition from not-running to
397
+ * running, including a start after a stop, which XState allows (its own
398
+ * `start()` bails only while the actor is already RUNNING). A repeated call
399
+ * while running does not re-fire it, so a defensive double mount does not
400
+ * re-run `onStart` side effects for one actual start.
471
401
  */
472
402
  start() {
473
- this.xstateActor.start();
474
- // Call onStart hook
475
- if (this.playerOptions.onStart) {
476
- this.playerOptions.onStart(this);
403
+ // See stop(): a call reaching in through the construction window would
404
+ // run a half-built actor.
405
+ if (!this.constructed) {
406
+ return this;
407
+ }
408
+ super.start();
409
+ if (this.lifecycle !== "running") {
410
+ this.lifecycle = "running";
411
+ const onStart = this.hooks.onStart;
412
+ if (onStart) {
413
+ onStart(this);
414
+ }
477
415
  }
478
416
  return this;
479
417
  }
480
418
  /**
481
- * Stop the actor and cleanup
419
+ * Stop the actor and clean up.
420
+ *
421
+ * Fires `onStop` only when the actor was actually running — mirroring
422
+ * XState, where stopping a never-started or already-stopped actor is a
423
+ * no-op with zero teardown — so paired cleanup never runs twice, nor
424
+ * against resources `onStart` never acquired. Stopping does not close the
425
+ * actor for good: a later `start()` is a fresh lifecycle and fires
426
+ * `onStart` again.
482
427
  */
483
428
  stop() {
484
- this.xstateActor.stop();
485
- // Call onStop hook
486
- if (this.playerOptions.onStop) {
487
- this.playerOptions.onStop(this);
429
+ // A call reaching in through the construction window (a context factory
430
+ // stopping its own `self`) would mark the actor stopped before its
431
+ // internal subscription is registered — XState drops observers added to
432
+ // a stopped actor, so the signals would never move again. There is
433
+ // nothing to tear down mid-construction, so ignore it.
434
+ if (!this.constructed) {
435
+ return this;
436
+ }
437
+ super.stop();
438
+ const wasRunning = this.lifecycle === "running";
439
+ this.lifecycle = "stopped";
440
+ const onStop = this.hooks.onStop;
441
+ if (wasRunning && onStop) {
442
+ onStop(this);
488
443
  }
489
444
  return this;
490
445
  }
491
446
  /**
492
- * Send an event to the underlying XState actor.
447
+ * Send an event to this actor.
493
448
  *
494
449
  * The actor's state machine guards decide whether the event causes a transition.
495
450
  * Pass any event from the machine's event union — domain events, routing events, etc.
@@ -513,82 +468,121 @@ export class PlayerActor extends AbstractActor {
513
468
  if (!event || typeof event !== "object") {
514
469
  throw new InvalidEventError(event);
515
470
  }
516
- // Store previous state for onTransition hook
517
- const prevSnapshot = this.xstateActor.getSnapshot();
471
+ // Inside the construction window there is no readable snapshot, and no
472
+ // hook can have been registered yet: deliver and return.
473
+ if (!this.constructed) {
474
+ Actor.prototype.send.call(this, event);
475
+ return;
476
+ }
477
+ // Captured unconditionally — getSnapshot() is a single property read, and
478
+ // the options bag is live: an onTransition installed while this event is
479
+ // being processed (e.g. from onStateChange) must still fire for it with
480
+ // the correct pre-send snapshot.
481
+ const prevSnapshot = this.getSnapshot();
518
482
  // Send to XState actor
519
- // EventFromLogic<TMachine> is exactly what xstateActor.send expects — no cast needed.
520
- this.xstateActor.send(event);
483
+ // `AbstractActor` re-declares send() as abstract purely to narrow the
484
+ // event type, and TypeScript forbids super calls to an abstract member,
485
+ // so reach XState's implementation directly. `this` IS the actor, so this
486
+ // is exactly the call `super.send(event)` would make: the relay that
487
+ // emits the @xstate.event inspection event and enqueues on our mailbox.
488
+ Actor.prototype.send.call(this, event);
521
489
  // Call onTransition hook
522
- if (this.playerOptions.onTransition) {
523
- const nextSnapshot = this.xstateActor.getSnapshot();
524
- this.playerOptions.onTransition(this, prevSnapshot, nextSnapshot);
490
+ const onTransition = this.hooks.onTransition;
491
+ if (onTransition) {
492
+ const nextSnapshot = this.getSnapshot();
493
+ onTransition(this, prevSnapshot, nextSnapshot);
525
494
  }
526
495
  }
527
496
  /**
528
497
  * Get current snapshot
529
498
  */
530
499
  getSnapshot() {
531
- return this.xstateActor.getSnapshot();
500
+ return super.getSnapshot();
532
501
  }
533
502
  subscribe(nextListenerOrObserver, errorListener, completeListener) {
534
503
  // XState's subscribe() normalizes function-or-observer internally; the cast
535
504
  // only reconciles the overload signatures.
536
- return this.xstateActor.subscribe(nextListenerOrObserver, errorListener, completeListener);
505
+ const subscription = super.subscribe(nextListenerOrObserver, errorListener, completeListener);
506
+ // Observers without an error listener make XState rethrow actor errors
507
+ // globally on their behalf; count them so the internal listener's own
508
+ // loud-default rethrow stands down while one is active (see the
509
+ // constructor's error listener).
510
+ const hasErrorListener = typeof nextListenerOrObserver === "object" && nextListenerOrObserver !== null
511
+ ? typeof nextListenerOrObserver.error === "function"
512
+ : typeof errorListener === "function";
513
+ if (hasErrorListener) {
514
+ return subscription;
515
+ }
516
+ this.nextOnlySubscriptions = (this.nextOnlySubscriptions ?? 0) + 1;
517
+ let counted = true;
518
+ return {
519
+ unsubscribe: () => {
520
+ if (counted) {
521
+ counted = false;
522
+ this.nextOnlySubscriptions = (this.nextOnlySubscriptions ?? 1) - 1;
523
+ }
524
+ subscription.unsubscribe();
525
+ },
526
+ };
537
527
  }
538
528
  /**
539
- * Listen for events emitted by the wrapped XState actor via the `emit` action.
529
+ * Listen for events this actor emits via the `emit` action.
540
530
  *
541
531
  * @param type - Emitted event type to listen for, or `"*"` for all.
542
532
  * @param handler - Called with each matching emitted event.
543
533
  * @returns Subscription with an `unsubscribe()` method.
544
534
  */
545
535
  on(type, handler) {
546
- return this.xstateActor.on(type, handler);
536
+ return super.on(type, handler);
547
537
  }
548
538
  /**
549
- * Get the persisted snapshot of the wrapped XState actor.
539
+ * Get this actor's persisted snapshot.
550
540
  *
551
541
  * Suitable for serialization and later restoration via the factory's
552
542
  * `restore.snapshot` option.
553
543
  */
554
- getPersistedSnapshot() {
555
- return this.xstateActor.getPersistedSnapshot();
544
+ getPersistedSnapshot(options) {
545
+ const forward = super.getPersistedSnapshot;
546
+ return forward.call(this, options);
556
547
  }
557
548
  /**
558
- * Validate view at state entry and cache result
559
- *
560
- * Per CONTEXT.md: "Prop validation: At state entry (when state becomes active)"
561
- * Validates once per transition and stores result in signal.
549
+ * Derive and cache the view at state entry — once per transition, stored in
550
+ * the signal rather than recomputed per read.
562
551
  *
563
552
  * @param snapshot - Current XState snapshot
564
553
  */
565
554
  validateAndCacheView(snapshot) {
555
+ if (snapshot === this.lastViewSnapshot) {
556
+ return;
557
+ }
558
+ this.lastViewSnapshot = snapshot;
566
559
  try {
567
560
  const view = deriveCurrentView(snapshot);
568
- // Emit only when the rendered view actually changed. deriveCurrentView
569
- // returns a fresh object on every call (so genuine changes always pass
570
- // Signal.State's Object.is gate), but re-emitting a fresh reference for
571
- // snapshots that do not alter the view (e.g. context-only assigns) would
572
- // make downstream providers treat every event as a view transition and
573
- // remount the UI, wiping in-view state.
574
- if (areViewSpecsEquivalent(this.lastEmittedView, view)) {
561
+ // Emit only when the rendered view actually changed: deriveCurrentView
562
+ // returns a fresh object per call, so reference identity cannot tell,
563
+ // and re-emitting a fresh reference for a snapshot that does not alter
564
+ // the view (e.g. a context-only assign) would make downstream providers
565
+ // remount the UI, wiping in-view state. Deep equality is deliberately
566
+ // NOT used here — it cannot see inside Maps/Sets (suppressing genuine
567
+ // changes) and recurses forever on cyclic props. The last emitted spec
568
+ // IS the signal's current value; read it untracked so the gate never
569
+ // registers currentView as a dependency of a surrounding computation.
570
+ const lastEmittedView = Signal.subtle.untrack(() => this.currentView.get());
571
+ if (viewSpecsEquivalent(lastEmittedView, view)) {
575
572
  return;
576
573
  }
577
- this.lastEmittedView = view;
578
- this.viewSignal.set(view);
574
+ this.currentView.set(view);
579
575
  }
580
576
  catch (error) {
581
- if (this.playerOptions.onError) {
582
- const err = error instanceof Error ? error : new Error(String(error));
583
- this.playerOptions.onError(this, err);
577
+ const onError = this.hooks.onError;
578
+ if (onError) {
579
+ onError(this, toError(error));
584
580
  }
585
581
  // On error: keep the last valid view (don't clear)
586
582
  }
587
583
  }
588
584
  /**
589
- * Convenience dispose method for cleanup
590
- *
591
- * Per CONTEXT.md: "Both .dispose() convenience method and manual machine.stop()"
585
+ * Convenience dispose method for cleanup — an alias for {@link stop}.
592
586
  */
593
587
  dispose() {
594
588
  this.stop();