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