@xmachines/play-xstate 1.0.0-beta.8 → 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 +175 -336
  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 +14 -3
  14. package/dist/guards/compose.d.ts.map +1 -1
  15. package/dist/guards/compose.js +29 -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 -28
  32. package/dist/player-actor.d.ts.map +1 -1
  33. package/dist/player-actor.js +424 -159
  34. package/dist/player-actor.js.map +1 -1
  35. package/dist/player-actor.typecheck.js +9 -5
  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 -6
  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 -51
  79. package/dist/catalog/validate-props.d.ts.map +0 -1
  80. package/dist/catalog/validate-props.js +0 -155
  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,75 +319,147 @@ 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
- const xstateActor = createActor(machine, { input });
113
- // Defensive check: Validate actor was created successfully
114
- if (!xstateActor) {
115
- throw new Error("Failed to create XState actor - machine may be null or invalid");
116
- }
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.
415
+ // 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, {
418
+ input,
419
+ snapshot: restoredSnapshot,
420
+ });
117
421
  // Call AbstractActor constructor with actor logic
118
422
  super(xstateActor.logic);
119
- this._xstateActor = xstateActor;
120
- this._options = options || {};
121
- this._catalog = catalog ?? {};
122
- this.catalog = this._catalog;
123
- // Initialize state signal manager
124
- this._stateManager = new StateSignalManager(xstateActor.getSnapshot());
125
- 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());
126
436
  // Initialize view signal
127
- this._viewSignal = new Signal.State(null);
128
- this._lastViewMemo = null;
437
+ this.viewSignal = new Signal.State(null);
129
438
  // Initialize currentRoute computed signal
130
439
  this.currentRoute = new Signal.Computed(() => {
131
440
  const snapshot = this.state.get();
132
- // Defensive check: Validate snapshot exists and has getMeta
133
- if (!snapshot || typeof snapshot.getMeta !== "function") {
134
- return null;
135
- }
136
- const meta = snapshot.getMeta();
137
- // Defensive check: Validate meta is an object
138
- if (!meta || typeof meta !== "object") {
139
- return null;
140
- }
141
- // Derive route from meta.route per Plan 04
142
- const routeTemplate = deriveRoute(meta);
143
- if (!routeTemplate) {
144
- return null;
145
- }
146
- // Build full URL with params, query, hash
147
- // Per CONTEXT.md: "Full URL generation including query params, hash, base path from context"
148
- return buildRouteUrl(routeTemplate, snapshot.context ?? {});
441
+ return deriveCurrentRoute(snapshot);
149
442
  });
150
443
  // Expose view signal directly for proper watcher propagation
151
- this.currentView = this._viewSignal;
444
+ this.currentView = this.viewSignal;
152
445
  // Subscribe to XState actor transitions
153
- this._xstateActor.subscribe((snapshot) => {
154
- // Only update on stable/active states per CONTEXT.md
155
- if (isActiveSnapshot(snapshot)) {
156
- // Update state signal with microtask batching
157
- this._stateManager.scheduleUpdate(snapshot);
158
- // Validate view at state entry per CONTEXT.md: "Prop validation: At state entry"
159
- // This runs once per transition, not on every signal read
160
- 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);
161
460
  // Call onStateChange hook
162
- if (this._options.onStateChange) {
163
- this._options.onStateChange(this, snapshot);
461
+ if (this.playerOptions.onStateChange) {
462
+ this.playerOptions.onStateChange(this, snapshot);
164
463
  }
165
464
  }
166
465
  });
@@ -171,10 +470,10 @@ export class PlayerActor extends AbstractActor {
171
470
  * Per RESEARCH.md Pitfall 1: Always call start() after creation
172
471
  */
173
472
  start() {
174
- this._xstateActor.start();
473
+ this.xstateActor.start();
175
474
  // Call onStart hook
176
- if (this._options.onStart) {
177
- this._options.onStart(this);
475
+ if (this.playerOptions.onStart) {
476
+ this.playerOptions.onStart(this);
178
477
  }
179
478
  return this;
180
479
  }
@@ -182,54 +481,78 @@ export class PlayerActor extends AbstractActor {
182
481
  * Stop the actor and cleanup
183
482
  */
184
483
  stop() {
185
- this._xstateActor.stop();
186
- this._stateManager.dispose();
484
+ this.xstateActor.stop();
187
485
  // Call onStop hook
188
- if (this._options.onStop) {
189
- this._options.onStop(this);
486
+ if (this.playerOptions.onStop) {
487
+ this.playerOptions.onStop(this);
190
488
  }
191
489
  return this;
192
490
  }
193
491
  /**
194
- * Send event to actor
492
+ * Send an event to the underlying XState actor.
195
493
  *
196
- * Forwards events to the underlying XState actor. The actor's state machine
197
- * 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.
198
496
  *
199
- * @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`.
200
501
  *
201
502
  * @example
202
503
  * ```typescript
203
- * // Domain event
204
- * actor.send({ type: 'auth.login', userId: '123' });
504
+ * // Domain event (typed to machine's event union)
505
+ * actor.send({ type: "auth.login", userId: "123" });
205
506
  *
206
507
  * // Routing event
207
- * actor.send({ type: 'play.route', to: '#home' });
508
+ * actor.send({ type: "play.route", to: "#home" });
208
509
  * ```
209
510
  */
210
511
  send(event) {
211
512
  // Defensive check: Validate event is not null/undefined
212
513
  if (!event || typeof event !== "object") {
213
- console.warn("PlayerActor.send() called with invalid event:", event);
214
- return;
514
+ throw new InvalidEventError(event);
215
515
  }
216
516
  // Store previous state for onTransition hook
217
- const prevSnapshot = this._xstateActor.getSnapshot();
517
+ const prevSnapshot = this.xstateActor.getSnapshot();
218
518
  // Send to XState actor
219
- // EventFromLogic<TMachine> is narrower than PlayEvent at the type level,
220
- // but at runtime both are plain { type: string, ...fields } objects.
221
- this._xstateActor.send(event);
519
+ // EventFromLogic<TMachine> is exactly what xstateActor.send expects — no cast needed.
520
+ this.xstateActor.send(event);
222
521
  // Call onTransition hook
223
- if (this._options.onTransition) {
224
- const nextSnapshot = this._xstateActor.getSnapshot();
225
- this._options.onTransition(this, prevSnapshot, nextSnapshot);
522
+ if (this.playerOptions.onTransition) {
523
+ const nextSnapshot = this.xstateActor.getSnapshot();
524
+ this.playerOptions.onTransition(this, prevSnapshot, nextSnapshot);
226
525
  }
227
526
  }
228
527
  /**
229
528
  * Get current snapshot
230
529
  */
231
530
  getSnapshot() {
232
- 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();
233
556
  }
234
557
  /**
235
558
  * Validate view at state entry and cache result
@@ -239,86 +562,28 @@ export class PlayerActor extends AbstractActor {
239
562
  *
240
563
  * @param snapshot - Current XState snapshot
241
564
  */
242
- _validateAndCacheView(snapshot) {
243
- // Defensive check: Validate snapshot exists
244
- if (!snapshot) {
245
- this._lastViewMemo = null;
246
- this._viewSignal.set(null);
247
- return;
248
- }
249
- const meta = snapshot.getMeta();
250
- // Defensive check: Validate meta is an object
251
- if (!meta || typeof meta !== "object") {
252
- this._lastViewMemo = null;
253
- this._viewSignal.set(null);
254
- return;
255
- }
256
- // Find meta.view in active state nodes
257
- let viewMeta = null;
258
- for (const [_stateId, stateMeta] of Object.entries(meta)) {
259
- const maybeView = stateMeta && typeof stateMeta === "object"
260
- ? stateMeta.view
261
- : undefined;
262
- if (maybeView && typeof maybeView === "object") {
263
- viewMeta = maybeView;
264
- break;
265
- }
266
- }
267
- if (!viewMeta) {
268
- this._lastViewMemo = null;
269
- this._viewSignal.set(null);
270
- return;
271
- }
272
- // Validate component binding
565
+ validateAndCacheView(snapshot) {
273
566
  try {
274
- validateComponentBinding(viewMeta, this._catalog);
275
- }
276
- catch (error) {
277
- // Call onError hook if validation fails
278
- if (this._options.onError) {
279
- 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;
280
576
  }
281
- console.error("Catalog binding validation failed:", error);
282
- this._viewSignal.set(null);
283
- return;
284
- }
285
- // Merge meta.view with context for props
286
- const props = mergeViewProps(viewMeta, snapshot.context);
287
- const stateSignature = createMemoSignature(snapshot.value);
288
- const propsSignature = createMemoSignature(props);
289
- if (this._lastViewMemo &&
290
- this._lastViewMemo.stateSignature === stateSignature &&
291
- this._lastViewMemo.component === viewMeta.component &&
292
- this._lastViewMemo.propsSignature === propsSignature) {
293
- this._viewSignal.set({
294
- component: this._lastViewMemo.view.component,
295
- props: this._lastViewMemo.view.props,
296
- });
297
- return;
577
+ this.lastEmittedView = view;
578
+ this.viewSignal.set(view);
298
579
  }
299
- // Validate props against Zod schema
300
- const validation = validateViewProps(viewMeta.component, props, this._catalog);
301
- if (!validation.success) {
302
- // Call onError hook if prop validation fails
303
- if (this._options.onError) {
304
- this._options.onError(this, new Error(`Invalid props for component "${viewMeta.component}": ${validation.error?.message || "Unknown error"}`));
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);
305
584
  }
306
- console.error("View props validation failed:", validation.error);
307
- // Store view structure even if validation fails (allow app to handle gracefully)
308
- // Per RESEARCH.md open question: prefer error events over throwing in production
585
+ // On error: keep the last valid view (don't clear)
309
586
  }
310
- // Cache validated view structure (validated once at state entry)
311
- const newView = {
312
- component: viewMeta.component,
313
- props: validation.success ? validation.data : props,
314
- };
315
- this._lastViewMemo = {
316
- stateSignature,
317
- component: viewMeta.component,
318
- propsSignature,
319
- view: newView,
320
- };
321
- this._viewSignal.set(newView);
322
587
  }
323
588
  /**
324
589
  * Convenience dispose method for cleanup