@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.
- package/.oxfmtrc.json +3 -0
- package/.oxlintrc.json +3 -0
- package/README.md +454 -0
- package/dist/catalog/index.d.ts +12 -0
- package/dist/catalog/index.d.ts.map +1 -0
- package/dist/catalog/index.js +11 -0
- package/dist/catalog/index.js.map +1 -0
- package/dist/catalog/types.d.ts +36 -0
- package/dist/catalog/types.d.ts.map +1 -0
- package/dist/catalog/types.js +2 -0
- package/dist/catalog/types.js.map +1 -0
- package/dist/catalog/validate-binding.d.ts +21 -0
- package/dist/catalog/validate-binding.d.ts.map +1 -0
- package/dist/catalog/validate-binding.js +30 -0
- package/dist/catalog/validate-binding.js.map +1 -0
- package/dist/catalog/validate-props.d.ts +41 -0
- package/dist/catalog/validate-props.d.ts.map +1 -0
- package/dist/catalog/validate-props.js +95 -0
- package/dist/catalog/validate-props.js.map +1 -0
- package/dist/define-player.d.ts +110 -0
- package/dist/define-player.d.ts.map +1 -0
- package/dist/define-player.js +116 -0
- package/dist/define-player.js.map +1 -0
- package/dist/guards/compose.d.ts +136 -0
- package/dist/guards/compose.d.ts.map +1 -0
- package/dist/guards/compose.js +156 -0
- package/dist/guards/compose.js.map +1 -0
- package/dist/guards/helpers.d.ts +60 -0
- package/dist/guards/helpers.d.ts.map +1 -0
- package/dist/guards/helpers.js +91 -0
- package/dist/guards/helpers.js.map +1 -0
- package/dist/guards/index.d.ts +12 -0
- package/dist/guards/index.d.ts.map +1 -0
- package/dist/guards/index.js +11 -0
- package/dist/guards/index.js.map +1 -0
- package/dist/guards/types.d.ts +21 -0
- package/dist/guards/types.d.ts.map +1 -0
- package/dist/guards/types.js +2 -0
- package/dist/guards/types.js.map +1 -0
- package/dist/index.d.ts +22 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +21 -0
- package/dist/index.js.map +1 -0
- package/dist/player-actor.d.ts +143 -0
- package/dist/player-actor.d.ts.map +1 -0
- package/dist/player-actor.js +294 -0
- package/dist/player-actor.js.map +1 -0
- package/dist/routing/build-url.d.ts +27 -0
- package/dist/routing/build-url.d.ts.map +1 -0
- package/dist/routing/build-url.js +111 -0
- package/dist/routing/build-url.js.map +1 -0
- package/dist/routing/derive-route.d.ts +111 -0
- package/dist/routing/derive-route.d.ts.map +1 -0
- package/dist/routing/derive-route.js +144 -0
- package/dist/routing/derive-route.js.map +1 -0
- package/dist/routing/format-play-route-transitions.d.ts +31 -0
- package/dist/routing/format-play-route-transitions.d.ts.map +1 -0
- package/dist/routing/format-play-route-transitions.js +70 -0
- package/dist/routing/format-play-route-transitions.js.map +1 -0
- package/dist/routing/index.d.ts +13 -0
- package/dist/routing/index.d.ts.map +1 -0
- package/dist/routing/index.js +12 -0
- package/dist/routing/index.js.map +1 -0
- package/dist/routing/types.d.ts +25 -0
- package/dist/routing/types.d.ts.map +1 -0
- package/dist/routing/types.js +2 -0
- package/dist/routing/types.js.map +1 -0
- package/dist/signals/debounce.d.ts +18 -0
- package/dist/signals/debounce.d.ts.map +1 -0
- package/dist/signals/debounce.js +35 -0
- package/dist/signals/debounce.js.map +1 -0
- package/dist/signals/index.d.ts +3 -0
- package/dist/signals/index.d.ts.map +1 -0
- package/dist/signals/index.js +3 -0
- package/dist/signals/index.js.map +1 -0
- package/dist/signals/state-signal.d.ts +33 -0
- package/dist/signals/state-signal.d.ts.map +1 -0
- package/dist/signals/state-signal.js +41 -0
- package/dist/signals/state-signal.js.map +1 -0
- package/dist/types.d.ts +39 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/examples/simple-machine.ts +187 -0
- package/package.json +46 -0
- package/src/catalog/index.ts +12 -0
- package/src/catalog/types.ts +38 -0
- package/src/catalog/validate-binding.ts +35 -0
- package/src/catalog/validate-props.ts +109 -0
- package/src/define-player.ts +121 -0
- package/src/guards/compose.ts +169 -0
- package/src/guards/helpers.ts +104 -0
- package/src/guards/index.ts +12 -0
- package/src/guards/types.ts +23 -0
- package/src/index.ts +40 -0
- package/src/player-actor.ts +346 -0
- package/src/routing/build-url.ts +127 -0
- package/src/routing/derive-route.ts +152 -0
- package/src/routing/format-play-route-transitions.ts +77 -0
- package/src/routing/index.ts +13 -0
- package/src/routing/types.ts +26 -0
- package/src/signals/debounce.ts +38 -0
- package/src/signals/index.ts +2 -0
- package/src/signals/state-signal.ts +45 -0
- package/src/types.ts +47 -0
- package/test/derive-route.test.ts +166 -0
- package/test/devtools-integration.spec.ts +97 -0
- package/test/format-play-route-transitions-query.test.ts +187 -0
- package/test/guards-edge-cases.spec.ts +630 -0
- package/test/player-actor-basic.spec.ts +189 -0
- package/test/player-actor-edge-cases.spec.ts +769 -0
- package/test/routing-edge-cases.spec.ts +340 -0
- package/tsconfig.json +15 -0
- package/tsconfig.tsbuildinfo +1 -0
- 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,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
|
+
});
|