@xmachines/play-router 2.0.0 → 2.1.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 (58) hide show
  1. package/README.md +93 -76
  2. package/dist/base-route-map.d.ts +63 -57
  3. package/dist/base-route-map.d.ts.map +1 -1
  4. package/dist/base-route-map.js +65 -59
  5. package/dist/base-route-map.js.map +1 -1
  6. package/dist/build-tree.d.ts +13 -12
  7. package/dist/build-tree.d.ts.map +1 -1
  8. package/dist/build-tree.js +30 -28
  9. package/dist/build-tree.js.map +1 -1
  10. package/dist/create-route-map-from-tree.d.ts +15 -15
  11. package/dist/create-route-map-from-tree.js +15 -15
  12. package/dist/create-route-map.d.ts +18 -16
  13. package/dist/create-route-map.d.ts.map +1 -1
  14. package/dist/create-route-map.js +10 -9
  15. package/dist/create-route-map.js.map +1 -1
  16. package/dist/errors.d.ts +40 -38
  17. package/dist/errors.d.ts.map +1 -1
  18. package/dist/errors.js +40 -38
  19. package/dist/errors.js.map +1 -1
  20. package/dist/extract-routes.d.ts +8 -7
  21. package/dist/extract-routes.d.ts.map +1 -1
  22. package/dist/extract-routes.js +31 -27
  23. package/dist/extract-routes.js.map +1 -1
  24. package/dist/find-route.d.ts +18 -15
  25. package/dist/find-route.d.ts.map +1 -1
  26. package/dist/find-route.js +42 -38
  27. package/dist/find-route.js.map +1 -1
  28. package/dist/index.d.ts +6 -1
  29. package/dist/index.d.ts.map +1 -1
  30. package/dist/index.js +11 -10
  31. package/dist/index.js.map +1 -1
  32. package/dist/machine-to-graph.d.ts +3 -2
  33. package/dist/machine-to-graph.d.ts.map +1 -1
  34. package/dist/machine-to-graph.js +20 -19
  35. package/dist/machine-to-graph.js.map +1 -1
  36. package/dist/query.d.ts +39 -37
  37. package/dist/query.d.ts.map +1 -1
  38. package/dist/query.js +62 -57
  39. package/dist/query.js.map +1 -1
  40. package/dist/router-bridge-base.d.ts +208 -190
  41. package/dist/router-bridge-base.d.ts.map +1 -1
  42. package/dist/router-bridge-base.js +235 -211
  43. package/dist/router-bridge-base.js.map +1 -1
  44. package/dist/router-sync.d.ts +41 -35
  45. package/dist/router-sync.d.ts.map +1 -1
  46. package/dist/router-sync.js +53 -45
  47. package/dist/router-sync.js.map +1 -1
  48. package/dist/types.d.ts +165 -147
  49. package/dist/types.d.ts.map +1 -1
  50. package/dist/url-pattern-utils.d.ts +53 -47
  51. package/dist/url-pattern-utils.d.ts.map +1 -1
  52. package/dist/url-pattern-utils.js +61 -55
  53. package/dist/url-pattern-utils.js.map +1 -1
  54. package/dist/validate-routes.d.ts +32 -31
  55. package/dist/validate-routes.d.ts.map +1 -1
  56. package/dist/validate-routes.js +30 -29
  57. package/dist/validate-routes.js.map +1 -1
  58. package/package.json +6 -5
@@ -1,17 +1,18 @@
1
1
  /**
2
- * RouterBridgeBase — Abstract base class for all framework router adapters
2
+ * RouterBridgeBase — the abstract base class of every framework router adapter
3
3
  *
4
- * Captures the 90% identical logic from all existing router bridges:
5
- * - TC39 Signal watcher for actor → router direction
6
- * - `lastSyncedPath` for echo suppression in the actorrouter direction
7
- * - `isProcessingNavigation` flag for guard-redirect loop prevention in `syncActorFromRouter` only
8
- * - `syncRouterFromActor` / `syncActorFromRouter` with URL parameter extraction
9
- * - connect / disconnect lifecycle matching the RouterBridge protocol
4
+ * The class holds the logic that each router bridge shares, and that is 90% of the
5
+ * code of every bridge:
6
+ * - The TC39 Signal watcher of the direction from the actor to the router
7
+ * - `lastSyncedPath`, for the echo suppression in the direction from the actor to the router
8
+ * - The `isProcessingNavigation` flag, which stops a loop of a guard redirect in `syncActorFromRouter` only
9
+ * - `syncRouterFromActor` and `syncActorFromRouter`, with the read of each URL parameter
10
+ * - The connect and disconnect lifecycle of the RouterBridge protocol
10
11
  *
11
- * Subclasses implement only the 3 framework-specific abstract methods:
12
- * - navigateRouter(path): How to tell the framework router to navigate
13
- * - watchRouterChanges(): How to subscribe to router location changes
14
- * - unwatchRouterChanges(): How to unsubscribe from router location changes
12
+ * A subclass implements the 3 abstract methods of its framework only:
13
+ * - navigateRouter(path): how the bridge tells the framework router to navigate
14
+ * - watchRouterChanges(): how the bridge subscribes to each location change of the router
15
+ * - unwatchRouterChanges(): how the bridge cancels that subscription
15
16
  *
16
17
  * @example
17
18
  * ```typescript
@@ -39,7 +40,7 @@
39
40
  * }
40
41
  * ```
41
42
  *
42
- * @see [Play RFC](../../docs/rfc/play.md) - Invariant INV-04
43
+ * @see [Play RFC](../../docs/rfc/play.md) - invariant INV-04
43
44
  */
44
45
  import { Signal, watchSignal } from "@xmachines/play-signals";
45
46
  import { DuplicateBridgeError, RouterSyncError } from "./errors.js";
@@ -47,79 +48,86 @@ import { buildPlayRouteEvent, extractQuery, extractRouteParams, matchRouteMap, s
47
48
  import { URLPatternUnavailableError } from "./errors.js";
48
49
  import { isParameterizedPattern } from "./url-pattern-utils.js";
49
50
  /**
50
- * Module-level registry of actors that currently have an active bridge connection.
51
+ * The registry of the actors with an active bridge connection, at the module level.
51
52
  *
52
- * Keyed on the actor instance (WeakMap no reference retention). Used to detect and
53
- * reject duplicate `connect()` calls for the same actor, which would produce duplicate
54
- * `play.route` events and conflicting `lastSyncedPath` echo-suppression state.
53
+ * The key is the actor instance, and the registry is a WeakMap, which holds no
54
+ * reference. The class uses it to find and refuse a second `connect()` call of the
55
+ * same actor. Such a call makes two `play.route` events for one navigation, and it
56
+ * makes two `lastSyncedPath` values of the echo suppression that contradict each
57
+ * other.
55
58
  *
56
59
  * @internal
57
60
  */
58
61
  const activeBridges = new WeakMap();
59
62
  /**
60
- * Abstract base class for all `@xmachines` router adapter bridges.
63
+ * The abstract base class of every router adapter bridge of `@xmachines`.
61
64
  *
62
- * Implements RouterBridge protocol and contains all common bridge logic.
63
- * Subclasses only need to implement the 3 abstract methods that differ
64
- * between frameworks.
65
+ * The class implements the RouterBridge protocol, and it holds every part of the
66
+ * bridge logic that the adapters share. A subclass implements the 3 abstract methods
67
+ * that are different in each framework, and it implements nothing more.
65
68
  */
66
69
  export class RouterBridgeBase {
67
70
  actor;
68
71
  routeMap;
69
- // ── Common state (identical across all 4 existing bridges) ──
72
+ // ── The common state. It is identical in each of the 4 bridges ──
70
73
  isConnected = false;
71
74
  hasConnectedOnce = false;
72
75
  lastSyncedPath = null;
73
76
  /**
74
- * Guards `syncActorFromRouter` against re-entrant calls triggered by the
75
- * actor's own guard redirects (router→actor send signal fires actor→router
76
- * push another syncActorFromRouter before the first one returns).
77
+ * The flag guards `syncActorFromRouter` against a re-entrant call from a guard
78
+ * redirect of the actor itself. Such a call has this sequence: the bridge sends to
79
+ * the actor, the signal fires, the bridge pushes to the router, and a second
80
+ * `syncActorFromRouter` call starts before the first one returns.
77
81
  *
78
- * NOT used for actor→router echo suppression that is handled exclusively by
79
- * `lastSyncedPath`, which is updated before `navigateRouter()` is called so
80
- * any router callback for the same path short-circuits at the
81
- * `sanitized === lastSyncedPath` check in `syncActorFromRouter`.
82
+ * The flag is NOT the echo suppression of the direction from the actor to the
83
+ * router. `lastSyncedPath` does that work alone: the bridge writes it before the
84
+ * `navigateRouter()` call. Therefore each router callback of the same path stops at
85
+ * the `sanitized === lastSyncedPath` test in `syncActorFromRouter`.
82
86
  */
83
87
  isProcessingNavigation = false;
84
88
  routeWatcher = null;
85
89
  /**
86
- * @param actor - A `RoutableActor` exposing `currentRoute`, `initialRoute`, and `send`.
87
- * @param routeMap - Bidirectional route map for `stateId path` resolution.
88
- * Provide `getStateIdByPath` and `getPathByStateId`. Framework adapters
89
- * typically wrap the result of `createRouteMap(machine)` or an equivalent.
90
- * `getPathByStateId` may be keyed on either the `"#stateId"` or the bare
91
- * `"stateId"` form both forms are tried automatically by the bridge, so
92
- * custom implementations (e.g. plain test objects) need only handle one.
90
+ * @param actor - A `RoutableActor`, with `currentRoute`, `initialRoute`, and `send`.
91
+ * @param routeMap - The route map of both directions, for the resolution between a
92
+ * `stateId` and a `path`. Give `getStateIdByPath` and `getPathByStateId`. A
93
+ * framework adapter usually wraps the result of `createRouteMap(machine)`, or an
94
+ * equivalent value. The key of `getPathByStateId` is the form `"#stateId"` or the
95
+ * bare form `"stateId"`. The bridge tries both forms. Therefore an implementation
96
+ * of your own, for example a plain test object, handles one form only.
93
97
  */
94
98
  constructor(actor, routeMap) {
95
99
  this.actor = actor;
96
100
  this.routeMap = routeMap;
97
- // Initialize lastSyncedPath to actor's current route (prevents initial sync loop).
98
- // null means "nothing synced yet" distinct from any real path string.
101
+ // Set lastSyncedPath to the current route of the actor. This stops a loop in the
102
+ // first synchronization. The value null means "nothing is in step yet", and it is
103
+ // therefore different from each real path string.
99
104
  this.lastSyncedPath = this.actor.currentRoute.get() ?? null;
100
105
  }
101
- // ── RouterBridge protocol (final subclasses must not override) ──
106
+ // ── The RouterBridge protocol. It is final: a subclass must override nothing here ──
102
107
  /**
103
- * Connect the router bridge to the Actor.
108
+ * Connects the router bridge to the Actor.
104
109
  *
105
- * Sets up the TC39 Signal watcher for actor router direction and
106
- * starts watching router changes (framework-specific).
110
+ * The method installs the TC39 Signal watcher of the direction from the actor to the
111
+ * router. It then starts the watch of the router changes, which each framework does
112
+ * in its own way.
107
113
  *
108
- * Ordering here is part of the bridge contract:
109
- * - `lastSyncedPath` is seeded in the constructor from `actor.currentRoute`
110
- * - the actor watcher is installed before adapter router subscriptions
111
- * - initial sync then resolves deep-link vs restore using `actor.initialRoute`
114
+ * The order of these steps is part of the contract of the bridge:
115
+ * - The constructor seeds `lastSyncedPath` from `actor.currentRoute`
116
+ * - The method installs the actor watcher before the router subscriptions of the adapter
117
+ * - The first synchronization then separates a deep link from a restore, with `actor.initialRoute`
112
118
  *
113
- * Adapters that need custom initial-sync behavior should override
114
- * `getInitialRouterPath()` rather than reordering `connect()` steps.
119
+ * An adapter that needs a different behavior of the first synchronization overrides
120
+ * `getInitialRouterPath()`. It does not change the order of the steps of
121
+ * `connect()`.
115
122
  */
116
123
  connect() {
117
124
  if (this.isConnected) {
118
125
  return;
119
126
  }
120
- // Enforce single-bridge-per-actor: two connected bridges for the same actor
121
- // would both receive every router navigation event, sending duplicate play.route
122
- // events and producing conflicting lastSyncedPath echo-suppression state.
127
+ // Permit one bridge for each actor: two connected bridges of the same actor both
128
+ // receive every navigation event of the router. They therefore send two play.route
129
+ // events, and their lastSyncedPath values of the echo suppression contradict each
130
+ // other.
123
131
  const existingBridge = activeBridges.get(this.actor);
124
132
  if (existingBridge !== undefined && existingBridge !== this) {
125
133
  throw new DuplicateBridgeError();
@@ -127,41 +135,42 @@ export class RouterBridgeBase {
127
135
  activeBridges.set(this.actor, this);
128
136
  this.isConnected = true;
129
137
  this.hasConnectedOnce = true;
130
- // Set up TC39 Signal watcher for actor router direction
138
+ // Install the TC39 Signal watcher of the direction from the actor to the router
131
139
  this.routeWatcher = createRouteWatcher(this.actor.currentRoute, (route) => {
132
140
  this.syncRouterFromActor(route);
133
141
  });
134
- // Start watching router changes (framework-specific)
142
+ // Start the watch of the router changes. Each framework does this in its own way
135
143
  this.watchRouterChanges();
136
- // Initial sync: router actor takes priority over actor router.
144
+ // The first synchronization: the direction from the router to the actor has the
145
+ // priority over the other direction.
137
146
  //
138
- // If the page loaded on a URL other than the actor's initial state (e.g. the
139
- // user typed "/about" directly, or followed a deep link), the router already
140
- // knows the correct path but the actor is still at its initial state ("/").
141
- // We must drive the actor to match the URL, not overwrite the URL with the
142
- // actor's default.
147
+ // The page can load on a URL that is not the initial state of the actor. For
148
+ // example, the user types "/about", or the user follows a deep link. The router
149
+ // holds the correct path then, and the actor is still at its initial state ("/").
150
+ // The code must therefore drive the actor to the URL, and it must not write the
151
+ // default of the actor to the URL.
143
152
  //
144
- // router.subscribe() only fires on *future* navigation events it does NOT
145
- // fire for the already-loaded location. Subclasses that can read the router's
146
- // current location synchronously should override getInitialRouterPath().
153
+ // router.subscribe() fires on a *later* navigation event only. It does NOT fire for
154
+ // the location that the page loaded. A subclass that can read the current location
155
+ // of its router synchronously overrides getInitialRouterPath().
147
156
  const initialRouterPath = this.getInitialRouterPath();
148
157
  const initialRouterSearch = this.getInitialRouterSearch();
149
158
  const initialActorRoute = this.actor.currentRoute.get();
150
159
  if (typeof initialRouterPath === "string") {
151
- // Check if actor is already at the router's current location.
152
- // Actor currentRoute may be a stateId (e.g. "#app.home") while initialRouterPath
153
- // is a URL path (e.g. "/home"). Resolve the path to a stateId before comparing
154
- // to avoid a false "they differ" that would trigger an unnecessary initial sync.
160
+ // Test if the actor is at the current location of the router already.
161
+ // The currentRoute of the actor can be a stateId, for example "#app.home", and
162
+ // initialRouterPath is a URL path, for example "/home". Therefore resolve the path
163
+ // to a stateId before the comparison. Without this step, a false "the two are
164
+ // different" starts a first synchronization that nothing needs.
155
165
  const resolvedStateId = this.routeMap.getStateIdByPath(sanitizePathname(initialRouterPath) ?? initialRouterPath);
156
- // The actor is already at the router's location when either side matches
157
- // directly, or when the actor route is a stateId that resolves to the same
158
- // registered path as the matched stateId. Lookups go through
159
- // lookupPathByStateId, which tries both the "#stateId" and bare "stateId"
160
- // forms automatically so a route map storing "#about" recognizes an
161
- // actor route of "about" (and vice versa) even when the map is a custom
162
- // structural implementation keyed on a single form. Concrete-path actor
163
- // routes (starting with "/") are compared directly against the router
164
- // path only.
166
+ // The actor is at the location of the router in two cases: the two values match
167
+ // directly, or the actor route is a stateId that resolves to the same registered
168
+ // path as the stateId of the match. Each lookup goes through lookupPathByStateId,
169
+ // which tries the form "#stateId" and also the bare form "stateId". Therefore a
170
+ // route map with "#about" recognizes an actor route of "about", and the opposite
171
+ // also works, and this is correct for a structural map of your own that holds one
172
+ // form. An actor route with a concrete path, which starts with "/", goes to a direct
173
+ // comparison with the router path only.
165
174
  const actorAlreadyAtRouterLocation = resolvedStateId !== null &&
166
175
  resolvedStateId !== undefined &&
167
176
  (resolvedStateId === initialActorRoute ||
@@ -172,17 +181,19 @@ export class RouterBridgeBase {
172
181
  this.lookupPathByStateId(initialActorRoute) ===
173
182
  this.lookupPathByStateId(resolvedStateId)));
174
183
  if (!actorAlreadyAtRouterLocation) {
175
- // Router path differs from actor route but is this a deep-link or a restore?
184
+ // The path of the router is different from the actor route. Is this a deep link, or
185
+ // is it a restore?
176
186
  //
177
- // Deep-link: router is at a non-initial URL the actor hasn't seen yet.
178
- // router wins: syncActorFromRouter (guards then evaluate access).
187
+ // A deep link: the router is at a URL that is not the initial one, and the actor saw
188
+ // that URL not yet.
189
+ // → the router wins: syncActorFromRouter. The guards then decide the access.
179
190
  //
180
- // Restore: browser is at the machine's initial URL while the actor was
181
- // restored to a different route from a snapshot.
182
- // → actor wins: push actor's restored route to the router.
191
+ // A restore: the browser is at the initial URL of the machine, and a snapshot
192
+ // restored the actor to a different route.
193
+ // → the actor wins: push the restored route of the actor to the router.
183
194
  //
184
- // Detection: if the router URL equals the machine's initial route AND the
185
- // actor is at a different route, this is a restore scenario.
195
+ // The detection: the URL of the router is the initial route of the machine, AND the
196
+ // actor is at a different route. This is then a restore.
186
197
  if (initialActorRoute &&
187
198
  initialRouterPath === this.actor.initialRoute &&
188
199
  initialActorRoute !== this.actor.initialRoute) {
@@ -194,19 +205,22 @@ export class RouterBridgeBase {
194
205
  }
195
206
  }
196
207
  else if (initialActorRoute && initialRouterPath === null) {
197
- // Explicit null: no router path available (restore/bootstrap) push actor route.
198
- // undefined means the adapter handles initial sync itself — fall through.
208
+ // An explicit null: no path of the router is available, from a restore or from a
209
+ // bootstrap push the actor route.
210
+ // The value undefined means that the adapter does the first synchronization itself.
211
+ // Continue.
199
212
  this.pushResolvedRoute(initialActorRoute);
200
213
  }
201
214
  else if (initialActorRoute && initialActorRoute !== this.lastSyncedPath) {
202
- // No router path overridesync router from actor (original behaviour)
215
+ // No path of the router replaces the actor route write the router from the actor, as before
203
216
  this.syncRouterFromActor(initialActorRoute);
204
217
  }
205
218
  }
206
219
  /**
207
- * Disconnect the router bridge from the Actor.
220
+ * Disconnects the router bridge from the Actor.
208
221
  *
209
- * Stops signal watching and unregisters framework-specific router listener.
222
+ * The method stops the watch of the signal, and it removes the router listener of the
223
+ * framework.
210
224
  */
211
225
  disconnect() {
212
226
  const hadRouteWatcher = this.routeWatcher !== null;
@@ -215,7 +229,7 @@ export class RouterBridgeBase {
215
229
  this.routeWatcher.unwatch();
216
230
  }
217
231
  catch {
218
- // Ignore detached watcher errors to keep disconnect idempotent.
232
+ // Ignore an error of a detached watcher. disconnect() therefore stays idempotent.
219
233
  }
220
234
  }
221
235
  this.routeWatcher = null;
@@ -224,32 +238,33 @@ export class RouterBridgeBase {
224
238
  }
225
239
  this.isProcessingNavigation = false;
226
240
  this.isConnected = false;
227
- // Release the actor slot so a new bridge can connect to the same actor.
228
- // Only clear if this bridge is the currently registered one a second bridge
229
- // that was rejected at connect() time must not evict the legitimate bridge.
241
+ // Free the slot of the actor, so that a new bridge can connect to the same actor.
242
+ // Clear the slot only when this bridge is the registered one: a second bridge that
243
+ // connect() refused must not remove the legitimate bridge.
230
244
  if (activeBridges.get(this.actor) === this) {
231
245
  activeBridges.delete(this.actor);
232
246
  }
233
247
  }
234
- // ── Sync methods (protected, overridable if subclass needs custom behavior) ──
248
+ // ── The sync methods. They are protected, and a subclass overrides one for its own behavior ──
235
249
  /**
236
- * Sync router location when actor route signal changes.
250
+ * Writes the location of the router when the route signal of the actor changes.
237
251
  *
238
- * Resolves the actor route to a concrete URL path and calls navigateRouter()
239
- * for framework-specific navigation. When the route cannot be resolved
240
- * (unknown stateId, parameterized pattern with no concrete values) the push
241
- * is skipped entirely `navigateRouter` is never called with a raw stateId
242
- * or pattern.
252
+ * The method resolves the actor route to a concrete URL path, then it calls
253
+ * navigateRouter() for the navigation of the framework. When it cannot resolve the
254
+ * route, which happens for an unknown stateId and for a parameterized pattern
255
+ * without concrete values, it skips the push completely: `navigateRouter` receives a
256
+ * raw stateId or a pattern never.
243
257
  *
244
- * Echo suppression preventing the router's own callback from re-driving the
245
- * actor is handled entirely by `lastSyncedPath`: it is set to the resolved
246
- * path before `navigateRouter()` is called, so any `syncActorFromRouter`
247
- * invocation for the same path short-circuits at the
248
- * `sanitized === lastSyncedPath` check and sends no event regardless of
249
- * whether the callback fires synchronously or asynchronously.
258
+ * `lastSyncedPath` does the complete echo suppression, which stops the callback of
259
+ * the router from a send to the actor: the method writes the resolved path to
260
+ * `lastSyncedPath` before the `navigateRouter()` call. Therefore each
261
+ * `syncActorFromRouter` call of the same path stops at the
262
+ * `sanitized === lastSyncedPath` test, and it sends no event. This is correct for a
263
+ * synchronous callback and for an asynchronous callback.
250
264
  *
251
- * `isProcessingNavigation` is NOT set here it is only used inside
252
- * `syncActorFromRouter` to guard against re-entrant guard-redirect loops.
265
+ * The method does NOT set `isProcessingNavigation`. That flag lives inside
266
+ * `syncActorFromRouter`, and it guards against a re-entrant loop of a guard
267
+ * redirect.
253
268
  */
254
269
  syncRouterFromActor(route) {
255
270
  if (this.hasConnectedOnce && !this.isConnected)
@@ -261,18 +276,20 @@ export class RouterBridgeBase {
261
276
  this.pushResolvedRoute(route);
262
277
  }
263
278
  /**
264
- * Resolve an actor route to its concrete URL path and push it to the router.
279
+ * Resolves an actor route to its concrete URL path, and pushes that path to the
280
+ * router.
265
281
  *
266
- * lastSyncedPath must store the concrete path so that the router callback
267
- * (which fires with the concrete path) matches and short-circuits in
268
- * syncActorFromRouter. Storing the raw stateId (e.g. "#home") would cause a
269
- * mismatch against the sanitized path ("/") in the watcher.
282
+ * lastSyncedPath must hold the concrete path, because the callback of the router
283
+ * fires with the concrete path. The two values therefore match, and
284
+ * syncActorFromRouter stops. A raw stateId in that field, for example "#home", does
285
+ * not match the sanitized path ("/") in the watcher.
270
286
  *
271
- * When resolveNavigationPath returns null (parameterized/wildcard pattern,
272
- * unknown id) the push is skipped entirely pushing the raw value would write
273
- * stateIds, `:param` patterns, or a literal `*` into the browser URL.
274
- * lastSyncedPath is still set to the
275
- * raw route so the dedup guard fires correctly on the next identical signal value.
287
+ * When resolveNavigationPath returns null, which happens for a parameterized
288
+ * pattern, for a wildcard pattern, and for an unknown id, the method skips the push
289
+ * completely: a push of the raw value writes a stateId, a `:param` pattern, or a
290
+ * literal `*` into the browser URL. The method still writes the raw route to
291
+ * lastSyncedPath. Therefore the dedup guard fires correctly on the next identical
292
+ * value of the signal.
276
293
  */
277
294
  pushResolvedRoute(route) {
278
295
  const resolved = this.resolveNavigationPath(route);
@@ -282,20 +299,20 @@ export class RouterBridgeBase {
282
299
  this.navigateRouter(resolved);
283
300
  }
284
301
  /**
285
- * Sync actor state when router location changes.
302
+ * Writes the actor state when the location of the router changes.
286
303
  *
287
- * **Known path:** sends a `play.route` event to the actor with the matched
288
- * stateId, params, and query. Prevents circular updates via the
289
- * `isProcessingNavigation` flag.
304
+ * **A known path:** the method sends a `play.route` event to the actor, with the
305
+ * stateId of the match, the params, and the query. The `isProcessingNavigation` flag
306
+ * stops a circular update.
290
307
  *
291
- * **Unknown/unmapped path:** does NOT send a `play.route` event (actor state
292
- * is unchanged). Instead, actively corrects the browser URL by calling
293
- * `navigateRouter(actor.currentRoute.get())` keeping the URL in sync with
294
- * actor state even when the user types an invalid path into the address bar
295
- * or pushes one programmatically mid-session. `lastSyncedPath` is set to the
296
- * resolved concrete path before calling `navigateRouter` so the router's own
297
- * callback for that navigation short-circuits the echo-suppression guard and
298
- * sends no spurious event.
308
+ * **An unknown path, or a path with no entry in the map:** the method sends NO
309
+ * `play.route` event, and the actor state stays as it is. It corrects the browser URL
310
+ * instead, with a `navigateRouter(actor.currentRoute.get())` call. The URL therefore
311
+ * follows the actor state, also when the user types an invalid path in the address
312
+ * bar, or when the code pushes such a path during a session. The method writes the
313
+ * resolved concrete path to `lastSyncedPath` before the `navigateRouter` call.
314
+ * Therefore the callback of the router for that navigation stops at the guard of the
315
+ * echo suppression, and it sends no false event.
299
316
  */
300
317
  syncActorFromRouter(pathname, search) {
301
318
  if (this.hasConnectedOnce && !this.isConnected)
@@ -304,7 +321,7 @@ export class RouterBridgeBase {
304
321
  return;
305
322
  const sanitized = sanitizePathname(pathname);
306
323
  if (sanitized === null)
307
- return; // Path too long reject
324
+ return; // The path is too long. Refuse it
308
325
  if (sanitized === this.lastSyncedPath)
309
326
  return;
310
327
  if (this.isProcessingNavigation)
@@ -317,21 +334,22 @@ export class RouterBridgeBase {
317
334
  match: (nextPathname) => matchRouteMap(nextPathname, this.routeMap, (resolvedPathname, stateId) => this.extractParams(resolvedPathname, stateId)),
318
335
  });
319
336
  if (!nextRoute) {
320
- // Unknown path no matching route in the map.
321
- // Push the actor's current route back to the router so the browser URL
322
- // stays in sync with actor state (corrects an invalid address-bar entry
323
- // on cold-load or mid-session navigation to a non-existent path).
337
+ // The path is unknown, because the map holds no route of it.
338
+ // Push the current route of the actor back to the router. The browser URL therefore
339
+ // follows the actor state, and this corrects an invalid entry in the address bar on a
340
+ // cold load, and also a navigation to a path that is absent during a session.
324
341
  //
325
- // Call pushResolvedRoute() directly rather than syncRouterFromActor() to
326
- // avoid the dedup guard (lastSyncedPath is already the actor's route).
327
- // It sets lastSyncedPath to the resolved concrete path before pushing so
328
- // that the router's own callback for this navigation (hashchange /
329
- // history event) short-circuits in the sanitized === lastSyncedPath
330
- // guard and does not send a spurious play.route event to the actor.
342
+ // The code calls pushResolvedRoute() directly, and not syncRouterFromActor(). It
343
+ // therefore passes around the dedup guard, because lastSyncedPath holds the route of
344
+ // the actor already. pushResolvedRoute sets lastSyncedPath to the resolved concrete
345
+ // path before the push. Therefore the callback of the router for this navigation, a
346
+ // hashchange event or a history event, stops at the
347
+ // sanitized === lastSyncedPath guard, and it sends no false play.route event to the
348
+ // actor.
331
349
  //
332
- // isProcessingNavigation is left true here; the finally block below
333
- // clears it after navigateRouter returns, providing a synchronous
334
- // re-entrancy guard for any router callback that fires inline.
350
+ // isProcessingNavigation stays true here. The finally block below clears it after
351
+ // navigateRouter returns. That block therefore guards synchronously against a
352
+ // re-entrance of each router callback that fires at once.
335
353
  const currentActorRoute = this.actor.currentRoute.get();
336
354
  if (currentActorRoute) {
337
355
  this.pushResolvedRoute(currentActorRoute);
@@ -342,8 +360,9 @@ export class RouterBridgeBase {
342
360
  this.lastSyncedPath = nextRoute.pathname;
343
361
  }
344
362
  catch (error) {
345
- // URLPatternUnavailableError is a user-actionable error propagate as-is
346
- // so callers can detect it and prompt the user to add a URLPattern polyfill.
363
+ // A URLPatternUnavailableError needs an action of the user: it goes to the caller
364
+ // without a change. The caller can therefore find it, and ask the user for a
365
+ // URLPattern polyfill.
347
366
  if (error instanceof URLPatternUnavailableError) {
348
367
  throw error;
349
368
  }
@@ -355,19 +374,20 @@ export class RouterBridgeBase {
355
374
  this.isProcessingNavigation = false;
356
375
  }
357
376
  }
358
- // ── Utilities (protected, overridable for framework-native param extraction) ──
377
+ // ── The utilities. They are protected, and a subclass overrides one to read the params of its framework ──
359
378
  /**
360
- * Extract path parameters from URL using the URLPattern API.
379
+ * Reads the path parameters of a URL, with the URLPattern API.
361
380
  *
362
- * Accesses `globalThis.URLPattern` at runtime no polyfill is imported by this
363
- * library. If `URLPattern` is unavailable and the matched route has parameterized
364
- * segments, a `URLPatternUnavailableError` is thrown — callers must provide a polyfill
365
- * for environments without native URLPattern support (Node.js < 24, older browsers).
381
+ * The method reads `globalThis.URLPattern` at run time, because this library imports
382
+ * no polyfill. When `URLPattern` is absent and the route of the match holds a
383
+ * parameterized segment, the method throws a `URLPatternUnavailableError`. A caller
384
+ * must therefore give a polyfill in an environment without the native URLPattern:
385
+ * Node.js < 24, and an older browser.
366
386
  *
367
- * @param pathname - The actual URL path (e.g., '/profile/john')
368
- * @param stateId - The matched state ID for looking up the route pattern
369
- * @returns Extracted path parameters, or empty object if no match
370
- * @throws {URLPatternUnavailableError} When URLPattern is absent and the route is parameterized
387
+ * @param pathname - The real URL path, for example '/profile/john'
388
+ * @param stateId - The stateId of the match, for the lookup of the route pattern
389
+ * @returns The path parameters of the read, or an empty object when nothing matches
390
+ * @throws {URLPatternUnavailableError} When URLPattern is absent and the route holds a parameter
371
391
  */
372
392
  extractParams(pathname, stateId) {
373
393
  const pattern = this.routeMap.getPathByStateId(stateId);
@@ -376,22 +396,22 @@ export class RouterBridgeBase {
376
396
  return extractRouteParams(pathname, pattern);
377
397
  }
378
398
  /**
379
- * Resolve an actor route value to a concrete URL path for navigation.
399
+ * Resolves a value of an actor route to a concrete URL path for a navigation.
380
400
  *
381
- * Bridges that receive raw `actor.currentRoute` values in `navigateRouter`
382
- * can call this to normalize stateIds (with or without `#` prefix) to paths.
383
- * Returns `null` when navigation is not possible:
384
- * - unknown stateId with no route map entry
385
- * - parameterized or wildcard pattern (e.g. `/profile/:id`, `/docs/*`) — no
386
- * concrete values available to build a real URL
387
- * - non-path string that isn't a known stateId
401
+ * A bridge that receives a raw `actor.currentRoute` value in `navigateRouter` calls
402
+ * this method to convert a stateId, with a `#` prefix or without one, into a path.
403
+ * The method returns `null` when a navigation is not possible:
404
+ * - an unknown stateId, with no entry in the route map
405
+ * - a parameterized pattern or a wildcard pattern, for example `/profile/:id` or
406
+ * `/docs/*`, because no concrete value is available for a real URL
407
+ * - a string that is no path and no known stateId
388
408
  *
389
- * StateId lookups try both the `"#stateId"` and bare `"stateId"` forms
390
- * automatically (via `lookupPathByStateId`), so custom route maps keyed on
391
- * only one form still resolve.
409
+ * A lookup of a stateId tries the form `"#stateId"` and the bare form `"stateId"`,
410
+ * through `lookupPathByStateId`. Therefore a route map of your own that holds one
411
+ * form only still resolves the path.
392
412
  *
393
- * @param route - Raw actor route value (stateId, `#`-stateId, or concrete path)
394
- * @returns Concrete URL path, or `null` if navigation should be skipped
413
+ * @param route - The raw value of the actor route: a stateId, a stateId with a `#`, or a concrete path
414
+ * @returns The concrete URL path, or `null` when the bridge must skip the navigation
395
415
  */
396
416
  resolveNavigationPath(route) {
397
417
  const mapped = this.lookupPathByStateId(route);
@@ -401,17 +421,19 @@ export class RouterBridgeBase {
401
421
  return path;
402
422
  }
403
423
  /**
404
- * Look up a route map path for an actor route value, trying both stateId forms.
424
+ * Returns the path of the route map for a value of an actor route, and it tries both
425
+ * forms of a stateId.
405
426
  *
406
- * The actor may emit `"#stateId"` while a custom route map is keyed on the bare
407
- * `"stateId"` form (or vice versa). `RouteMap` canonicalizes internally, but the
408
- * constructor accepts any structural `{ getStateIdByPath, getPathByStateId }`
409
- * object plain test objects and adapter-specific maps are documented-legal —
410
- * so both forms are tried automatically here: raw first, then the `#`-stripped
411
- * form, then the `#`-prefixed form.
427
+ * The actor can emit `"#stateId"` while a route map of your own holds the bare form
428
+ * `"stateId"`, and also the opposite. `RouteMap` makes the canonical form
429
+ * internally, but the constructor accepts every structural
430
+ * `{ getStateIdByPath, getPathByStateId }` object: a plain test object and a map of
431
+ * one adapter are legal, and the documentation says so. Therefore this method tries
432
+ * both forms: the raw form first, then the form without the `#`, then the form with
433
+ * the `#`.
412
434
  *
413
- * @param route - Raw actor route value (stateId with or without `#` prefix)
414
- * @returns The mapped path, or `null`/`undefined` if no form is registered
435
+ * @param route - The raw value of the actor route: a stateId, with a `#` prefix or without one
436
+ * @returns The path of the map, or `null` or `undefined` when no form is registered
415
437
  */
416
438
  lookupPathByStateId(route) {
417
439
  const direct = this.routeMap.getPathByStateId(route);
@@ -421,66 +443,68 @@ export class RouterBridgeBase {
421
443
  return this.routeMap.getPathByStateId(alternate);
422
444
  }
423
445
  /**
424
- * Extract query parameters from URL search string.
446
+ * Reads the query parameters of a URL search string.
425
447
  *
426
- * @param search - URL search string (e.g., '?tab=security&page=1')
427
- * @returns Extracted query parameters or empty object
448
+ * @param search - The URL search string, for example '?tab=security&page=1'
449
+ * @returns The query parameters of the read, or an empty object
428
450
  */
429
451
  extractQuery(search) {
430
452
  return extractQuery(search);
431
453
  }
432
454
  /**
433
- * Sanitize and validate a raw URL pathname received from the router.
455
+ * Cleans a raw URL pathname of the router, and checks it.
434
456
  *
435
- * Applies the path-length cap (2048 chars), strips query strings and fragments
436
- * that may have been included in the pathname segment, and normalises
437
- * consecutive slashes.
457
+ * The method applies the limit of the path length, which is 2048 characters. It
458
+ * removes a query string and a fragment from the segment of the pathname, and it
459
+ * normalizes each sequence of slashes.
438
460
  *
439
- * Implementations that bypass `syncActorFromRouter()` (e.g. when using
440
- * framework-native reactive watchers that receive pre-parsed route objects)
441
- * MUST call this method before passing the path to any route-map lookup.
442
- * `syncActorFromRouter()` calls this internally, so bridges that delegate
443
- * to it do not need to call `sanitizePath` themselves.
461
+ * An implementation that passes around `syncActorFromRouter()`, for example with a
462
+ * reactive watcher of its framework that receives a route object of a parse before,
463
+ * MUST call this method before it gives the path to a lookup in the route map.
464
+ * `syncActorFromRouter()` calls the method internally. Therefore a bridge that uses
465
+ * that method calls `sanitizePath` never itself.
444
466
  *
445
- * @param pathname - Raw URL pathname from the framework router.
446
- * @returns Sanitized pathname, or `null` if the path is invalid / too long.
467
+ * @param pathname - The raw URL pathname of the framework router.
468
+ * @returns The clean pathname, or `null` when the path is invalid or too long.
447
469
  */
448
470
  sanitizePath(pathname) {
449
471
  return sanitizePathname(pathname);
450
472
  }
451
473
  /**
452
- * Return the router's current pathname at connect() time.
474
+ * Returns the current pathname of the router at the moment of `connect()`.
453
475
  *
454
- * Called once during connect() to perform the initial URL actor sync.
455
- * router.subscribe() only fires on *future* navigation events; it does not
456
- * replay the already-loaded location. Subclasses that can read the router's
457
- * current location synchronously (e.g. `router.state.location.pathname`)
458
- * should override this method so that deep-link / direct-URL loads drive the
459
- * actor to the correct state instead of leaving it at its machine default.
476
+ * `connect()` calls this method one time, for the first synchronization from the URL
477
+ * to the actor. router.subscribe() fires on a *later* navigation event only. It does
478
+ * not repeat the location that the router loaded before. A subclass that can read the
479
+ * current location of its router synchronously, for example with
480
+ * `router.state.location.pathname`, overrides this method. A deep link and a direct
481
+ * URL then drive the actor to the correct state, and they leave it not at the default
482
+ * of its machine.
460
483
  *
461
- * Return semantics:
462
- * - `string` → router has a current path; base connect() will sync actor from router
463
- * - `null` → router is active but has no current path yet; base connect() will sync router from actor
464
- * - `undefined` → adapter handles initial sync itself and base connect() should stay out of the way
484
+ * The meaning of each return value:
485
+ * - `string` → the router has a current path, and the base `connect()` writes the actor state from the router
486
+ * - `null` → the router is active, but it has no current path yet, and the base `connect()` writes the router from the actor
487
+ * - `undefined` → the adapter does the first synchronization itself, and the base `connect()` does nothing
465
488
  *
466
- * The default returns `undefined`, preserving the previous behaviour for
467
- * bridges that have not yet implemented this hook.
489
+ * The default value is `undefined`. Therefore a bridge without this hook keeps its
490
+ * earlier behavior.
468
491
  */
469
492
  getInitialRouterPath() {
470
493
  return undefined;
471
494
  }
472
495
  /**
473
- * Return the router's current search string at connect() time.
496
+ * Returns the current search string of the router at the moment of `connect()`.
474
497
  *
475
- * Paired with `getInitialRouterPath()` — called once during connect() to pass
476
- * the initial URL query string to `syncActorFromRouter()`. If the router's current
477
- * URL has no search (or the subclass doesn't override this), returns `undefined`
478
- * and `syncActorFromRouter` will produce an empty `query: {}` in the event.
498
+ * This method is the pair of `getInitialRouterPath()`. `connect()` calls it one time,
499
+ * to give the query string of the first URL to `syncActorFromRouter()`. It returns
500
+ * `undefined` when the current URL of the router has no search string, and also when
501
+ * the subclass overrides the method not. `syncActorFromRouter` then makes an empty
502
+ * `query: {}` in the event.
479
503
  *
480
- * Subclasses that override `getInitialRouterPath()` and have a query string
481
- * available should also override this method.
504
+ * A subclass that overrides `getInitialRouterPath()` and has a query string
505
+ * overrides this method too.
482
506
  *
483
- * @returns URL search string (e.g. `"?tab=security"`), or `undefined` if not available.
507
+ * @returns The URL search string, for example `"?tab=security"`, or `undefined` when it is not available.
484
508
  */
485
509
  getInitialRouterSearch() {
486
510
  return undefined;