@xmachines/play-xstate 1.0.0-beta.9 → 1.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 (97) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +171 -338
  3. package/dist/define-player.d.ts +6 -56
  4. package/dist/define-player.d.ts.map +1 -1
  5. package/dist/define-player.js +7 -61
  6. package/dist/define-player.js.map +1 -1
  7. package/dist/define-player.typecheck.js +6 -0
  8. package/dist/define-player.typecheck.js.map +1 -1
  9. package/dist/errors.d.ts +170 -0
  10. package/dist/errors.d.ts.map +1 -0
  11. package/dist/errors.js +202 -0
  12. package/dist/errors.js.map +1 -0
  13. package/dist/guards/compose.d.ts +9 -2
  14. package/dist/guards/compose.d.ts.map +1 -1
  15. package/dist/guards/compose.js +3 -2
  16. package/dist/guards/compose.js.map +1 -1
  17. package/dist/guards/helpers.d.ts +16 -1
  18. package/dist/guards/helpers.d.ts.map +1 -1
  19. package/dist/guards/helpers.js +16 -35
  20. package/dist/guards/helpers.js.map +1 -1
  21. package/dist/guards/index.d.ts +2 -1
  22. package/dist/guards/index.d.ts.map +1 -1
  23. package/dist/guards/index.js +1 -1
  24. package/dist/guards/index.js.map +1 -1
  25. package/dist/guards/types.d.ts +1 -1
  26. package/dist/guards/types.d.ts.map +1 -1
  27. package/dist/index.d.ts +6 -10
  28. package/dist/index.d.ts.map +1 -1
  29. package/dist/index.js +3 -5
  30. package/dist/index.js.map +1 -1
  31. package/dist/player-actor.d.ts +134 -27
  32. package/dist/player-actor.d.ts.map +1 -1
  33. package/dist/player-actor.js +417 -166
  34. package/dist/player-actor.js.map +1 -1
  35. package/dist/player-actor.typecheck.js +5 -3
  36. package/dist/player-actor.typecheck.js.map +1 -1
  37. package/dist/routing/build-url.d.ts +27 -16
  38. package/dist/routing/build-url.d.ts.map +1 -1
  39. package/dist/routing/build-url.js +48 -25
  40. package/dist/routing/build-url.js.map +1 -1
  41. package/dist/routing/derive-current-route.d.ts +22 -0
  42. package/dist/routing/derive-current-route.d.ts.map +1 -0
  43. package/dist/routing/derive-current-route.js +122 -0
  44. package/dist/routing/derive-current-route.js.map +1 -0
  45. package/dist/routing/derive-initial-route.d.ts +43 -0
  46. package/dist/routing/derive-initial-route.d.ts.map +1 -0
  47. package/dist/routing/derive-initial-route.js +57 -0
  48. package/dist/routing/derive-initial-route.js.map +1 -0
  49. package/dist/routing/derive-route.d.ts +15 -1
  50. package/dist/routing/derive-route.d.ts.map +1 -1
  51. package/dist/routing/derive-route.js +25 -10
  52. package/dist/routing/derive-route.js.map +1 -1
  53. package/dist/routing/format-play-route-transitions.d.ts +29 -2
  54. package/dist/routing/format-play-route-transitions.d.ts.map +1 -1
  55. package/dist/routing/format-play-route-transitions.js +32 -8
  56. package/dist/routing/format-play-route-transitions.js.map +1 -1
  57. package/dist/routing/index.d.ts +3 -1
  58. package/dist/routing/index.d.ts.map +1 -1
  59. package/dist/routing/index.js +2 -0
  60. package/dist/routing/index.js.map +1 -1
  61. package/dist/routing/types.d.ts +11 -4
  62. package/dist/routing/types.d.ts.map +1 -1
  63. package/dist/types.d.ts +12 -16
  64. package/dist/types.d.ts.map +1 -1
  65. package/package.json +34 -11
  66. package/dist/catalog/index.d.ts +0 -13
  67. package/dist/catalog/index.d.ts.map +0 -1
  68. package/dist/catalog/index.js +0 -11
  69. package/dist/catalog/index.js.map +0 -1
  70. package/dist/catalog/types.d.ts +0 -27
  71. package/dist/catalog/types.d.ts.map +0 -1
  72. package/dist/catalog/types.js +0 -2
  73. package/dist/catalog/types.js.map +0 -1
  74. package/dist/catalog/validate-binding.d.ts +0 -22
  75. package/dist/catalog/validate-binding.d.ts.map +0 -1
  76. package/dist/catalog/validate-binding.js +0 -30
  77. package/dist/catalog/validate-binding.js.map +0 -1
  78. package/dist/catalog/validate-props.d.ts +0 -66
  79. package/dist/catalog/validate-props.d.ts.map +0 -1
  80. package/dist/catalog/validate-props.js +0 -135
  81. package/dist/catalog/validate-props.js.map +0 -1
  82. package/dist/catalog/validate-props.typecheck.d.ts +0 -2
  83. package/dist/catalog/validate-props.typecheck.d.ts.map +0 -1
  84. package/dist/catalog/validate-props.typecheck.js +0 -6
  85. package/dist/catalog/validate-props.typecheck.js.map +0 -1
  86. package/dist/signals/debounce.d.ts +0 -18
  87. package/dist/signals/debounce.d.ts.map +0 -1
  88. package/dist/signals/debounce.js +0 -35
  89. package/dist/signals/debounce.js.map +0 -1
  90. package/dist/signals/index.d.ts +0 -2
  91. package/dist/signals/index.d.ts.map +0 -1
  92. package/dist/signals/index.js +0 -2
  93. package/dist/signals/index.js.map +0 -1
  94. package/dist/signals/state-signal.d.ts +0 -33
  95. package/dist/signals/state-signal.d.ts.map +0 -1
  96. package/dist/signals/state-signal.js +0 -41
  97. package/dist/signals/state-signal.js.map +0 -1
@@ -1,9 +1,8 @@
1
1
  import { createActor, } from "xstate";
2
- import { AbstractActor, } from "@xmachines/play-actor";
2
+ import { AbstractActor } from "@xmachines/play-actor";
3
3
  import { Signal } from "@xmachines/play-signals";
4
- import { StateSignalManager } from "./signals/state-signal.js";
5
- import { deriveRoute, buildRouteUrl } from "./routing/index.js";
6
- import { validateComponentBinding, validateViewProps, mergeViewProps, createMemoSignature, } from "./catalog/index.js";
4
+ import { InvalidEventError, InvalidMachineError } from "./errors.js";
5
+ import { deriveCurrentRoute, deriveInitialRoute } from "./routing/index.js";
7
6
  const hasSnapshotStatus = (snapshot) => {
8
7
  if (!snapshot || typeof snapshot !== "object") {
9
8
  return false;
@@ -13,8 +12,236 @@ const hasSnapshotStatus = (snapshot) => {
13
12
  }
14
13
  return typeof snapshot.status === "string";
15
14
  };
16
- const isActiveSnapshot = (snapshot) => {
17
- return hasSnapshotStatus(snapshot) && snapshot.status === "active";
15
+ /**
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.
20
+ */
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;
42
+ };
43
+ /**
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.
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
+ * ```
69
+ */
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
76
+ }
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
81
+ }
82
+ return merged;
83
+ }
84
+ /**
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.
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).
100
+ */
101
+ const areViewSpecsEquivalent = (a, b) => {
102
+ if (a === b)
103
+ 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)
110
+ 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)
122
+ return false;
123
+ const elementKeys = Object.keys(aElements);
124
+ if (elementKeys.length !== Object.keys(bElements).length)
125
+ return false;
126
+ for (const key of elementKeys) {
127
+ const aElement = aElements[key]; // nosemgrep: gitlab.eslint.detect-object-injection
128
+ const bElement = bElements[key]; // nosemgrep: gitlab.eslint.detect-object-injection
129
+ if (aElement === bElement)
130
+ continue;
131
+ if (!aElement || !bElement)
132
+ return false;
133
+ const fieldKeys = Object.keys(aElement);
134
+ if (fieldKeys.length !== Object.keys(bElement).length)
135
+ 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)
146
+ 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
+ }
152
+ return true;
153
+ };
154
+ /**
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
+ * ```
195
+ */
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;
18
245
  };
19
246
  /**
20
247
  * Concrete XState actor implementing Play Architecture signal protocol
@@ -60,7 +287,7 @@ const isActiveSnapshot = (snapshot) => {
60
287
  * ```
61
288
  *
62
289
  * @example
63
- * Signal lifecycle with watchers (microtask batching)
290
+ * Signal lifecycle with watchers
64
291
  * ```typescript
65
292
  * import { Signal } from "@xmachines/play-signals";
66
293
  *
@@ -73,10 +300,10 @@ const isActiveSnapshot = (snapshot) => {
73
300
  *
74
301
  * watcher.watch(actor.state);
75
302
  * actor.send({ type: 'play.route', to: '#about' });
76
- * // Watcher notification scheduled via microtask
303
+ * // Watcher notification scheduled via microtask by the watcher itself
77
304
  * ```
78
305
  *
79
- * @see {@link https://gitlab.com/xmachin-es/rfc/-/blob/main/src/play-v1.md | RFC Play v1}
306
+ * @see [Play RFC](../../docs/rfc/play.md)
80
307
  * @see {@link definePlayer} for factory creation
81
308
  * @see {@link @xmachines/play-actor!AbstractActor} for signal protocol
82
309
  * @see {@link @xmachines/play-actor!Routable} for routing capability
@@ -92,21 +319,92 @@ const isActiveSnapshot = (snapshot) => {
92
319
  * cached and updated at state entry, not computed on every read.
93
320
  */
94
321
  export class PlayerActor extends AbstractActor {
95
- _xstateActor;
96
- _stateManager;
97
- _options;
98
- _catalog;
99
- _viewSignal;
100
- _lastViewMemo;
322
+ xstateActor;
323
+ playerOptions;
324
+ viewSignal;
325
+ /** Last spec emitted on the view signal — used to skip no-change re-emissions. */
326
+ lastEmittedView = null;
101
327
  // AbstractActor protocol requirements
102
328
  state;
329
+ /**
330
+ * Returns whether the actor's current state can accept the given event.
331
+ *
332
+ * 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.
334
+ *
335
+ * @example
336
+ * ```typescript
337
+ * if (actor.can({ type: "auth.logout" })) { ... }
338
+ * ```
339
+ */
340
+ can(event) {
341
+ return this.state.get().can(event);
342
+ }
343
+ /**
344
+ * A TC39 `Signal.Computed` that derives the current URL path from the active
345
+ * machine state's `meta.route` template and the actor's context.
346
+ *
347
+ * 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`.
354
+ *
355
+ * @example
356
+ * ```typescript
357
+ * // Returns "/profile/alice" when context.userId === "alice"
358
+ * const route = actor.currentRoute.get();
359
+ * ```
360
+ */
103
361
  currentRoute;
362
+ /**
363
+ * The route derived from the machine's initial state — fixed at construction,
364
+ * never changes even when the actor is restored from a snapshot.
365
+ *
366
+ * Router bridges compare this against the browser URL to distinguish a deep-link
367
+ * (non-initial URL → router wins) from a restore (initial URL + actor at a
368
+ * different restored route → actor wins).
369
+ *
370
+ * Derived statically from the machine definition via `deriveInitialRoute`
371
+ * (XState's pure `initialTransition` helper): the initial state chain and its
372
+ * `meta.route` templates are fixed at machine definition time, while `:param`
373
+ * substitution uses the machine's real initial context for this actor's `input`.
374
+ * No extra actor is ever created, and a restored snapshot never influences the
375
+ * value — it is always the machine's **default** initial route.
376
+ */
377
+ initialRoute;
378
+ /**
379
+ * Reactive signal containing the current view spec derived from the active state's
380
+ * `meta.view` metadata.
381
+ *
382
+ * Emits a **fresh object reference** whenever the rendered view actually changes —
383
+ * a different state's view, or a param/context change that alters the resolved
384
+ * spec (including `reenter: true` re-entries with new params). Snapshots that do
385
+ * not change the rendered view (e.g. context-only assigns) keep the previous
386
+ * reference so downstream providers do not remount the UI on every event.
387
+ *
388
+ * The emitted `PlaySpec` has its element `props` enriched with `context.params`
389
+ * before emission — URL path parameters (e.g. `:section?`) flow into component props
390
+ * automatically. See `mergeRouteParamsIntoProps` for the merge priority rules.
391
+ *
392
+ * Returns `null` when the current state has no `meta.view` metadata.
393
+ *
394
+ * @example
395
+ * ```typescript
396
+ * const view = actor.currentView.get();
397
+ * if (view) {
398
+ * console.log(view.root); // e.g. "root"
399
+ * console.log(view.elements); // @xmachines/json-render-core Spec elements
400
+ * }
401
+ * ```
402
+ */
104
403
  currentView;
105
- catalog;
106
- constructor(machine, catalog, options, input) {
404
+ constructor(machine, options, input, restoredSnapshot) {
107
405
  // Defensive check: Validate machine before passing to createActor
108
406
  if (!machine || typeof machine !== "object") {
109
- throw new Error("PlayerActor requires a valid XState machine");
407
+ throw new InvalidMachineError();
110
408
  }
111
409
  // Create XState actor
112
410
  // XState 5.28.0: createActor() options has a conditional type constraint on `input` that
@@ -116,58 +414,52 @@ export class PlayerActor extends AbstractActor {
116
414
  // at the constructor call site, so consumers receive compile-time validation.
117
415
  // Track XState typing improvements: https://github.com/statelyai/xstate/issues
118
416
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
119
- const xstateActor = createActor(machine, { input });
120
- // Defensive check: Validate actor was created successfully
121
- if (!xstateActor) {
122
- throw new Error("Failed to create XState actor - machine may be null or invalid");
123
- }
417
+ const xstateActor = createActor(machine, {
418
+ input,
419
+ snapshot: restoredSnapshot,
420
+ });
124
421
  // Call AbstractActor constructor with actor logic
125
422
  super(xstateActor.logic);
126
- this._xstateActor = xstateActor;
127
- this._options = options || {};
128
- this._catalog = catalog ?? {};
129
- this.catalog = this._catalog;
130
- // Initialize state signal manager
131
- this._stateManager = new StateSignalManager(xstateActor.getSnapshot());
132
- this.state = this._stateManager.signal;
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;
430
+ this.playerOptions = options || {};
431
+ // Initialize state signal. Updates are synchronous (no microtask batching):
432
+ // XState already coalesces multiple transitions within a single send() into
433
+ // one subscription callback, and synchronous updates ensure guard-triggered
434
+ // redirects are immediately visible to router bridges.
435
+ this.state = new Signal.State(xstateActor.getSnapshot());
133
436
  // Initialize view signal
134
- this._viewSignal = new Signal.State(null);
135
- this._lastViewMemo = null;
437
+ this.viewSignal = new Signal.State(null);
136
438
  // Initialize currentRoute computed signal
137
439
  this.currentRoute = new Signal.Computed(() => {
138
440
  const snapshot = this.state.get();
139
- // Defensive check: Validate snapshot exists and has getMeta
140
- if (!snapshot || typeof snapshot.getMeta !== "function") {
141
- return null;
142
- }
143
- const meta = snapshot.getMeta();
144
- // Defensive check: Validate meta is an object
145
- if (!meta || typeof meta !== "object") {
146
- return null;
147
- }
148
- // Derive route from meta.route per Plan 04
149
- const routeTemplate = deriveRoute(meta);
150
- if (!routeTemplate) {
151
- return null;
152
- }
153
- // Build full URL with params, query, hash
154
- // Per CONTEXT.md: "Full URL generation including query params, hash, base path from context"
155
- return buildRouteUrl(routeTemplate, snapshot.context ?? {});
441
+ return deriveCurrentRoute(snapshot);
156
442
  });
157
443
  // Expose view signal directly for proper watcher propagation
158
- this.currentView = this._viewSignal;
444
+ this.currentView = this.viewSignal;
159
445
  // Subscribe to XState actor transitions
160
- this._xstateActor.subscribe((snapshot) => {
161
- // Only update on stable/active states per CONTEXT.md
162
- if (isActiveSnapshot(snapshot)) {
163
- // Update state signal with microtask batching
164
- this._stateManager.scheduleUpdate(snapshot);
165
- // Validate view at state entry per CONTEXT.md: "Prop validation: At state entry"
166
- // This runs once per transition, not on every signal read
167
- this._validateAndCacheView(snapshot);
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);
168
460
  // Call onStateChange hook
169
- if (this._options.onStateChange) {
170
- this._options.onStateChange(this, snapshot);
461
+ if (this.playerOptions.onStateChange) {
462
+ this.playerOptions.onStateChange(this, snapshot);
171
463
  }
172
464
  }
173
465
  });
@@ -178,10 +470,10 @@ export class PlayerActor extends AbstractActor {
178
470
  * Per RESEARCH.md Pitfall 1: Always call start() after creation
179
471
  */
180
472
  start() {
181
- this._xstateActor.start();
473
+ this.xstateActor.start();
182
474
  // Call onStart hook
183
- if (this._options.onStart) {
184
- this._options.onStart(this);
475
+ if (this.playerOptions.onStart) {
476
+ this.playerOptions.onStart(this);
185
477
  }
186
478
  return this;
187
479
  }
@@ -189,54 +481,78 @@ export class PlayerActor extends AbstractActor {
189
481
  * Stop the actor and cleanup
190
482
  */
191
483
  stop() {
192
- this._xstateActor.stop();
193
- this._stateManager.dispose();
484
+ this.xstateActor.stop();
194
485
  // Call onStop hook
195
- if (this._options.onStop) {
196
- this._options.onStop(this);
486
+ if (this.playerOptions.onStop) {
487
+ this.playerOptions.onStop(this);
197
488
  }
198
489
  return this;
199
490
  }
200
491
  /**
201
- * Send event to actor
492
+ * Send an event to the underlying XState actor.
202
493
  *
203
- * Forwards events to the underlying XState actor. The actor's state machine
204
- * guards determine whether each event is valid from the current state.
494
+ * The actor's state machine guards decide whether the event causes a transition.
495
+ * Pass any event from the machine's event union — domain events, routing events, etc.
205
496
  *
206
- * @param event - Any event object with a type property
497
+ * @param event - An event from the machine's `EventFromLogic<TMachine>` union.
498
+ *
499
+ * @throws {InvalidEventError} When `event` is not a plain object (`null`, `undefined`,
500
+ * a string, number, etc.). Import the class from `@xmachines/play-xstate/errors`.
207
501
  *
208
502
  * @example
209
503
  * ```typescript
210
- * // Domain event
211
- * actor.send({ type: 'auth.login', userId: '123' });
504
+ * // Domain event (typed to machine's event union)
505
+ * actor.send({ type: "auth.login", userId: "123" });
212
506
  *
213
507
  * // Routing event
214
- * actor.send({ type: 'play.route', to: '#home' });
508
+ * actor.send({ type: "play.route", to: "#home" });
215
509
  * ```
216
510
  */
217
511
  send(event) {
218
512
  // Defensive check: Validate event is not null/undefined
219
513
  if (!event || typeof event !== "object") {
220
- console.warn("PlayerActor.send() called with invalid event:", event);
221
- return;
514
+ throw new InvalidEventError(event);
222
515
  }
223
516
  // Store previous state for onTransition hook
224
- const prevSnapshot = this._xstateActor.getSnapshot();
517
+ const prevSnapshot = this.xstateActor.getSnapshot();
225
518
  // Send to XState actor
226
- // EventFromLogic<TMachine> is narrower than PlayEvent at the type level,
227
- // but at runtime both are plain { type: string, ...fields } objects.
228
- this._xstateActor.send(event);
519
+ // EventFromLogic<TMachine> is exactly what xstateActor.send expects — no cast needed.
520
+ this.xstateActor.send(event);
229
521
  // Call onTransition hook
230
- if (this._options.onTransition) {
231
- const nextSnapshot = this._xstateActor.getSnapshot();
232
- this._options.onTransition(this, prevSnapshot, nextSnapshot);
522
+ if (this.playerOptions.onTransition) {
523
+ const nextSnapshot = this.xstateActor.getSnapshot();
524
+ this.playerOptions.onTransition(this, prevSnapshot, nextSnapshot);
233
525
  }
234
526
  }
235
527
  /**
236
528
  * Get current snapshot
237
529
  */
238
530
  getSnapshot() {
239
- return this._xstateActor.getSnapshot();
531
+ return this.xstateActor.getSnapshot();
532
+ }
533
+ subscribe(nextListenerOrObserver, errorListener, completeListener) {
534
+ // XState's subscribe() normalizes function-or-observer internally; the cast
535
+ // only reconciles the overload signatures.
536
+ return this.xstateActor.subscribe(nextListenerOrObserver, errorListener, completeListener);
537
+ }
538
+ /**
539
+ * Listen for events emitted by the wrapped XState actor via the `emit` action.
540
+ *
541
+ * @param type - Emitted event type to listen for, or `"*"` for all.
542
+ * @param handler - Called with each matching emitted event.
543
+ * @returns Subscription with an `unsubscribe()` method.
544
+ */
545
+ on(type, handler) {
546
+ return this.xstateActor.on(type, handler);
547
+ }
548
+ /**
549
+ * Get the persisted snapshot of the wrapped XState actor.
550
+ *
551
+ * Suitable for serialization and later restoration via the factory's
552
+ * `restore.snapshot` option.
553
+ */
554
+ getPersistedSnapshot() {
555
+ return this.xstateActor.getPersistedSnapshot();
240
556
  }
241
557
  /**
242
558
  * Validate view at state entry and cache result
@@ -246,93 +562,28 @@ export class PlayerActor extends AbstractActor {
246
562
  *
247
563
  * @param snapshot - Current XState snapshot
248
564
  */
249
- _validateAndCacheView(snapshot) {
250
- // Defensive check: Validate snapshot exists
251
- if (!snapshot) {
252
- this._lastViewMemo = null;
253
- this._viewSignal.set(null);
254
- return;
255
- }
256
- const meta = snapshot.getMeta();
257
- // Defensive check: Validate meta is an object
258
- if (!meta || typeof meta !== "object") {
259
- this._lastViewMemo = null;
260
- this._viewSignal.set(null);
261
- return;
262
- }
263
- // Find meta.view in active state nodes
264
- let viewMeta = null;
265
- for (const [_stateId, stateMeta] of Object.entries(meta)) {
266
- const maybeView = stateMeta && typeof stateMeta === "object"
267
- ? stateMeta.view
268
- : undefined;
269
- if (maybeView && typeof maybeView === "object") {
270
- viewMeta = maybeView;
271
- break;
272
- }
273
- }
274
- if (!viewMeta) {
275
- this._lastViewMemo = null;
276
- this._viewSignal.set(null);
277
- return;
278
- }
279
- // Validate component binding
565
+ validateAndCacheView(snapshot) {
280
566
  try {
281
- validateComponentBinding(viewMeta, this._catalog);
282
- }
283
- catch (error) {
284
- // Call onError hook if validation fails
285
- if (this._options.onError) {
286
- this._options.onError(this, error);
567
+ 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)) {
575
+ return;
287
576
  }
288
- console.error("Catalog binding validation failed:", error);
289
- this._viewSignal.set(null);
290
- return;
291
- }
292
- // Merge meta.view with context for props
293
- const props = mergeViewProps(viewMeta, snapshot.context);
294
- // Use reference equality for snapshot.value (XState snapshots are immutable)
295
- // Per user decision (CONS-11): ref equality on snapshot.value, createMemoSignature for props
296
- const propsSignature = createMemoSignature(props);
297
- if (this._lastViewMemo &&
298
- this._lastViewMemo.snapshotValue === snapshot.value &&
299
- this._lastViewMemo.component === viewMeta.component &&
300
- this._lastViewMemo.propsSignature === propsSignature) {
301
- this._viewSignal.set({
302
- component: this._lastViewMemo.view.component,
303
- props: this._lastViewMemo.view.props,
304
- });
305
- return;
577
+ this.lastEmittedView = view;
578
+ this.viewSignal.set(view);
306
579
  }
307
- // Validate props against Zod schema
308
- const validation = validateViewProps(viewMeta.component, props, this._catalog);
309
- if (!validation.success) {
310
- // Call onError hook if prop validation fails
311
- if (this._options.onError) {
312
- this._options.onError(this, new Error(`Invalid props for component "${viewMeta.component}": ${validation.error?.message || "Unknown error"}`));
313
- }
314
- console.error("View props validation failed:", validation.error);
315
- // STRICT MODE (CONS-15): block rendering on validation failure
316
- if (this._options.propValidation === "strict") {
317
- this._lastViewMemo = null;
318
- this._viewSignal.set(null);
319
- return;
580
+ catch (error) {
581
+ if (this.playerOptions.onError) {
582
+ const err = error instanceof Error ? error : new Error(String(error));
583
+ this.playerOptions.onError(this, err);
320
584
  }
321
- // LENIENT MODE (default): fall through and render with unvalidated props
322
- // Documented trade-off: components may receive malformed props but app stays visible
585
+ // On error: keep the last valid view (don't clear)
323
586
  }
324
- // Cache validated view structure (validated once at state entry)
325
- const newView = {
326
- component: viewMeta.component,
327
- props: validation.success ? validation.data : props,
328
- };
329
- this._lastViewMemo = {
330
- snapshotValue: snapshot.value,
331
- component: viewMeta.component,
332
- propsSignature,
333
- view: newView,
334
- };
335
- this._viewSignal.set(newView);
336
587
  }
337
588
  /**
338
589
  * Convenience dispose method for cleanup