@xmachines/play-xstate 1.0.0-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (115) hide show
  1. package/.oxfmtrc.json +3 -0
  2. package/.oxlintrc.json +3 -0
  3. package/README.md +454 -0
  4. package/dist/catalog/index.d.ts +12 -0
  5. package/dist/catalog/index.d.ts.map +1 -0
  6. package/dist/catalog/index.js +11 -0
  7. package/dist/catalog/index.js.map +1 -0
  8. package/dist/catalog/types.d.ts +36 -0
  9. package/dist/catalog/types.d.ts.map +1 -0
  10. package/dist/catalog/types.js +2 -0
  11. package/dist/catalog/types.js.map +1 -0
  12. package/dist/catalog/validate-binding.d.ts +21 -0
  13. package/dist/catalog/validate-binding.d.ts.map +1 -0
  14. package/dist/catalog/validate-binding.js +30 -0
  15. package/dist/catalog/validate-binding.js.map +1 -0
  16. package/dist/catalog/validate-props.d.ts +41 -0
  17. package/dist/catalog/validate-props.d.ts.map +1 -0
  18. package/dist/catalog/validate-props.js +95 -0
  19. package/dist/catalog/validate-props.js.map +1 -0
  20. package/dist/define-player.d.ts +110 -0
  21. package/dist/define-player.d.ts.map +1 -0
  22. package/dist/define-player.js +116 -0
  23. package/dist/define-player.js.map +1 -0
  24. package/dist/guards/compose.d.ts +136 -0
  25. package/dist/guards/compose.d.ts.map +1 -0
  26. package/dist/guards/compose.js +156 -0
  27. package/dist/guards/compose.js.map +1 -0
  28. package/dist/guards/helpers.d.ts +60 -0
  29. package/dist/guards/helpers.d.ts.map +1 -0
  30. package/dist/guards/helpers.js +91 -0
  31. package/dist/guards/helpers.js.map +1 -0
  32. package/dist/guards/index.d.ts +12 -0
  33. package/dist/guards/index.d.ts.map +1 -0
  34. package/dist/guards/index.js +11 -0
  35. package/dist/guards/index.js.map +1 -0
  36. package/dist/guards/types.d.ts +21 -0
  37. package/dist/guards/types.d.ts.map +1 -0
  38. package/dist/guards/types.js +2 -0
  39. package/dist/guards/types.js.map +1 -0
  40. package/dist/index.d.ts +22 -0
  41. package/dist/index.d.ts.map +1 -0
  42. package/dist/index.js +21 -0
  43. package/dist/index.js.map +1 -0
  44. package/dist/player-actor.d.ts +143 -0
  45. package/dist/player-actor.d.ts.map +1 -0
  46. package/dist/player-actor.js +294 -0
  47. package/dist/player-actor.js.map +1 -0
  48. package/dist/routing/build-url.d.ts +27 -0
  49. package/dist/routing/build-url.d.ts.map +1 -0
  50. package/dist/routing/build-url.js +111 -0
  51. package/dist/routing/build-url.js.map +1 -0
  52. package/dist/routing/derive-route.d.ts +111 -0
  53. package/dist/routing/derive-route.d.ts.map +1 -0
  54. package/dist/routing/derive-route.js +144 -0
  55. package/dist/routing/derive-route.js.map +1 -0
  56. package/dist/routing/format-play-route-transitions.d.ts +31 -0
  57. package/dist/routing/format-play-route-transitions.d.ts.map +1 -0
  58. package/dist/routing/format-play-route-transitions.js +70 -0
  59. package/dist/routing/format-play-route-transitions.js.map +1 -0
  60. package/dist/routing/index.d.ts +13 -0
  61. package/dist/routing/index.d.ts.map +1 -0
  62. package/dist/routing/index.js +12 -0
  63. package/dist/routing/index.js.map +1 -0
  64. package/dist/routing/types.d.ts +25 -0
  65. package/dist/routing/types.d.ts.map +1 -0
  66. package/dist/routing/types.js +2 -0
  67. package/dist/routing/types.js.map +1 -0
  68. package/dist/signals/debounce.d.ts +18 -0
  69. package/dist/signals/debounce.d.ts.map +1 -0
  70. package/dist/signals/debounce.js +35 -0
  71. package/dist/signals/debounce.js.map +1 -0
  72. package/dist/signals/index.d.ts +3 -0
  73. package/dist/signals/index.d.ts.map +1 -0
  74. package/dist/signals/index.js +3 -0
  75. package/dist/signals/index.js.map +1 -0
  76. package/dist/signals/state-signal.d.ts +33 -0
  77. package/dist/signals/state-signal.d.ts.map +1 -0
  78. package/dist/signals/state-signal.js +41 -0
  79. package/dist/signals/state-signal.js.map +1 -0
  80. package/dist/types.d.ts +39 -0
  81. package/dist/types.d.ts.map +1 -0
  82. package/dist/types.js +2 -0
  83. package/dist/types.js.map +1 -0
  84. package/examples/simple-machine.ts +187 -0
  85. package/package.json +46 -0
  86. package/src/catalog/index.ts +12 -0
  87. package/src/catalog/types.ts +38 -0
  88. package/src/catalog/validate-binding.ts +35 -0
  89. package/src/catalog/validate-props.ts +109 -0
  90. package/src/define-player.ts +121 -0
  91. package/src/guards/compose.ts +169 -0
  92. package/src/guards/helpers.ts +104 -0
  93. package/src/guards/index.ts +12 -0
  94. package/src/guards/types.ts +23 -0
  95. package/src/index.ts +40 -0
  96. package/src/player-actor.ts +346 -0
  97. package/src/routing/build-url.ts +127 -0
  98. package/src/routing/derive-route.ts +152 -0
  99. package/src/routing/format-play-route-transitions.ts +77 -0
  100. package/src/routing/index.ts +13 -0
  101. package/src/routing/types.ts +26 -0
  102. package/src/signals/debounce.ts +38 -0
  103. package/src/signals/index.ts +2 -0
  104. package/src/signals/state-signal.ts +45 -0
  105. package/src/types.ts +47 -0
  106. package/test/derive-route.test.ts +166 -0
  107. package/test/devtools-integration.spec.ts +97 -0
  108. package/test/format-play-route-transitions-query.test.ts +187 -0
  109. package/test/guards-edge-cases.spec.ts +630 -0
  110. package/test/player-actor-basic.spec.ts +189 -0
  111. package/test/player-actor-edge-cases.spec.ts +769 -0
  112. package/test/routing-edge-cases.spec.ts +340 -0
  113. package/tsconfig.json +15 -0
  114. package/tsconfig.tsbuildinfo +1 -0
  115. package/vitest.config.ts +27 -0
@@ -0,0 +1,127 @@
1
+ import type { RouteContext } from "./types.js";
2
+ import { isAbsoluteRoute } from "./derive-route.js";
3
+
4
+ /**
5
+ * Build full URL from route template and context
6
+ *
7
+ * Per CONTEXT.md:
8
+ * - "currentRoute derivation: Full URL generation including query params, hash, base path"
9
+ * - "Parameters: String template syntax — /user/:id"
10
+ * - "Inheritance: relative paths inherit parent route"
11
+ *
12
+ * Per RESEARCH.md Pattern 3: Replace :param with context values
13
+ *
14
+ * @example
15
+ * ```typescript
16
+ * const url = buildRouteUrl('/user/:id', {
17
+ * id: '123',
18
+ * query: { tab: 'profile' },
19
+ * hash: 'section-1'
20
+ * });
21
+ * // Result: '/user/123?tab=profile#section-1'
22
+ * ```
23
+ *
24
+ * @param routeTemplate - Route path with :param placeholders
25
+ * @param context - Route context with parameters, query, hash
26
+ * @returns Full URL string
27
+ */
28
+ export const buildRouteUrl = (routeTemplate: string, context: RouteContext = {}): string => {
29
+ // Handle relative vs absolute paths
30
+ const basePath = context.basePath || "";
31
+ const isAbsolute = isAbsoluteRoute(routeTemplate);
32
+
33
+ // Build base URL
34
+ let url = isAbsolute ? routeTemplate : joinPaths(basePath, routeTemplate);
35
+
36
+ // Replace :param with context values
37
+ url = substituteParams(url, context);
38
+
39
+ // Append query params from context
40
+ if (context.query && typeof context.query === "object") {
41
+ const params = new URLSearchParams(context.query as any);
42
+ const queryString = params.toString();
43
+ if (queryString) {
44
+ url += `?${queryString}`;
45
+ }
46
+ }
47
+
48
+ // Append hash from context
49
+ if (context.hash && typeof context.hash === "string") {
50
+ url += `#${context.hash}`;
51
+ }
52
+
53
+ return url;
54
+ };
55
+
56
+ /**
57
+ * Substitute :param placeholders with context values
58
+ *
59
+ * Per RESEARCH.md: Replace :param with encodeURIComponent(context[param])
60
+ *
61
+ * Supports optional parameters with :param? syntax.
62
+ * - Optional parameters without values are removed entirely (including the /)
63
+ * - Required parameters without values log warnings
64
+ *
65
+ * Parameter lookup:
66
+ * - First checks context.routeParams[param] (common pattern in state machines)
67
+ * - Falls back to context[param] (flat context)
68
+ *
69
+ * @param template - URL template with :param or :param? syntax
70
+ * @param context - Context with parameter values (may have routeParams field)
71
+ * @returns URL with parameters substituted and double slashes cleaned
72
+ */
73
+ const substituteParams = (template: string, context: RouteContext): string => {
74
+ // Replace parameters, handling optional syntax
75
+ let hasOptionalRemoval = false;
76
+ const result = template.replace(/:(\w+)(\?)?/g, (_match, param, optional) => {
77
+ // Check routeParams first (common pattern), then flat context
78
+ const value = (context as any).routeParams?.[param] ?? context[param];
79
+
80
+ // Parameter has a non-empty value - substitute it
81
+ // For optional params, treat empty string as "no value"
82
+ if (value !== undefined && value !== null && value !== "") {
83
+ return encodeURIComponent(String(value));
84
+ }
85
+
86
+ // Optional parameter without value (or empty string) - remove the segment
87
+ if (optional === "?") {
88
+ hasOptionalRemoval = true;
89
+ return ""; // Will leave // in path, cleaned up below
90
+ }
91
+
92
+ // Required parameter missing - warn and use empty string
93
+ console.warn(`Route parameter '${param}' not found in context. Template: ${template}`);
94
+ return "";
95
+ });
96
+
97
+ // Clean up double slashes
98
+ let cleaned = result.replace(/\/+/g, "/");
99
+
100
+ // Only remove trailing slash if we removed optional parameters
101
+ // (preserves trailing slash for required params, which indicates an error)
102
+ if (hasOptionalRemoval && cleaned.endsWith("/")) {
103
+ cleaned = cleaned.slice(0, -1);
104
+ }
105
+
106
+ return cleaned;
107
+ };
108
+
109
+ /**
110
+ * Join base path and relative path
111
+ *
112
+ * Ensures single slash between paths
113
+ *
114
+ * @param base - Base path
115
+ * @param relative - Relative path
116
+ * @returns Joined path
117
+ */
118
+ const joinPaths = (base: string, relative: string): string => {
119
+ // Remove trailing slash from base
120
+ const normalizedBase = base.replace(/\/$/, "");
121
+
122
+ // Remove leading slash from relative
123
+ const normalizedRelative = relative.replace(/^\//, "");
124
+
125
+ // Join with single slash
126
+ return normalizedBase ? `${normalizedBase}/${normalizedRelative}` : `/${normalizedRelative}`;
127
+ };
@@ -0,0 +1,152 @@
1
+ import type { RouteMetadata, RouteObject } from "./types.js";
2
+
3
+ /**
4
+ * Derive route from XState state metadata
5
+ *
6
+ * Extracts route URL template from `meta.route` in the active state's metadata.
7
+ * Supports both string routes (`"/about"`) and object routes with path property
8
+ * (`{ path: "/about" }`). Returns null for states without route metadata (not
9
+ * all states need to be routable).
10
+ *
11
+ * **Architectural Context:** Implements **Actor Authority (INV-01)** by extracting
12
+ * routing information from state machine definitions rather than external configuration.
13
+ * The route is determined by the Actor's current state, not by infrastructure decisions.
14
+ *
15
+ * @param stateMeta - State metadata from snapshot.getMeta()
16
+ * @returns Route path template (may include :params) or null if no route found
17
+ *
18
+ * @example
19
+ * Basic route extraction
20
+ * ```typescript
21
+ * import { deriveRoute } from "@xmachines/play-xstate";
22
+ * import { setup } from "xstate";
23
+ *
24
+ * const machine = setup({}).createMachine({
25
+ * states: {
26
+ * about: {
27
+ * meta: { route: "/about", view: { component: "AboutPage" } }
28
+ * }
29
+ * }
30
+ * });
31
+ *
32
+ * const actor = createActor(machine);
33
+ * actor.start();
34
+ * const snapshot = actor.getSnapshot();
35
+ * const meta = snapshot.getMeta();
36
+ *
37
+ * const route = deriveRoute(meta);
38
+ * console.log(route); // "/about"
39
+ * ```
40
+ *
41
+ * @example
42
+ * Route with parameters
43
+ * ```typescript
44
+ * const machine = setup({}).createMachine({
45
+ * states: {
46
+ * profile: {
47
+ * meta: {
48
+ * route: "/profile/:userId",
49
+ * view: { component: "ProfilePage", userId: (ctx) => ctx.userId }
50
+ * }
51
+ * }
52
+ * }
53
+ * });
54
+ *
55
+ * const route = deriveRoute(snapshot.getMeta());
56
+ * console.log(route); // "/profile/:userId" (template, before substitution)
57
+ * ```
58
+ *
59
+ * @example
60
+ * Route object format
61
+ * ```typescript
62
+ * const machine = setup({}).createMachine({
63
+ * states: {
64
+ * dashboard: {
65
+ * meta: {
66
+ * route: { path: "/dashboard" },
67
+ * view: { component: "Dashboard" }
68
+ * }
69
+ * }
70
+ * }
71
+ * });
72
+ *
73
+ * const route = deriveRoute(snapshot.getMeta());
74
+ * console.log(route); // "/dashboard"
75
+ * ```
76
+ *
77
+ * @see {@link https://gitlab.com/xmachin-es/rfc/-/blob/main/src/play-v1.md | RFC Play v1}
78
+ * @see {@link buildRouteUrl} for URL construction with parameter substitution
79
+ * @see {@link isAbsoluteRoute} for checking path absoluteness
80
+ *
81
+ * @remarks
82
+ * This function checks `meta.route` for route definitions. States with `route: {}` config
83
+ * are routable. Parameter substitution happens via {@link buildRouteUrl}, not in this
84
+ * function (deriveRoute returns templates).
85
+ *
86
+ * **Non-routable States:** States without `meta.route` return `null`. This is intentional—
87
+ * not all states need routes. For example, intermediate loading states or substates may
88
+ * not correspond to distinct URLs.
89
+ */
90
+ export const deriveRoute = (stateMeta: Record<string, any>): string | null => {
91
+ // Iterate through active state nodes to find route information
92
+ for (const [_stateId, meta] of Object.entries(stateMeta)) {
93
+ if (!meta) continue;
94
+
95
+ // Check meta.route for routable states
96
+ if (meta.route) {
97
+ return normalizeRoute(meta.route);
98
+ }
99
+ }
100
+
101
+ return null;
102
+ };
103
+
104
+ /**
105
+ * Normalize route metadata to string path
106
+ *
107
+ * Handles both string and object formats for route definitions, ensuring consistent
108
+ * string output for URL construction.
109
+ *
110
+ * @param route - Route metadata (string or object with path property)
111
+ * @returns Normalized route path string
112
+ * @throws {Error} If route format is invalid
113
+ */
114
+ const normalizeRoute = (route: RouteMetadata): string => {
115
+ if (typeof route === "string") {
116
+ return route;
117
+ }
118
+
119
+ // Route object with path property
120
+ if (route && typeof route === "object" && "path" in route) {
121
+ return (route as RouteObject).path;
122
+ }
123
+
124
+ throw new Error(
125
+ `Invalid route metadata: ${JSON.stringify(route)}. Expected string or { path: string }`,
126
+ );
127
+ };
128
+
129
+ /**
130
+ * Check if route path is absolute
131
+ *
132
+ * Determines whether a route path is absolute (starts with `/`) or relative.
133
+ * Absolute paths don't inherit from parent routes, while relative paths can be
134
+ * composed with parent paths for nested routing.
135
+ *
136
+ * @param path - Route path to check
137
+ * @returns true if path starts with '/', false otherwise
138
+ *
139
+ * @example
140
+ * ```typescript
141
+ * import { isAbsoluteRoute } from "@xmachines/play-xstate";
142
+ *
143
+ * console.log(isAbsoluteRoute("/dashboard")); // true
144
+ * console.log(isAbsoluteRoute("settings")); // false
145
+ * console.log(isAbsoluteRoute("./about")); // false
146
+ * ```
147
+ *
148
+ * @see {@link deriveRoute} for route extraction
149
+ */
150
+ export const isAbsoluteRoute = (path: string): boolean => {
151
+ return path.startsWith("/");
152
+ };
@@ -0,0 +1,77 @@
1
+ import { assign } from "xstate";
2
+
3
+ /**
4
+ * Formats play.route transitions from declarative route configs
5
+ *
6
+ * Crawls machine states looking for states with meta.route and generates
7
+ * transitions that handle `play.route` events by matching event.to to state IDs.
8
+ *
9
+ * Inspired by XState's internal formatRouteTransitions (stateUtils.ts line 391).
10
+ *
11
+ * Usage:
12
+ * ```typescript
13
+ * const machineConfig = {
14
+ * id: "myMachine",
15
+ * states: {
16
+ * home: { id: "home", meta: { route: "/home" } },
17
+ * dashboard: { id: "dashboard", meta: { route: "/dashboard" } }
18
+ * }
19
+ * };
20
+ *
21
+ * const machine = createMachine(formatPlayRouteTransitions(machineConfig));
22
+ * ```
23
+ *
24
+ * This automatically generates play.route handlers at the root level that:
25
+ * - Match event.to against state IDs (e.g., event.to === "#home")
26
+ * - Target the appropriate state
27
+ * - Assign routeParams and queryParams from the event to context
28
+ *
29
+ * @param machineConfig - XState machine config (before createMachine)
30
+ * @returns Machine config with auto-generated play.route handlers
31
+ */
32
+ export function formatPlayRouteTransitions(machineConfig: any): any {
33
+ const routeTransitions: any[] = [];
34
+
35
+ const collectRoutes = (states: Record<string, any>, parentPath = "") => {
36
+ Object.entries(states).forEach(([key, stateConfig]) => {
37
+ const stateId = stateConfig.id || (parentPath ? `${parentPath}.${key}` : key);
38
+
39
+ if (stateConfig.meta?.route && stateConfig.id) {
40
+ // Generate transition for this routable state
41
+ const transition = {
42
+ target: `.${key}`, // Relative target from root
43
+ guard: ({ event }: { event: any }) => event.to === `#${stateConfig.id}`,
44
+ reenter: true, // Enable context updates on self-transitions
45
+ actions: assign({
46
+ routeParams: ({ event }: { event: any }) => event.params || {},
47
+ queryParams: ({ event }: { event: any }) => event.query || {},
48
+ }),
49
+ };
50
+
51
+ routeTransitions.push(transition);
52
+ }
53
+
54
+ // Recursively collect from child states
55
+ if (stateConfig.states) {
56
+ collectRoutes(stateConfig.states, stateId);
57
+ }
58
+ });
59
+ };
60
+
61
+ if (machineConfig.states) {
62
+ collectRoutes(machineConfig.states);
63
+ }
64
+
65
+ // Add play.route handler to root if we found any routes
66
+ if (routeTransitions.length > 0) {
67
+ return {
68
+ ...machineConfig,
69
+ on: {
70
+ ...machineConfig.on,
71
+ "play.route": routeTransitions,
72
+ },
73
+ };
74
+ }
75
+
76
+ return machineConfig;
77
+ }
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Route derivation and URL building utilities
3
+ *
4
+ * Provides functions for extracting routes from state metadata
5
+ * and building full URLs with parameter substitution.
6
+ *
7
+ * @packageDocumentation
8
+ */
9
+
10
+ export { deriveRoute, isAbsoluteRoute } from "./derive-route.js";
11
+ export { buildRouteUrl } from "./build-url.js";
12
+ export { formatPlayRouteTransitions } from "./format-play-route-transitions.js";
13
+ export type { RouteMetadata, RouteObject, RouteContext } from "./types.js";
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Route metadata from state machine
3
+ *
4
+ * Per CONTEXT.md: Both simple strings and objects supported
5
+ */
6
+ export type RouteMetadata = string | RouteObject;
7
+
8
+ /**
9
+ * Route object with additional metadata
10
+ */
11
+ export interface RouteObject {
12
+ /** Route path template (e.g., '/user/:id') */
13
+ path: string;
14
+ /** Additional route metadata (title, etc.) */
15
+ [key: string]: any;
16
+ }
17
+
18
+ /**
19
+ * Route build context from machine context
20
+ */
21
+ export interface RouteContext {
22
+ /** Base path for relative routes */
23
+ basePath?: string;
24
+ /** Route parameters to substitute */
25
+ [key: string]: any;
26
+ }
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Microtask debouncing for glitch-free signal updates
3
+ *
4
+ * Per CONTEXT.md: "Microtask batching for glitch-free guarantee"
5
+ * Per RESEARCH.md Pattern 5: Updates batch in microtask queue
6
+ *
7
+ * Leverages TC39 Signal primitives for proper batching.
8
+ */
9
+
10
+ /**
11
+ * Schedule callback in microtask queue
12
+ *
13
+ * Multiple calls in same tick coalesce into single execution.
14
+ *
15
+ * @param callback - Function to execute in microtask
16
+ * @returns Function to cancel scheduled execution
17
+ */
18
+ export const scheduleMicrotask = (callback: () => void): (() => void) => {
19
+ let scheduled = false;
20
+ let cancelled = false;
21
+
22
+ const execute = () => {
23
+ if (!cancelled) {
24
+ callback();
25
+ }
26
+ scheduled = false;
27
+ };
28
+
29
+ if (!scheduled) {
30
+ scheduled = true;
31
+ queueMicrotask(execute);
32
+ }
33
+
34
+ // Return cancel function
35
+ return () => {
36
+ cancelled = true;
37
+ };
38
+ };
@@ -0,0 +1,2 @@
1
+ export { StateSignalManager } from "./state-signal.js";
2
+ export { scheduleMicrotask } from "./debounce.js";
@@ -0,0 +1,45 @@
1
+ import { Signal } from "@xmachines/play-signals";
2
+
3
+ /**
4
+ * Manage state signal with synchronous updates
5
+ *
6
+ * Per CONTEXT.md: "Update timing: On stable states only"
7
+ * Per RESEARCH.md: Only update when snapshot.status === 'active'
8
+ *
9
+ * Note: Previously used microtask batching, but this broke guard redirects.
10
+ * XState already provides coalescence - if multiple transitions occur in a single send(),
11
+ * the subscription callback only fires once with the final state.
12
+ */
13
+ export class StateSignalManager<TSnapshot = any> {
14
+ private _signal: Signal.State<TSnapshot>;
15
+
16
+ constructor(initialSnapshot: TSnapshot) {
17
+ this._signal = new Signal.State(initialSnapshot);
18
+ }
19
+
20
+ /**
21
+ * Get the signal instance
22
+ */
23
+ get signal(): Signal.State<TSnapshot> {
24
+ return this._signal;
25
+ }
26
+
27
+ /**
28
+ * Update signal synchronously
29
+ *
30
+ * Synchronous updates ensure that guard-triggered redirects are immediately
31
+ * visible to router bridges, allowing them to sync the browser URL.
32
+ *
33
+ * @param snapshot - New snapshot to set
34
+ */
35
+ scheduleUpdate(snapshot: TSnapshot): void {
36
+ this._signal.set(snapshot);
37
+ }
38
+
39
+ /**
40
+ * Cleanup (no-op since we're synchronous now)
41
+ */
42
+ dispose(): void {
43
+ // No cleanup needed for synchronous updates
44
+ }
45
+ }
package/src/types.ts ADDED
@@ -0,0 +1,47 @@
1
+ import type { AnyStateMachine } from "xstate";
2
+ import type { PlayerActor as PlayerActorClass } from "./player-actor.js";
3
+
4
+ /**
5
+ * Configuration for definePlayer()
6
+ *
7
+ * Per CONTEXT.md: Single config object with machine, catalog, options
8
+ */
9
+ export interface PlayerConfig<TMachine extends AnyStateMachine, TCatalog = any> {
10
+ /** XState v5 state machine */
11
+ machine: TMachine;
12
+
13
+ /** UI component catalog (optional - allows machines without UI) */
14
+ catalog?: TCatalog;
15
+
16
+ /** Lifecycle hooks and configuration */
17
+ options?: PlayerOptions;
18
+ }
19
+
20
+ /**
21
+ * Player lifecycle hooks
22
+ *
23
+ * Per CONTEXT.md: Rich set of hooks for observability
24
+ */
25
+ export interface PlayerOptions {
26
+ /** Called when actor starts */
27
+ onStart?: (actor: any) => void;
28
+
29
+ /** Called when actor stops */
30
+ onStop?: (actor: any) => void;
31
+
32
+ /** Called on every state transition */
33
+ onTransition?: (actor: any, prevState: any, nextState: any) => void;
34
+
35
+ /** Called when state signal changes */
36
+ onStateChange?: (actor: any, state: any) => void;
37
+
38
+ /** Called on actor errors */
39
+ onError?: (actor: any, error: Error) => void;
40
+ }
41
+
42
+ /**
43
+ * Factory function returned by definePlayer()
44
+ *
45
+ * Per CONTEXT.md: Factory supports creating multiple actor instances
46
+ */
47
+ export type PlayerFactory<TInput = any> = (input?: TInput) => PlayerActorClass<any>;
@@ -0,0 +1,166 @@
1
+ import { describe, expect, test } from "vitest";
2
+ import { deriveRoute } from "../src/routing/derive-route.js";
3
+
4
+ describe("deriveRoute", () => {
5
+ describe("meta.route pattern", () => {
6
+ test("extracts path from meta.route", () => {
7
+ const meta = {
8
+ "root.about": {
9
+ route: "/about",
10
+ view: { component: "About" },
11
+ },
12
+ };
13
+ const result = deriveRoute(meta);
14
+ expect(result).toBe("/about");
15
+ });
16
+
17
+ test("handles multiple states and finds first with meta.route", () => {
18
+ const meta = {
19
+ root: {},
20
+ "root.authenticated": {},
21
+ "root.authenticated.home": {
22
+ route: "/home",
23
+ view: { component: "Home" },
24
+ },
25
+ };
26
+ const result = deriveRoute(meta);
27
+ expect(result).toBe("/home");
28
+ });
29
+
30
+ test("handles meta.route as route object with path property", () => {
31
+ const meta = {
32
+ "root.profile": {
33
+ route: { path: "/profile", title: "Profile" },
34
+ view: { component: "Profile" },
35
+ },
36
+ };
37
+ const result = deriveRoute(meta);
38
+ expect(result).toBe("/profile");
39
+ });
40
+
41
+ test("normalizes various meta.route formats", () => {
42
+ const testCases = [
43
+ { route: "/simple", expected: "/simple" },
44
+ { route: { path: "/object" }, expected: "/object" },
45
+ ];
46
+
47
+ testCases.forEach(({ route, expected }) => {
48
+ const meta = { "root.test": { route } };
49
+ expect(deriveRoute(meta)).toBe(expected);
50
+ });
51
+ });
52
+ });
53
+
54
+ describe("Precedence rules", () => {
55
+ test("prioritizes meta.route over meta.path within same state", () => {
56
+ const meta = {
57
+ "root.test": {
58
+ path: "/path-value",
59
+ route: "/route-value",
60
+ },
61
+ };
62
+ // When both exist on same state, meta.route takes precedence
63
+ const result = deriveRoute(meta);
64
+ expect(result).toBe("/route-value");
65
+ });
66
+ });
67
+
68
+ describe("Edge cases", () => {
69
+ test("returns null when no route or path in any state", () => {
70
+ const meta = {
71
+ root: {},
72
+ "root.loading": {
73
+ view: { component: "Loading" },
74
+ },
75
+ };
76
+ expect(deriveRoute(meta)).toBeNull();
77
+ });
78
+
79
+ test("handles empty meta object", () => {
80
+ const meta = {};
81
+ expect(deriveRoute(meta)).toBeNull();
82
+ });
83
+
84
+ test("handles null/undefined meta values", () => {
85
+ const meta = {
86
+ "root.a": null,
87
+ "root.b": undefined,
88
+ "root.c": {
89
+ route: "/valid",
90
+ },
91
+ };
92
+ expect(deriveRoute(meta)).toBe("/valid");
93
+ });
94
+
95
+ test("skips states without route metadata", () => {
96
+ const meta = {
97
+ root: { other: "data" },
98
+ "root.loading": { view: {} },
99
+ "root.error": { message: "error" },
100
+ "root.success": {
101
+ route: "/success",
102
+ },
103
+ };
104
+ expect(deriveRoute(meta)).toBe("/success");
105
+ });
106
+
107
+ test("handles meta with only view metadata (no routing)", () => {
108
+ const meta = {
109
+ "root.component": {
110
+ view: { component: "SomeComponent" },
111
+ },
112
+ };
113
+ expect(deriveRoute(meta)).toBeNull();
114
+ });
115
+ });
116
+
117
+ describe("Real-world scenarios", () => {
118
+ test("auth machine login state", () => {
119
+ const meta = {
120
+ root: {},
121
+ "root.public": {},
122
+ "root.public.login": {
123
+ route: "/",
124
+ view: { component: "Login" },
125
+ },
126
+ };
127
+ expect(deriveRoute(meta)).toBe("/");
128
+ });
129
+
130
+ test("auth machine authenticated home state", () => {
131
+ const meta = {
132
+ root: {},
133
+ "root.authenticated": {},
134
+ "root.authenticated.home": {
135
+ route: "/home",
136
+ view: { component: "Home" },
137
+ },
138
+ };
139
+ expect(deriveRoute(meta)).toBe("/home");
140
+ });
141
+
142
+ test("auth machine about state", () => {
143
+ const meta = {
144
+ root: {},
145
+ "root.public": {},
146
+ "root.public.about": {
147
+ route: "/about",
148
+ view: { component: "About" },
149
+ },
150
+ };
151
+ expect(deriveRoute(meta)).toBe("/about");
152
+ });
153
+
154
+ test("auth machine contact state", () => {
155
+ const meta = {
156
+ root: {},
157
+ "root.public": {},
158
+ "root.public.contact": {
159
+ route: "/contact",
160
+ view: { component: "Contact" },
161
+ },
162
+ };
163
+ expect(deriveRoute(meta)).toBe("/contact");
164
+ });
165
+ });
166
+ });