@xmachines/play-router 2.0.0 → 2.1.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 (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 +240 -190
  41. package/dist/router-bridge-base.d.ts.map +1 -1
  42. package/dist/router-bridge-base.js +350 -228
  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
- *
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`.
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.
81
+ *
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 location stops
85
+ * at the {@link isEchoOfLastSync} 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,55 +238,79 @@ 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.
237
- *
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.
243
- *
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.
250
- *
251
- * `isProcessingNavigation` is NOT set here it is only used inside
252
- * `syncActorFromRouter` to guard against re-entrant guard-redirect loops.
250
+ * Writes the location of the router when the route signal of the actor changes.
251
+ *
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.
257
+ *
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.
264
+ *
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)
256
271
  return;
257
272
  if (!route || typeof route !== "string")
258
273
  return;
259
- if (route === this.lastSyncedPath)
274
+ if (this.isEchoOfLastPush(route))
260
275
  return;
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.
265
- *
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.
270
- *
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.
279
+ * Tests a route of the actor against the last synchronized location.
280
+ *
281
+ * The exact string test covers a stateId, for example `"#home"`, which
282
+ * `pushResolvedRoute` writes to the field when it cannot resolve a path. The query
283
+ * test covers a location that carries a query: the order of the keys of a query is
284
+ * not significant, so `?b=2&a=1` and `?a=1&b=2` are the same location, and the
285
+ * bridge must push neither of them back.
286
+ */
287
+ isEchoOfLastPush(route) {
288
+ const last = this.lastSyncedPath;
289
+ if (last === null)
290
+ return false;
291
+ if (route === last)
292
+ return true;
293
+ const [routePath, routeQuery] = splitLocation(route);
294
+ const [lastPath, lastQuery] = splitLocation(last);
295
+ if (routePath !== lastPath)
296
+ return false;
297
+ return sortedQuery(routeQuery) === sortedQuery(lastQuery);
298
+ }
299
+ /**
300
+ * Resolves an actor route to its concrete URL path, and pushes that path to the
301
+ * router.
302
+ *
303
+ * lastSyncedPath must hold the concrete path, because the callback of the router
304
+ * fires with the concrete path. The two values therefore match, and
305
+ * syncActorFromRouter stops. A raw stateId in that field, for example "#home", does
306
+ * not match the sanitized path ("/") in the watcher.
307
+ *
308
+ * When resolveNavigationPath returns null, which happens for a parameterized
309
+ * pattern, for a wildcard pattern, and for an unknown id, the method skips the push
310
+ * completely: a push of the raw value writes a stateId, a `:param` pattern, or a
311
+ * literal `*` into the browser URL. The method still writes the raw route to
312
+ * lastSyncedPath. Therefore the dedup guard fires correctly on the next identical
313
+ * value of the signal.
276
314
  */
277
315
  pushResolvedRoute(route) {
278
316
  const resolved = this.resolveNavigationPath(route);
@@ -282,20 +320,20 @@ export class RouterBridgeBase {
282
320
  this.navigateRouter(resolved);
283
321
  }
284
322
  /**
285
- * Sync actor state when router location changes.
286
- *
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.
290
- *
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.
323
+ * Writes the actor state when the location of the router changes.
324
+ *
325
+ * **A known path:** the method sends a `play.route` event to the actor, with the
326
+ * stateId of the match, the params, and the query. The `isProcessingNavigation` flag
327
+ * stops a circular update.
328
+ *
329
+ * **An unknown path, or a path with no entry in the map:** the method sends NO
330
+ * `play.route` event, and the actor state stays as it is. It corrects the browser URL
331
+ * instead, with a `navigateRouter(actor.currentRoute.get())` call. The URL therefore
332
+ * follows the actor state, also when the user types an invalid path in the address
333
+ * bar, or when the code pushes such a path during a session. The method writes the
334
+ * resolved concrete path to `lastSyncedPath` before the `navigateRouter` call.
335
+ * Therefore the callback of the router for that navigation stops at the guard of the
336
+ * echo suppression, and it sends no false event.
299
337
  */
300
338
  syncActorFromRouter(pathname, search) {
301
339
  if (this.hasConnectedOnce && !this.isConnected)
@@ -304,8 +342,8 @@ export class RouterBridgeBase {
304
342
  return;
305
343
  const sanitized = sanitizePathname(pathname);
306
344
  if (sanitized === null)
307
- return; // Path too long reject
308
- if (sanitized === this.lastSyncedPath)
345
+ return; // The path is too long. Refuse it
346
+ if (this.isEchoOfLastSync(sanitized, search))
309
347
  return;
310
348
  if (this.isProcessingNavigation)
311
349
  return;
@@ -317,33 +355,49 @@ export class RouterBridgeBase {
317
355
  match: (nextPathname) => matchRouteMap(nextPathname, this.routeMap, (resolvedPathname, stateId) => this.extractParams(resolvedPathname, stateId)),
318
356
  });
319
357
  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).
358
+ // The path is unknown, because the map holds no route of it.
359
+ // Push the current route of the actor back to the router. The browser URL therefore
360
+ // follows the actor state, and this corrects an invalid entry in the address bar on a
361
+ // cold load, and also a navigation to a path that is absent during a session.
324
362
  //
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.
363
+ // The code calls pushResolvedRoute() directly, and not syncRouterFromActor(). It
364
+ // therefore passes around the dedup guard, because lastSyncedPath holds the route of
365
+ // the actor already. pushResolvedRoute sets lastSyncedPath to the resolved concrete
366
+ // path before the push. Therefore the callback of the router for this navigation, a
367
+ // hashchange event or a history event, stops at the
368
+ // sanitized === lastSyncedPath guard, and it sends no false play.route event to the
369
+ // actor.
331
370
  //
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.
371
+ // isProcessingNavigation stays true here. The finally block below clears it after
372
+ // navigateRouter returns. That block therefore guards synchronously against a
373
+ // re-entrance of each router callback that fires at once.
335
374
  const currentActorRoute = this.actor.currentRoute.get();
336
375
  if (currentActorRoute) {
337
376
  this.pushResolvedRoute(currentActorRoute);
338
377
  }
339
378
  return;
340
379
  }
380
+ // Record the location BEFORE the send, and record it with its query.
381
+ //
382
+ // The route signal of the actor notifies the watcher of the other direction
383
+ // for this same move, and `syncRouterFromActor` then tests the route of the
384
+ // actor against this field. Two details decide that test:
385
+ //
386
+ // - The write must happen first. A write after the send leaves the previous
387
+ // path in the field while the watcher runs, so the test misses and the
388
+ // bridge pushes the location that the router already holds. On a press on
389
+ // BACK that push is a new history entry, and it replaces the entry that
390
+ // FORWARD returns to.
391
+ // - The write must carry the query. A machine that republishes the query
392
+ // holds `/second?id=1` as its route, and a pathname alone in this field
393
+ // never matches it.
394
+ this.lastSyncedPath = withSearch(nextRoute.pathname, search);
341
395
  this.actor.send(nextRoute.event);
342
- this.lastSyncedPath = nextRoute.pathname;
343
396
  }
344
397
  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.
398
+ // A URLPatternUnavailableError needs an action of the user: it goes to the caller
399
+ // without a change. The caller can therefore find it, and ask the user for a
400
+ // URLPattern polyfill.
347
401
  if (error instanceof URLPatternUnavailableError) {
348
402
  throw error;
349
403
  }
@@ -355,19 +409,20 @@ export class RouterBridgeBase {
355
409
  this.isProcessingNavigation = false;
356
410
  }
357
411
  }
358
- // ── Utilities (protected, overridable for framework-native param extraction) ──
412
+ // ── The utilities. They are protected, and a subclass overrides one to read the params of its framework ──
359
413
  /**
360
- * Extract path parameters from URL using the URLPattern API.
414
+ * Reads the path parameters of a URL, with the URLPattern API.
361
415
  *
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).
416
+ * The method reads `globalThis.URLPattern` at run time, because this library imports
417
+ * no polyfill. When `URLPattern` is absent and the route of the match holds a
418
+ * parameterized segment, the method throws a `URLPatternUnavailableError`. A caller
419
+ * must therefore give a polyfill in an environment without the native URLPattern:
420
+ * Node.js < 24, and an older browser.
366
421
  *
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
422
+ * @param pathname - The real URL path, for example '/profile/john'
423
+ * @param stateId - The stateId of the match, for the lookup of the route pattern
424
+ * @returns The path parameters of the read, or an empty object when nothing matches
425
+ * @throws {URLPatternUnavailableError} When URLPattern is absent and the route holds a parameter
371
426
  */
372
427
  extractParams(pathname, stateId) {
373
428
  const pattern = this.routeMap.getPathByStateId(stateId);
@@ -376,22 +431,22 @@ export class RouterBridgeBase {
376
431
  return extractRouteParams(pathname, pattern);
377
432
  }
378
433
  /**
379
- * Resolve an actor route value to a concrete URL path for navigation.
380
- *
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
388
- *
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.
392
- *
393
- * @param route - Raw actor route value (stateId, `#`-stateId, or concrete path)
394
- * @returns Concrete URL path, or `null` if navigation should be skipped
434
+ * Resolves a value of an actor route to a concrete URL path for a navigation.
435
+ *
436
+ * A bridge that receives a raw `actor.currentRoute` value in `navigateRouter` calls
437
+ * this method to convert a stateId, with a `#` prefix or without one, into a path.
438
+ * The method returns `null` when a navigation is not possible:
439
+ * - an unknown stateId, with no entry in the route map
440
+ * - a parameterized pattern or a wildcard pattern, for example `/profile/:id` or
441
+ * `/docs/*`, because no concrete value is available for a real URL
442
+ * - a string that is no path and no known stateId
443
+ *
444
+ * A lookup of a stateId tries the form `"#stateId"` and the bare form `"stateId"`,
445
+ * through `lookupPathByStateId`. Therefore a route map of your own that holds one
446
+ * form only still resolves the path.
447
+ *
448
+ * @param route - The raw value of the actor route: a stateId, a stateId with a `#`, or a concrete path
449
+ * @returns The concrete URL path, or `null` when the bridge must skip the navigation
395
450
  */
396
451
  resolveNavigationPath(route) {
397
452
  const mapped = this.lookupPathByStateId(route);
@@ -401,17 +456,19 @@ export class RouterBridgeBase {
401
456
  return path;
402
457
  }
403
458
  /**
404
- * Look up a route map path for an actor route value, trying both stateId forms.
459
+ * Returns the path of the route map for a value of an actor route, and it tries both
460
+ * forms of a stateId.
405
461
  *
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.
462
+ * The actor can emit `"#stateId"` while a route map of your own holds the bare form
463
+ * `"stateId"`, and also the opposite. `RouteMap` makes the canonical form
464
+ * internally, but the constructor accepts every structural
465
+ * `{ getStateIdByPath, getPathByStateId }` object: a plain test object and a map of
466
+ * one adapter are legal, and the documentation says so. Therefore this method tries
467
+ * both forms: the raw form first, then the form without the `#`, then the form with
468
+ * the `#`.
412
469
  *
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
470
+ * @param route - The raw value of the actor route: a stateId, with a `#` prefix or without one
471
+ * @returns The path of the map, or `null` or `undefined` when no form is registered
415
472
  */
416
473
  lookupPathByStateId(route) {
417
474
  const direct = this.routeMap.getPathByStateId(route);
@@ -421,71 +478,136 @@ export class RouterBridgeBase {
421
478
  return this.routeMap.getPathByStateId(alternate);
422
479
  }
423
480
  /**
424
- * Extract query parameters from URL search string.
481
+ * Reads the query parameters of a URL search string.
425
482
  *
426
- * @param search - URL search string (e.g., '?tab=security&page=1')
427
- * @returns Extracted query parameters or empty object
483
+ * @param search - The URL search string, for example '?tab=security&page=1'
484
+ * @returns The query parameters of the read, or an empty object
428
485
  */
429
486
  extractQuery(search) {
430
487
  return extractQuery(search);
431
488
  }
432
489
  /**
433
- * Sanitize and validate a raw URL pathname received from the router.
490
+ * True when a location of the router is the location that this bridge pushed.
434
491
  *
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.
492
+ * `lastSyncedPath` holds the value of `actor.currentRoute`, and that value carries
493
+ * the query string of `context.query`, for example `"/dates?trip=one-way"`. A router
494
+ * callback reports the pathname and the search SEPARATELY, and `sanitizePathname`
495
+ * removes the query. A comparison of the pathname alone therefore misses for every
496
+ * route with a query, and the push of this bridge comes back as a `play.route` event
497
+ * of its own navigation. That echo is not only wasted work: it writes to the signal
498
+ * graph from inside the watcher callback of the actor route, and the watcher then
499
+ * arms itself again on a computed that is dirty already, which ends the direction
500
+ * from the actor to the router for the rest of the session.
438
501
  *
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.
502
+ * The query is compared as a MAP, and not as a string: a framework can re-serialize
503
+ * a search string in another order, and `URLSearchParams` also accepts the form with
504
+ * a leading `?` and the form without one.
444
505
  *
445
- * @param pathname - Raw URL pathname from the framework router.
446
- * @returns Sanitized pathname, or `null` if the path is invalid / too long.
506
+ * @param sanitized - The clean pathname of the router callback, with no query.
507
+ * @param search - The search string of the router callback, when it has one.
508
+ * @returns True when the location is the echo of the last push of this bridge.
509
+ */
510
+ isEchoOfLastSync(sanitized, search) {
511
+ const last = this.lastSyncedPath;
512
+ if (last === null)
513
+ return false;
514
+ if (sanitized === last && (search ?? "") === "")
515
+ return true;
516
+ // Compare the query of BOTH sides. A pathname test alone reports an echo for a
517
+ // move that keeps the path and changes the query, which a filter link does, and
518
+ // which a `validateSearch` of a route does when it injects a default. The actor
519
+ // then never hears of the move, and the URL and the actor diverge.
520
+ const [lastPath, lastQuery] = splitLocation(last);
521
+ if (lastPath !== sanitized)
522
+ return false;
523
+ return sortedQuery(lastQuery) === sortedQuery(search ?? "");
524
+ }
525
+ /**
526
+ * Cleans a raw URL pathname of the router, and checks it.
527
+ *
528
+ * The method applies the limit of the path length, which is 2048 characters. It
529
+ * removes a query string and a fragment from the segment of the pathname, and it
530
+ * normalizes each sequence of slashes.
531
+ *
532
+ * An implementation that passes around `syncActorFromRouter()`, for example with a
533
+ * reactive watcher of its framework that receives a route object of a parse before,
534
+ * MUST call this method before it gives the path to a lookup in the route map.
535
+ * `syncActorFromRouter()` calls the method internally. Therefore a bridge that uses
536
+ * that method calls `sanitizePath` never itself.
537
+ *
538
+ * @param pathname - The raw URL pathname of the framework router.
539
+ * @returns The clean pathname, or `null` when the path is invalid or too long.
447
540
  */
448
541
  sanitizePath(pathname) {
449
542
  return sanitizePathname(pathname);
450
543
  }
451
544
  /**
452
- * Return the router's current pathname at connect() time.
453
- *
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.
460
- *
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
465
- *
466
- * The default returns `undefined`, preserving the previous behaviour for
467
- * bridges that have not yet implemented this hook.
545
+ * Returns the current pathname of the router at the moment of `connect()`.
546
+ *
547
+ * `connect()` calls this method one time, for the first synchronization from the URL
548
+ * to the actor. router.subscribe() fires on a *later* navigation event only. It does
549
+ * not repeat the location that the router loaded before. A subclass that can read the
550
+ * current location of its router synchronously, for example with
551
+ * `router.state.location.pathname`, overrides this method. A deep link and a direct
552
+ * URL then drive the actor to the correct state, and they leave it not at the default
553
+ * of its machine.
554
+ *
555
+ * The meaning of each return value:
556
+ * - `string` → the router has a current path, and the base `connect()` writes the actor state from the router
557
+ * - `null` → the router is active, but it has no current path yet, and the base `connect()` writes the router from the actor
558
+ * - `undefined` → the adapter does the first synchronization itself, and the base `connect()` does nothing
559
+ *
560
+ * The default value is `undefined`. Therefore a bridge without this hook keeps its
561
+ * earlier behavior.
468
562
  */
469
563
  getInitialRouterPath() {
470
564
  return undefined;
471
565
  }
472
566
  /**
473
- * Return the router's current search string at connect() time.
567
+ * Returns the current search string of the router at the moment of `connect()`.
474
568
  *
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.
569
+ * This method is the pair of `getInitialRouterPath()`. `connect()` calls it one time,
570
+ * to give the query string of the first URL to `syncActorFromRouter()`. It returns
571
+ * `undefined` when the current URL of the router has no search string, and also when
572
+ * the subclass overrides the method not. `syncActorFromRouter` then makes an empty
573
+ * `query: {}` in the event.
479
574
  *
480
- * Subclasses that override `getInitialRouterPath()` and have a query string
481
- * available should also override this method.
575
+ * A subclass that overrides `getInitialRouterPath()` and has a query string
576
+ * overrides this method too.
482
577
  *
483
- * @returns URL search string (e.g. `"?tab=security"`), or `undefined` if not available.
578
+ * @returns The URL search string, for example `"?tab=security"`, or `undefined` when it is not available.
484
579
  */
485
580
  getInitialRouterSearch() {
486
581
  return undefined;
487
582
  }
488
583
  }
584
+ /**
585
+ * The canonical form of a query string, for a comparison that ignores the order:
586
+ * each `key=value` pair, sorted, joined with `&`. An empty search gives `""`.
587
+ *
588
+ * `sort()` in place, and not `toSorted()`: the array is a fresh one of `map()`, so
589
+ * nothing else holds a reference to it and the mutation escapes nowhere. `toSorted()`
590
+ * is ES2023, and four example packages compile this source through the workspace
591
+ * aliases with `"lib": ["ES2022", …]`, which would break `tsc --build` of the repo.
592
+ */
593
+ /** Splits a location into its path and its query, with no leading `?`. */
594
+ function splitLocation(location) {
595
+ const mark = location.indexOf("?");
596
+ if (mark === -1)
597
+ return [location, ""];
598
+ return [location.slice(0, mark), location.slice(mark + 1)];
599
+ }
600
+ /** Joins a pathname and a query into one location value. An empty query adds nothing. */
601
+ function withSearch(pathname, search) {
602
+ const query = (search ?? "").replace(/^\?/, "");
603
+ return query === "" ? pathname : `${pathname}?${query}`;
604
+ }
605
+ function sortedQuery(search) {
606
+ const pairs = [...new URLSearchParams(search).entries()].map(([key, value]) => `${key}=${value}`);
607
+ // oxlint-disable-next-line unicorn/no-array-sort
608
+ pairs.sort();
609
+ return pairs.join("&");
610
+ }
489
611
  function noopCleanup() { }
490
612
  function createRouteWatcher(signal, onRoute) {
491
613
  let cleanup = noopCleanup;