@xmachines/play-router 2.1.0 → 2.2.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 (39) hide show
  1. package/README.md +270 -11
  2. package/dist/base-path.d.ts +209 -0
  3. package/dist/base-path.d.ts.map +1 -0
  4. package/dist/base-path.js +418 -0
  5. package/dist/base-path.js.map +1 -0
  6. package/dist/base-route-map.d.ts.map +1 -1
  7. package/dist/base-route-map.js +5 -0
  8. package/dist/base-route-map.js.map +1 -1
  9. package/dist/errors.d.ts +87 -4
  10. package/dist/errors.d.ts.map +1 -1
  11. package/dist/errors.js +97 -4
  12. package/dist/errors.js.map +1 -1
  13. package/dist/framework-params.d.ts +144 -0
  14. package/dist/framework-params.d.ts.map +1 -0
  15. package/dist/framework-params.js +291 -0
  16. package/dist/framework-params.js.map +1 -0
  17. package/dist/index.d.ts +7 -2
  18. package/dist/index.d.ts.map +1 -1
  19. package/dist/index.js +13 -1
  20. package/dist/index.js.map +1 -1
  21. package/dist/provider-lifecycle.d.ts +179 -0
  22. package/dist/provider-lifecycle.d.ts.map +1 -0
  23. package/dist/provider-lifecycle.js +153 -0
  24. package/dist/provider-lifecycle.js.map +1 -0
  25. package/dist/query.d.ts +49 -0
  26. package/dist/query.d.ts.map +1 -1
  27. package/dist/query.js +59 -0
  28. package/dist/query.js.map +1 -1
  29. package/dist/router-bridge-base.d.ts +353 -15
  30. package/dist/router-bridge-base.d.ts.map +1 -1
  31. package/dist/router-bridge-base.js +998 -83
  32. package/dist/router-bridge-base.js.map +1 -1
  33. package/dist/types.d.ts +44 -0
  34. package/dist/types.d.ts.map +1 -1
  35. package/dist/url-pattern-utils.d.ts +0 -30
  36. package/dist/url-pattern-utils.d.ts.map +1 -1
  37. package/dist/url-pattern-utils.js +52 -1
  38. package/dist/url-pattern-utils.js.map +1 -1
  39. package/package.json +4 -4
@@ -43,10 +43,10 @@
43
43
  * @see [Play RFC](../../docs/rfc/play.md) - invariant INV-04
44
44
  */
45
45
  import { Signal, watchSignal } from "@xmachines/play-signals";
46
- import { DuplicateBridgeError, RouterSyncError } from "./errors.js";
46
+ import { DuplicateBridgeError, RouterSyncError, URLPatternUnavailableError } from "./errors.js";
47
47
  import { buildPlayRouteEvent, extractQuery, extractRouteParams, matchRouteMap, sanitizePathname, } from "./router-sync.js";
48
- import { URLPatternUnavailableError } from "./errors.js";
49
48
  import { isParameterizedPattern } from "./url-pattern-utils.js";
49
+ import { NO_BASE_PATH, firstMarkIndex, joinBasePath, resolveBasePath, stripBasePath, } from "./base-path.js";
50
50
  /**
51
51
  * The registry of the actors with an active bridge connection, at the module level.
52
52
  *
@@ -59,20 +59,41 @@ import { isParameterizedPattern } from "./url-pattern-utils.js";
59
59
  * @internal
60
60
  */
61
61
  const activeBridges = new WeakMap();
62
- /**
63
- * The abstract base class of every router adapter bridge of `@xmachines`.
64
- *
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.
68
- */
69
62
  export class RouterBridgeBase {
70
63
  actor;
71
64
  routeMap;
72
65
  // ── The common state. It is identical in each of the 4 bridges ──
73
66
  isConnected = false;
74
67
  hasConnectedOnce = false;
68
+ /**
69
+ * The machine-side location that the router holds, as far as this bridge knows. It
70
+ * carries the query and the fragment, and it carries NO `basePath`.
71
+ *
72
+ * Only code that saw a location, or that wrote one, writes this field:
73
+ * `performInitialSync` when it finds the two sides in step, `takeReturnUnderMount`
74
+ * for the same test under a mount, `pushResolvedRoute` after it resolves a route to
75
+ * a concrete path, and `syncActorFromRouter` for the location that the router
76
+ * reports. `null` means that this bridge placed no location yet, so it knows nothing
77
+ * about the address bar, and every test against this field must then answer "no".
78
+ *
79
+ * The field held the route of the actor as well, from the seed of the constructor
80
+ * and from a route that resolves to no URL. Those two values describe no location
81
+ * that a router ever showed, and each reader of this field asks about a location, so
82
+ * each of them read them wrongly. {@link lastActorRoute} holds them now.
83
+ */
75
84
  lastSyncedPath = null;
85
+ /**
86
+ * The raw value of `actor.currentRoute` that this bridge accounted for already.
87
+ *
88
+ * The field answers ONE question: "did the bridge see this value of the signal
89
+ * before?". It is therefore a string, and not a location — a route that resolves to
90
+ * no URL, such as an unknown stateId or a parameterized pattern, still stops a
91
+ * second attempt at the same value.
92
+ *
93
+ * The constructor and `connect()` seed it, so that the first fire of the watcher of
94
+ * the signal pushes nothing for a route that moved nowhere.
95
+ */
96
+ lastActorRoute = null;
76
97
  /**
77
98
  * The flag guards `syncActorFromRouter` against a re-entrant call from a guard
78
99
  * redirect of the actor itself. Such a call has this sequence: the bridge sends to
@@ -81,11 +102,73 @@ export class RouterBridgeBase {
81
102
  *
82
103
  * The flag is NOT the echo suppression of the direction from the actor to the
83
104
  * 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`.
105
+ * `navigateRouter()` call. Therefore each router callback of the same location stops
106
+ * at the {@link isEchoOfLastSync} test in `syncActorFromRouter`.
86
107
  */
87
108
  isProcessingNavigation = false;
109
+ /**
110
+ * Who owns the address bar, and what this bridge owes it.
111
+ *
112
+ * - `"under-mount"` — the location lies under the mount, so this bridge owns the
113
+ * address bar and it writes the location for every route of its actor.
114
+ * - `"outside"` — the location lies outside the mount, so the HOST owns it. The
115
+ * bridge sends no event and it corrects no URL, which is what lets the routes of
116
+ * the host and the routes of the machine share one router.
117
+ * - `"outside-push-owed"` — outside, AND the route of the actor changed while the
118
+ * host owned the address bar. The push waits here instead of going to the router,
119
+ * and {@link RouterBridgeBase.takeReturnUnderMount} spends it when the location
120
+ * comes back. The machine therefore keeps its place while the user reads a page of
121
+ * the host.
122
+ *
123
+ * ONE value, and not a latch plus a memory. The memory only ever means something
124
+ * while the latch is up, so two fields could hold a fourth combination that says
125
+ * "the bridge owns the address bar AND a push waits for a return" — a state with no
126
+ * meaning, which a return would spend at a location the router already holds. Two
127
+ * writes also cannot happen together: every site that lowered the latch wrote the
128
+ * memory on the NEXT line, and the value between the two lines was that fourth
129
+ * combination. `takeReturnUnderMount` reads the value it replaces instead.
130
+ *
131
+ * `syncActorFromRouter` is the one place that sees a location, so it is the one
132
+ * place that decides between `"under-mount"` and `"outside"`.
133
+ * `performInitialSync` decides it for the location that the page loaded with.
134
+ *
135
+ * A bridge with no mount never leaves its mount: `stripBasePath` returns the path
136
+ * unchanged for an empty prefix, and never `null`. The value therefore stays
137
+ * `"under-mount"` for every bridge that takes no `basePath`.
138
+ */
139
+ mountState = "under-mount";
140
+ /**
141
+ * True once the first synchronization ran against a location INSIDE the mount.
142
+ *
143
+ * `performInitialSync` separates a deep link from a restore, and it can make that
144
+ * decision only for a location that lies under the mount. A bridge that connects
145
+ * while the host owns the address bar therefore reaches no decision at all: it
146
+ * raises the latch and returns. The flag says that the decision is still owed, and
147
+ * `syncActorFromRouter` pays it at the first location that IS under the mount —
148
+ * through {@link RouterBridgeBase.takeReturnUnderMount} for a RETURN, which needs the
149
+ * decision itself, and directly for every other location under the mount.
150
+ *
151
+ * A bridge with no mount never leaves its mount, so `performInitialSync` writes this
152
+ * flag on the first `connect()` and nothing reads it again.
153
+ */
154
+ hasSynchronizedUnderMount = false;
88
155
  routeWatcher = null;
156
+ /**
157
+ * The mount point of the machine inside the host router: the resolved prefix, and
158
+ * the values of each `:param` of that prefix.
159
+ *
160
+ * The prefix lives here, and NOT in the route map, for two reasons. A route map is
161
+ * static, it is shared, and it holds an LRU cache inside, while a mount point is
162
+ * dynamic. One route map therefore serves every `machineId` without a rebuild. And
163
+ * the bridge is the boundary between a location of the host router and a path of
164
+ * the machine already, which is exactly what the prefix separates.
165
+ *
166
+ * `lastSyncedPath` stays MACHINE-side, and it carries no prefix. The code adds the
167
+ * prefix at the one `navigateRouter()` call in `pushResolvedRoute`, and it removes
168
+ * the prefix at the one inbound entry point `syncActorFromRouter`. Every test of
169
+ * the echo suppression therefore keeps comparing like with like.
170
+ */
171
+ mount = NO_BASE_PATH;
89
172
  /**
90
173
  * @param actor - A `RoutableActor`, with `currentRoute`, `initialRoute`, and `send`.
91
174
  * @param routeMap - The route map of both directions, for the resolution between a
@@ -95,13 +178,243 @@ export class RouterBridgeBase {
95
178
  * bare form `"stateId"`. The bridge tries both forms. Therefore an implementation
96
179
  * of your own, for example a plain test object, handles one form only.
97
180
  */
98
- constructor(actor, routeMap) {
181
+ constructor(actor, routeMap, options) {
99
182
  this.actor = actor;
100
183
  this.routeMap = routeMap;
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.
104
- this.lastSyncedPath = this.actor.currentRoute.get() ?? null;
184
+ // Resolve the mount BEFORE the seed of lastSyncedPath: an invalid basePath must
185
+ // fail here, at the construction, and not on the first navigation.
186
+ this.mount = resolveBasePath(options?.basePath, options?.basePathParams);
187
+ // Record the route of the actor as accounted for. This stops a loop in the first
188
+ // synchronization: the watcher of the signal fires once when `connect()` arms it,
189
+ // and a route that moved nowhere must push nothing.
190
+ //
191
+ // The seed goes to `lastActorRoute`, and NOT to `lastSyncedPath`. The route of the
192
+ // actor describes no location that the router ever showed — the bridge has written
193
+ // nothing yet — and every reader of `lastSyncedPath` asks about a location.
194
+ this.lastActorRoute = this.actor.currentRoute.get() ?? null;
195
+ }
196
+ /**
197
+ * The resolved URL prefix that the machine of this bridge is mounted under, or `""`
198
+ * when the machine owns the complete router.
199
+ */
200
+ get basePath() {
201
+ return this.mount.path;
202
+ }
203
+ /**
204
+ * The resolved values of the `:param` segments of the mount, or `{}` for a prefix
205
+ * without a param.
206
+ *
207
+ * These params belong to the HOST: the host wrote the prefix and resolved the
208
+ * values, and they describe the route of the host and not the route of the machine.
209
+ * They therefore travel in NO `play.route` event. The machine is authoritative over
210
+ * its own params, and `event.params` holds what the pattern of the machine
211
+ * declares, and nothing else.
212
+ *
213
+ * They live here instead, because a value that reached the actor on a navigation
214
+ * ALONE would go stale: `setBasePath()` can move the mount while the machine stays
215
+ * on the same route, no event goes out, and `event.params` would then contradict
216
+ * `basePath`. A read of this accessor cannot go stale.
217
+ *
218
+ * A machine that needs the identity of its host — a `machineId`, a tenant — takes
219
+ * it through the `input` of the actor, where it belongs: that identity decides
220
+ * WHICH machine runs, and it is not a param of a route inside the machine.
221
+ *
222
+ * @example
223
+ * ```typescript
224
+ * bridge.basePath; // "/abc123/play"
225
+ * bridge.basePathParams; // { machineId: "abc123" }
226
+ * ```
227
+ */
228
+ get basePathParams() {
229
+ return this.mount.params;
230
+ }
231
+ /**
232
+ * Moves the machine to a different mount point, while it stays connected.
233
+ *
234
+ * This is the "load and unload" half of a shared router. It moves WHERE an actor is
235
+ * mounted, and never WHICH actor is mounted: the actor, the route map, and its LRU
236
+ * cache all stay, and nothing goes away.
237
+ *
238
+ * An actor never changes identity. A prefix that IDENTIFIES the actor — a
239
+ * `machineId` that names the document it runs — therefore never moves through this
240
+ * method: a new identity is a new actor, and a new actor takes a new bridge, because
241
+ * `connect()` permits one bridge for each actor. The segments that move here are the
242
+ * ones that LOCATE: a region, a locale, a tenant, a workspace slug.
243
+ *
244
+ * The call then brings the location in step with the NEW prefix. A location inside
245
+ * the new mount runs the same first-synchronization decision as `connect()`: it
246
+ * drives the actor, and a restore keeps the route of the actor. A location OUTSIDE
247
+ * the new mount is the old mount in practice, so the bridge writes the new one
248
+ * itself, and it keeps the route of the actor while it does so — nothing else moves
249
+ * the address bar. A call that resolves to the current prefix changes no location,
250
+ * and it therefore reconciles nothing.
251
+ *
252
+ * @param basePath - The new prefix, as a pattern or as a concrete path. An absent
253
+ * value, `""`, and `"/"` all remove the prefix, and they give the machine the
254
+ * complete router again.
255
+ * @param basePathParams - The values of the `:param` segments of `basePath`.
256
+ * @throws {InvalidBasePathError} For a prefix that resolves to one concrete path never.
257
+ * @throws {MissingBasePathParamError} When a `:param` of the prefix has no value.
258
+ *
259
+ * @example
260
+ * ```typescript
261
+ * // The host moved this actor to another place in its URL space. `machineId` names
262
+ * // WHICH document the actor runs, and it does not change: a new identity is a new
263
+ * // actor, and therefore a new bridge, because one actor takes one bridge.
264
+ * bridge.setBasePath("/:region/:machineId/play", { region: "us", machineId });
265
+ * ```
266
+ */
267
+ setBasePath(basePath, basePathParams) {
268
+ const next = resolveBasePath(basePath, basePathParams);
269
+ const samePath = next.path === this.mount.path;
270
+ // Take the new mount in every case, also when the prefix STRING does not change: a
271
+ // caller can move between a literal that holds the value already ("/abc123/play")
272
+ // and the equal pattern ("/:machineId/play" with `{ machineId }`). The two give the
273
+ // same path and different params, and `basePathParams` reports those params. They
274
+ // travel in NO `play.route` event: the machine is authoritative over its own
275
+ // params, and a param of the mount belongs to the host.
276
+ this.mount = next;
277
+ // The same prefix means that no location changed, so there is nothing to bring in
278
+ // step, and `lastSyncedPath` still describes a location that is still current.
279
+ if (samePath)
280
+ return;
281
+ if (this.isConnected) {
282
+ // The machine was at no location under the NEW prefix yet, and lastSyncedPath
283
+ // describes the old one. A clear of that field lets the first synchronization
284
+ // below push or send without a stop at the dedup guard.
285
+ //
286
+ // The clear stays INSIDE this guard, with the three records below: each of them
287
+ // is a statement about a connection, and a bridge that is not connected has
288
+ // none to correct. `connect()` clears the same record for the next one.
289
+ this.lastSyncedPath = null;
290
+ // Both records of the OLD mount go with it. The latch says "the location lies
291
+ // outside the mount", and the memory says "the actor is not where the address bar
292
+ // says" — each of them is a statement about the prefix that this call replaces, so
293
+ // neither survives it. `reconcileLocationWithMount` writes both again for a
294
+ // location that it can read; for a location that it CANNOT read — an adapter whose
295
+ // `getInitialRouterPath()` returns `null` or `undefined` at this moment — a stale
296
+ // latch silenced the direction from the actor to the router for the rest of the
297
+ // session, at a mount that may well cover the location already.
298
+ this.mountState = "under-mount";
299
+ // The first synchronization of the NEW prefix is owed again. The one that ran
300
+ // against the old prefix says nothing about this one: a location that the old
301
+ // mount covered can lie outside the new mount, and the decision between a deep
302
+ // link and a restore therefore has to run again for the prefix that replaces it.
303
+ this.hasSynchronizedUnderMount = false;
304
+ // No rollback here, unlike in `connect()`. A throw there escapes a call that
305
+ // returned never, so the caller holds nothing for a cleanup. Here the bridge is
306
+ // connected already, and the caller holds its `disconnect` already. A teardown
307
+ // on a failed move of the mount would make that handle a silent no-op, and it
308
+ // would stop a bridge that the caller still believes in. A throw from here leaves
309
+ // the NEW mount in place — `resolveBasePath` above refuses an invalid prefix
310
+ // before the assignment — so the caller sees the throw and it decides.
311
+ this.reconcileLocationWithMount();
312
+ }
313
+ }
314
+ /**
315
+ * Brings the location of the router in step with a mount that just moved.
316
+ *
317
+ * **The location lies under the new mount:** run the same first-synchronization
318
+ * decision as `connect()`, so a location inside the mount drives the actor and a
319
+ * restore keeps the route of the actor.
320
+ *
321
+ * **The location lies outside it:** the bridge writes the new mount ITSELF, and it
322
+ * keeps the route of the actor while it does so. A host that re-points a mount
323
+ * changes no location by itself, and nothing else moves the address bar. Without
324
+ * this push the machine sits at a mount that no location is under: it hears no
325
+ * `play.route`, `lastSyncedPath` is clear already, and the stale prefix stays in the
326
+ * URL until the route of the actor happens to change — which then jumps the URL with
327
+ * no reason that the history of the user explains.
328
+ *
329
+ * `pushResolvedRoute` writes `lastSyncedPath` before it navigates, so the callback
330
+ * of the router for this push stops at the echo suppression, and it sends no event.
331
+ */
332
+ reconcileLocationWithMount() {
333
+ const routerPath = this.getInitialRouterPath();
334
+ // `sanitizePathname` refuses a path of more than 2048 characters, and the RAW value
335
+ // is no substitute for the clean one: it still carries a query, a fragment, and a
336
+ // duplicate slash, so a compare against the mount reads a location of the host as
337
+ // "inside the mount", or the opposite. Such a location is one that this bridge
338
+ // cannot place, exactly like a location that the adapter cannot read.
339
+ const sanitized = typeof routerPath === "string" ? sanitizePathname(routerPath) : null;
340
+ // A location that reads as `null` because the adapter cannot read its router
341
+ // synchronously is NOT a location outside the mount. Leave that case to
342
+ // `performInitialSync`, which handles an absent location already, and which
343
+ // refuses a path that is too long.
344
+ if (typeof routerPath !== "string" || sanitized === null) {
345
+ this.performInitialSync(routerPath);
346
+ return;
347
+ }
348
+ const machinePath = stripBasePath(sanitized, this.mount.path);
349
+ if (machinePath !== null) {
350
+ // Hand the location on, so that the adapter is asked for it ONE time: a read of
351
+ // `getInitialRouterPath()` belongs to the adapter, it can walk the state of a
352
+ // router, and a second read can answer differently from the first.
353
+ this.performInitialSync(routerPath);
354
+ return;
355
+ }
356
+ const currentActorRoute = this.actor.currentRoute.get();
357
+ if (!currentActorRoute) {
358
+ // Nothing to write yet. The location still belongs to the HOST, and recording
359
+ // that is the whole job of this branch: without it the first route of the actor
360
+ // would write over the page of the host.
361
+ this.markOutside();
362
+ return;
363
+ }
364
+ // The route of the actor has to resolve to a concrete path BEFORE the latch comes
365
+ // down. `pushResolvedRoute` writes nothing for an unknown stateId and for a
366
+ // parameterized pattern, and it reports nothing either: a lowered latch then said
367
+ // that the bridge owns an address bar that still holds a location of the HOST, and
368
+ // the next route change of the actor — an `after` timer, a guard that settles —
369
+ // wrote over the page of the host with no URL event that asked for it.
370
+ //
371
+ // Keep the latch UP for that case, and remember the push, exactly as the branch
372
+ // above does for an actor with no route yet.
373
+ const resolved = this.resolveNavigationPath(currentActorRoute);
374
+ if (resolved === null) {
375
+ this.mountState = "outside-push-owed";
376
+ return;
377
+ }
378
+ // The bridge owns the address bar again the moment it writes the new mount.
379
+ this.mountState = "under-mount";
380
+ // The push below IS the first synchronization of this mount: the actor won it, and
381
+ // the bridge wrote the location. A debt that stayed said the opposite, and
382
+ // `takeReturnUnderMount` paid it at the first LATER return under the mount: it read
383
+ // the route of the actor as a restore and pushed it, so a deliberate navigation of
384
+ // the host to the mount point landed the user somewhere else, with no `play.route`
385
+ // event for the location they chose.
386
+ this.hasSynchronizedUnderMount = true;
387
+ // The resolution above travels with the route, so `resolveNavigationPath` runs one
388
+ // time: the method is `protected`, a subclass can override it, and a second call
389
+ // can answer differently from the first.
390
+ this.pushHoldingNavigation(currentActorRoute, resolved);
391
+ }
392
+ /**
393
+ * Pushes a route to the router, and it holds {@link isProcessingNavigation} across
394
+ * the write.
395
+ *
396
+ * Every corrective push of the bridge needs that flag: `navigateRouter` makes the
397
+ * router call `syncActorFromRouter` again inside it, and a router that NORMALIZES the
398
+ * location it receives — a `validateSearch` that injects a default, a trailing slash
399
+ * that a history adds — reports a value that the echo test misses. The bridge would
400
+ * then answer its own write with a `play.route` event, and on a press of BACK that
401
+ * answer is a history entry that FORWARD cannot return to.
402
+ *
403
+ * The one push that does NOT come through here is `syncRouterFromActor`: that push
404
+ * follows the actor, and the echo suppression of `lastSyncedPath` is what stops it.
405
+ *
406
+ * @param route - The raw value of the actor route.
407
+ * @param resolved - The concrete path of that route, when the caller resolved it
408
+ * already. `undefined` resolves it in `pushResolvedRoute`.
409
+ */
410
+ pushHoldingNavigation(route, resolved) {
411
+ this.isProcessingNavigation = true;
412
+ try {
413
+ this.pushResolvedRoute(route, resolved);
414
+ }
415
+ finally {
416
+ this.isProcessingNavigation = false;
417
+ }
105
418
  }
106
419
  // ── The RouterBridge protocol. It is final: a subclass must override nothing here ──
107
420
  /**
@@ -112,7 +425,7 @@ export class RouterBridgeBase {
112
425
  * in its own way.
113
426
  *
114
427
  * The order of these steps is part of the contract of the bridge:
115
- * - The constructor seeds `lastSyncedPath` from `actor.currentRoute`
428
+ * - The constructor seeds `lastActorRoute` from `actor.currentRoute`, and it records no location
116
429
  * - The method installs the actor watcher before the router subscriptions of the adapter
117
430
  * - The first synchronization then separates a deep link from a restore, with `actor.initialRoute`
118
431
  *
@@ -133,54 +446,153 @@ export class RouterBridgeBase {
133
446
  throw new DuplicateBridgeError();
134
447
  }
135
448
  activeBridges.set(this.actor, this);
449
+ // Seed the dedup again, exactly as the constructor does, and for the same reason:
450
+ // the field must describe the route of the actor at the moment this connection
451
+ // starts. `disconnect()` leaves the value of the PREVIOUS connection there, and the
452
+ // actor can move while nothing watches it — a provider that unmounts and mounts
453
+ // again does that, and so does the pair of `<StrictMode>`. The write also covers
454
+ // the gap between the constructor and the first `connect()`, which the Vue provider
455
+ // reaches through `await router.isReady()`.
456
+ //
457
+ this.lastActorRoute = this.actor.currentRoute.get() ?? null;
458
+ // This connection has observed NO location yet: `performInitialSync` below is the
459
+ // first read of the router. Say so, rather than keep the location of the previous
460
+ // connection — `disconnect()` leaves that value behind, and `setBasePath` can move
461
+ // the prefix while nothing is connected, so the value can describe a location under
462
+ // a prefix that is gone. `isEchoOfLastSync` then read the real location of the
463
+ // router as an echo of this bridge, and the first synchronization sent no
464
+ // `play.route` for it: the URL and the actor separated with no error.
465
+ this.lastSyncedPath = null;
136
466
  this.isConnected = true;
137
467
  this.hasConnectedOnce = true;
138
- // Install the TC39 Signal watcher of the direction from the actor to the router
139
- this.routeWatcher = createRouteWatcher(this.actor.currentRoute, (route) => {
140
- this.syncRouterFromActor(route);
141
- });
142
- // Start the watch of the router changes. Each framework does this in its own way
143
- this.watchRouterChanges();
144
- // The first synchronization: the direction from the router to the actor has the
145
- // priority over the other direction.
468
+ // From here on, connect() gives all or nothing. THREE steps below can throw, and
469
+ // the rollback has to cover each of them:
470
+ // - `createRouteWatcher`, on a signal that refuses a watcher
471
+ // - `watchRouterChanges()`, which belongs to the adapter: `afterNavigate()` of
472
+ // SvelteKit raises `lifecycle_outside_component` outside the initialization of
473
+ // a component, and a host reaches that by calling `connectRouter` from a module
474
+ // scope or a store
475
+ // - `performInitialSync()`, with a URLPatternUnavailableError for a parameterized
476
+ // route on a runtime without URLPattern, and a RouterSyncError for each other
477
+ // failure of the send to the actor
146
478
  //
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.
479
+ // The throw needs an action of the caller, and it therefore goes to the caller
480
+ // without a change.
152
481
  //
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().
156
- const initialRouterPath = this.getInitialRouterPath();
157
- const initialRouterSearch = this.getInitialRouterSearch();
482
+ // connect() then returns never, and the caller holds therefore NO handle for a
483
+ // cleanup. Every part above must go away again, or it leaks:
484
+ // - the router listener keeps firing, and it drives the actor through a bridge
485
+ // that the caller believes to be dead
486
+ // - the signal watcher keeps pushing each actor route to the router
487
+ // - the activeBridges entry keeps the actor, so the NEXT connect() call of the
488
+ // caller for this actor throws a DuplicateBridgeError. A repair of the real
489
+ // cause, for example a load of the polyfill, rescues that actor never.
490
+ try {
491
+ // The TC39 Signal watcher of the direction from the actor to the router.
492
+ this.routeWatcher = createRouteWatcher(this.actor.currentRoute, (route) => {
493
+ this.syncRouterFromActor(route);
494
+ });
495
+ // The watch of the router changes. Each framework does this in its own way.
496
+ this.watchRouterChanges();
497
+ this.performInitialSync(this.getInitialRouterPath());
498
+ }
499
+ catch (error) {
500
+ // disconnect() is exactly this teardown, and it is idempotent. It runs inside a
501
+ // guard of its own, because `unwatchRouterChanges()` belongs to the adapter and
502
+ // it can throw: a Vue scope that stops, a Solid disposer, a SvelteKit teardown.
503
+ // That error must NOT replace the one the rollback exists to surface — a caller
504
+ // that receives it never learns that its runtime needs a URLPattern polyfill.
505
+ try {
506
+ this.disconnect();
507
+ }
508
+ catch {
509
+ // The teardown failed, so the slot of the actor may still be taken. The
510
+ // original error still reaches the caller, which is the one that names a
511
+ // cause it can repair.
512
+ }
513
+ throw error;
514
+ }
515
+ }
516
+ /**
517
+ * Brings the actor and the router in step one time, and decides which side wins.
518
+ *
519
+ * The method is separate from `connect()`, so that `connect()` can roll the whole
520
+ * first synchronization back when it throws. See the `try` block there.
521
+ *
522
+ * The first synchronization: the direction from the router to the actor has the
523
+ * priority over the other direction.
524
+ *
525
+ * The page can load on a URL that is not the initial state of the actor. For
526
+ * example, the user types "/about", or the user follows a deep link. The router
527
+ * holds the correct path then, and the actor is still at its initial state ("/").
528
+ * The code must therefore drive the actor to the URL, and it must not write the
529
+ * default of the actor to the URL.
530
+ *
531
+ * router.subscribe() fires on a *later* navigation event only. It does NOT fire for
532
+ * the location that the page loaded. A subclass that can read the current location
533
+ * of its router synchronously overrides getInitialRouterPath().
534
+ *
535
+ * @param initialRouterPath - The location of the router. Every caller reads it and
536
+ * passes it, and this parameter carries NO default: a default fires for an
537
+ * explicit `undefined` too, so `reconcileLocationWithMount` asked the adapter a
538
+ * SECOND time for exactly the value that says "the adapter does the first
539
+ * synchronization itself" — and a read belongs to the adapter, which can walk the
540
+ * state of a router and answer differently on the second call.
541
+ */
542
+ performInitialSync(initialRouterPath) {
158
543
  const initialActorRoute = this.actor.currentRoute.get();
159
544
  if (typeof initialRouterPath === "string") {
545
+ // `sanitizePathname` refuses a path of more than 2048 characters, and the RAW
546
+ // value is no substitute for the clean one: it still carries a query, a
547
+ // fragment, and a duplicate slash, so a compare against the mount reads a
548
+ // location of the host as "inside the mount", or the opposite. The route
549
+ // resolution refuses such a path anyway — `syncActorFromRouter` sanitizes it
550
+ // again and returns — so touch no latch for it and reconcile nothing.
551
+ const sanitized = sanitizePathname(initialRouterPath);
552
+ if (sanitized === null)
553
+ return;
554
+ // Take the machine half of the location of the router. A location OUTSIDE the
555
+ // mount belongs to the host, so the bridge stays out of it completely: it sends
556
+ // no event, and — this part makes a shared router work — it runs no corrective
557
+ // navigation either. A correction here would drag the user off a page of the
558
+ // host at the moment when the bridge connects.
559
+ const machinePath = stripBasePath(sanitized, this.mount.path);
560
+ if (machinePath === null) {
561
+ this.markOutside();
562
+ return;
563
+ }
564
+ // The symmetric write, and the reason this method has to make it: a location
565
+ // that lies INSIDE the mount means the bridge owns the address bar, and this
566
+ // method is reached without a router event — by `connect()`, and by a
567
+ // `setBasePath()` that moves the mount to where the location already is. A
568
+ // latch that only ever went up silenced the machine for the rest of the
569
+ // session, because nothing else here lowers it.
570
+ // The synchronization below brings the two sides in step, so a push that waited
571
+ // for the address bar to come back is spent with the same write. A memory that
572
+ // survived would replay that push on the next return, at a location the router
573
+ // already holds.
574
+ this.mountState = "under-mount";
575
+ // The decision below IS the first synchronization, and it runs against a location
576
+ // under the mount, so nothing is owed any more.
577
+ this.hasSynchronizedUnderMount = true;
160
578
  // 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.
165
- const resolvedStateId = this.routeMap.getStateIdByPath(sanitizePathname(initialRouterPath) ?? initialRouterPath);
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.
174
- const actorAlreadyAtRouterLocation = resolvedStateId !== null &&
175
- resolvedStateId !== undefined &&
176
- (resolvedStateId === initialActorRoute ||
177
- initialRouterPath === initialActorRoute ||
178
- (initialActorRoute != null &&
179
- !initialActorRoute.startsWith("/") &&
180
- this.lookupPathByStateId(initialActorRoute) != null &&
181
- this.lookupPathByStateId(initialActorRoute) ===
182
- this.lookupPathByStateId(resolvedStateId)));
183
- if (!actorAlreadyAtRouterLocation) {
579
+ const actorAlreadyAtRouterLocation = this.isActorAtLocation(machinePath, initialActorRoute);
580
+ // The two are in step already, so nothing is synchronized — but the location has
581
+ // to be RECORDED, or the echo suppression starts disarmed. `setBasePath` clears
582
+ // `lastSyncedPath` on a move of the prefix, and this branch left it null: the
583
+ // next callback of the router for that same location then read as a real move
584
+ // and sent a redundant `play.route`, and the next route change of the actor
585
+ // pushed a location the router already held.
586
+ //
587
+ // The write is UNCONDITIONAL, and a test for a null field is wrong here: the field
588
+ // must describe the location that this method just found in step, whatever it
589
+ // held before. `setBasePath` can re-point the mount between two synchronizations
590
+ // of one connection, and the record of the old prefix says nothing about this
591
+ // location.
592
+ if (actorAlreadyAtRouterLocation) {
593
+ this.lastSyncedPath = withSearch(machinePath, this.getInitialRouterSearch());
594
+ }
595
+ else {
184
596
  // The path of the router is different from the actor route. Is this a deep link, or
185
597
  // is it a restore?
186
598
  //
@@ -195,12 +607,15 @@ export class RouterBridgeBase {
195
607
  // The detection: the URL of the router is the initial route of the machine, AND the
196
608
  // actor is at a different route. This is then a restore.
197
609
  if (initialActorRoute &&
198
- initialRouterPath === this.actor.initialRoute &&
610
+ machinePath === this.actor.initialRoute &&
199
611
  initialActorRoute !== this.actor.initialRoute) {
200
- this.pushResolvedRoute(initialActorRoute);
612
+ this.pushHoldingNavigation(initialActorRoute);
201
613
  }
202
614
  else {
203
- this.syncActorFromRouter(initialRouterPath, initialRouterSearch);
615
+ // The search is read HERE, and not at the top of the method: the read belongs
616
+ // to the adapter, it can walk the state of a router, and every branch above
617
+ // returns without it.
618
+ this.syncActorFromRouter(initialRouterPath, this.getInitialRouterSearch());
204
619
  }
205
620
  }
206
621
  }
@@ -209,11 +624,18 @@ export class RouterBridgeBase {
209
624
  // bootstrap → push the actor route.
210
625
  // The value undefined means that the adapter does the first synchronization itself.
211
626
  // Continue.
212
- this.pushResolvedRoute(initialActorRoute);
627
+ this.pushHoldingNavigation(initialActorRoute);
628
+ // The push IS the first synchronization of this mount, and the actor won it. A
629
+ // debt that stayed said the opposite, and `takeReturnUnderMount` paid it at the
630
+ // first LATER return under the mount: it read the route of the actor as a restore
631
+ // and pushed it, so a deliberate navigation of the host to the mount point landed
632
+ // the user somewhere else, with no `play.route` event for the location they chose.
633
+ this.hasSynchronizedUnderMount = true;
213
634
  }
214
- else if (initialActorRoute && initialActorRoute !== this.lastSyncedPath) {
635
+ else if (initialActorRoute && initialActorRoute !== this.lastActorRoute) {
215
636
  // No path of the router replaces the actor route → write the router from the actor, as before
216
637
  this.syncRouterFromActor(initialActorRoute);
638
+ this.hasSynchronizedUnderMount = true;
217
639
  }
218
640
  }
219
641
  /**
@@ -233,16 +655,39 @@ export class RouterBridgeBase {
233
655
  }
234
656
  }
235
657
  this.routeWatcher = null;
236
- if (this.isConnected || hadRouteWatcher) {
237
- this.unwatchRouterChanges();
658
+ // `unwatchRouterChanges()` belongs to the adapter, and it can throw: a Vue scope
659
+ // that stops, a disposed Solid root, a SvelteKit teardown. Everything below it
660
+ // still has to run — a `finally`, and not a plain sequence. Without it the state
661
+ // stayed connected and the slot of the actor stayed taken, so every later
662
+ // `connect()` for that actor threw a DuplicateBridgeError, which is the one error
663
+ // that naming the real cause cannot repair. That also makes `disconnect()`
664
+ // idempotent, which the rollback of `connect()` depends on.
665
+ try {
666
+ if (this.isConnected || hadRouteWatcher) {
667
+ this.unwatchRouterChanges();
668
+ }
669
+ }
670
+ catch {
671
+ // Ignore an error of the teardown of the adapter, exactly as the watcher above
672
+ // ignores one. `disconnect()` is what a provider calls on an unmount, and a
673
+ // failure to release one subscription must not break that unmount — nor the
674
+ // rollback of `connect()`, which calls this method and needs the error of the
675
+ // first synchronization to reach the caller.
238
676
  }
239
- this.isProcessingNavigation = false;
240
- this.isConnected = false;
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.
244
- if (activeBridges.get(this.actor) === this) {
245
- activeBridges.delete(this.actor);
677
+ finally {
678
+ this.isProcessingNavigation = false;
679
+ this.isConnected = false;
680
+ // A push that waited for the address bar to come back dies with the connection,
681
+ // and so does the record of where the location lay. A later `connect()` runs its
682
+ // own first synchronization, and it decides all three again.
683
+ this.mountState = "under-mount";
684
+ this.hasSynchronizedUnderMount = false;
685
+ // Free the slot of the actor, so that a new bridge can connect to the same
686
+ // actor. Clear the slot only when this bridge is the registered one: a second
687
+ // bridge that connect() refused must not remove the legitimate bridge.
688
+ if (activeBridges.get(this.actor) === this) {
689
+ activeBridges.delete(this.actor);
690
+ }
246
691
  }
247
692
  }
248
693
  // ── The sync methods. They are protected, and a subclass overrides one for its own behavior ──
@@ -271,9 +716,239 @@ export class RouterBridgeBase {
271
716
  return;
272
717
  if (!route || typeof route !== "string")
273
718
  return;
274
- if (route === this.lastSyncedPath)
719
+ // The host owns the address bar, so this bridge writes no URL. The inbound
720
+ // direction stays silent for the same location, and the two directions have to
721
+ // agree: a route that changes for a reason that is NOT a URL event — an `after`
722
+ // timer, an async guard that settles, a restore of a snapshot, a sibling that
723
+ // sends an event — would otherwise drag the user off a page of the host.
724
+ //
725
+ // The memory holds ONE fact: the actor is not where the address bar says. The
726
+ // test therefore runs on each change, and an echo CLEARS it — a route that
727
+ // wanders away and back leaves the URL correct, and a memory that stayed set
728
+ // would push a location the router already holds, which is a history entry that
729
+ // BACK cannot escape.
730
+ const echo = this.testEchoOfLastPush(route);
731
+ if (this.mountState !== "under-mount") {
732
+ this.mountState = echo.isEcho ? "outside" : "outside-push-owed";
275
733
  return;
276
- this.pushResolvedRoute(route);
734
+ }
735
+ if (echo.isEcho)
736
+ return;
737
+ this.pushResolvedRoute(route, echo.resolved);
738
+ }
739
+ /**
740
+ * Records that the location lies outside the mount, so the HOST owns the address bar.
741
+ *
742
+ * A bridge that is outside ALREADY keeps the value it holds: `"outside-push-owed"`
743
+ * says that a push waits for the address bar to come back, and a second location of
744
+ * the host is no reason to forget it.
745
+ */
746
+ markOutside() {
747
+ if (this.mountState === "under-mount")
748
+ this.mountState = "outside";
749
+ }
750
+ /**
751
+ * Takes the return of the address bar under the mount.
752
+ *
753
+ * The actor wins that return when it moved while the host owned the location AND the
754
+ * address bar came back to the location it still held: the method pushes the route of
755
+ * the actor, and the caller sends no `play.route` event. A return to any other route
756
+ * of the mount belongs to the host, so the memory goes and the caller drives the
757
+ * actor from the location instead.
758
+ *
759
+ * The method also pays the first synchronization that the mount still owes. A bridge
760
+ * that connected while the host owned the address bar reached no decision between a
761
+ * deep link and a restore: `performInitialSync` can decide only for a location under
762
+ * the mount, and it found none. This is the first such location, so the decision runs
763
+ * here, with the same rule.
764
+ *
765
+ * The method holds `isProcessingNavigation` across each push, because
766
+ * `navigateRouter` makes the router call `syncActorFromRouter` again.
767
+ *
768
+ * @returns `true` when the method handled the location, so the caller returns.
769
+ */
770
+ takeReturnUnderMount(machinePath, search) {
771
+ const previous = this.mountState;
772
+ if (previous === "under-mount")
773
+ return false;
774
+ // The bridge owns the address bar again from here. The memory of a push that waited
775
+ // travels in `previous`, and the write below spends it in the SAME step: no branch
776
+ // has to clear it, and no branch can forget to.
777
+ this.mountState = "under-mount";
778
+ // The first synchronization of this mount never ran: `connect()`, or a
779
+ // `setBasePath()` that moved the mount, found the location outside the prefix, so
780
+ // `performInitialSync` raised the latch and returned before it could separate a
781
+ // deep link from a restore. Make that decision HERE, at the first location that
782
+ // lies under the mount, with the rule of `performInitialSync`: the router is at
783
+ // the initial route of the machine AND the actor is at another one, so a snapshot
784
+ // restored the actor and the actor wins.
785
+ //
786
+ // Without this the restore was lost the moment the host showed the machine: the
787
+ // location drove the actor back to the route of the mount point, while the SAME
788
+ // bridge connected at the SAME location kept the restored route. Which of the two
789
+ // happened depended on where the one shared router stood at the moment of
790
+ // `connect()`, which is the arbitrary part.
791
+ const owesFirstSync = !this.hasSynchronizedUnderMount;
792
+ this.hasSynchronizedUnderMount = true;
793
+ const restoredRoute = this.actor.currentRoute.get();
794
+ // `isActorAtLocation` is the test that `performInitialSync` makes BEFORE it looks
795
+ // for a restore, and both branches of the owed synchronization below need it. The
796
+ // actor route can be a stateId, and a stateId is never string-equal to
797
+ // `initialRoute`, which is a path: an actor that sits at "#home" of the mount point
798
+ // read as a restore, so the bridge pushed "/abc123/play" over the location the HOST
799
+ // chose, dropped its query, and returned `true` — so no `play.route` carried that
800
+ // location to the machine either.
801
+ //
802
+ // ONE call, because `getStateIdByPath` and `getPathByStateId` belong to the route
803
+ // map: a map of a consumer can answer differently on a second call, and the two
804
+ // branches would then disagree about the same location.
805
+ const actorAtLocation = owesFirstSync && this.isActorAtLocation(machinePath, restoredRoute);
806
+ if (owesFirstSync &&
807
+ restoredRoute &&
808
+ machinePath === this.actor.initialRoute &&
809
+ restoredRoute !== this.actor.initialRoute &&
810
+ !actorAtLocation) {
811
+ return this.pushUnderMount(restoredRoute);
812
+ }
813
+ if (owesFirstSync) {
814
+ // The restore does not apply, and the bridge held the address bar under this
815
+ // mount NEVER: `performInitialSync` found the location outside the prefix and
816
+ // returned before it could record one. This location is therefore the first one
817
+ // that the mount can place.
818
+ //
819
+ // The memory is dropped already, so hand the location back to the caller. The
820
+ // caller then drives the actor from it, which is the deep-link half of the rule
821
+ // that `performInitialSync` applies.
822
+ //
823
+ // The FIRST half of that rule, and this branch owes it too: `performInitialSync`
824
+ // asks whether the actor stands at the location already, and it records the
825
+ // location and sends NOTHING when it does. Without the same test here, the same
826
+ // two situations answered differently — the bridge that connected under the mount
827
+ // stayed quiet, and the bridge that connected outside it re-entered the state the
828
+ // actor was already in, which runs every entry action of that state again. Which
829
+ // of the two happened depended on where the one shared router stood at the moment
830
+ // of `connect()`, which is the arbitrary part.
831
+ if (actorAtLocation) {
832
+ this.lastSyncedPath = withSearch(machinePath, search);
833
+ return true;
834
+ }
835
+ this.lastSyncedPath = null;
836
+ return false;
837
+ }
838
+ if (previous !== "outside-push-owed")
839
+ return false;
840
+ // The actor wins a return to the location that the address bar STILL HELD. That is
841
+ // the machine keeping its place while the user read a page of the host.
842
+ //
843
+ // It must not win a return to another route of the mount. A location that the
844
+ // address bar did not hold is a choice of the host — a link that the user pressed,
845
+ // a deep link they opened — and a push of the route of the actor would land them
846
+ // on a page they did not ask for, with no explanation. Drop the memory instead,
847
+ // and let the caller send the event for the location they chose.
848
+ if (!this.isEchoOfLastSync(machinePath, search))
849
+ return false;
850
+ // The route that the block above read already. One read, because
851
+ // `actor.currentRoute` belongs to the consumer and a second read can answer
852
+ // differently from the first.
853
+ //
854
+ // A falsy route has nothing to write, and the bridge owns the address bar again
855
+ // from the top of this method: `syncRouterFromActor` pushes the first real route of
856
+ // the actor by itself, so no replay is needed. A memory kept here could be spent
857
+ // only by a LATER return, at which point it pushes a location the router already
858
+ // holds — a history entry that BACK cannot escape.
859
+ if (!restoredRoute)
860
+ return false;
861
+ return this.pushUnderMount(restoredRoute);
862
+ }
863
+ /**
864
+ * Writes the route of the actor to a router that just came under the mount.
865
+ *
866
+ * The method holds `isProcessingNavigation` across the push, because
867
+ * `navigateRouter` makes the router call `syncActorFromRouter` again inside it.
868
+ *
869
+ * A re-entrant call pushes nothing inside another push, and the memory of a
870
+ * suppressed push DOES survive that case: the outer push is still in flight, and a
871
+ * clear here would throw the remembered route away with nothing left to replay it.
872
+ *
873
+ * @param route - The raw value of the actor route.
874
+ * @returns `true` when the method wrote the location, so that the caller returns and
875
+ * sends no event. `false` when the route resolves to no concrete path, so that the
876
+ * caller drives the actor from the location instead.
877
+ */
878
+ pushUnderMount(route) {
879
+ if (this.isProcessingNavigation)
880
+ return true;
881
+ // The route has to resolve to a concrete path BEFORE the method reports that it
882
+ // handled the location. `pushResolvedRoute` writes nothing for an unknown stateId
883
+ // and for a parameterized pattern, and it still records the RAW value in
884
+ // `lastSyncedPath`: a `true` return then left the location in neither side — the
885
+ // URL kept the page of the host, the actor heard no `play.route`, and the record of
886
+ // the location that the address bar holds was gone, so no later return could be
887
+ // recognized either. `reconcileLocationWithMount` guards the same case.
888
+ //
889
+ // The resolution travels with the route, so `resolveNavigationPath` runs one time:
890
+ // the method is `protected`, a subclass can override it, and a second call can
891
+ // answer differently from the first.
892
+ const resolved = this.resolveNavigationPath(route);
893
+ if (resolved === null) {
894
+ // Clear the record of the location, or the `false` return reaches the caller
895
+ // and changes nothing. The caller hands the location back for the SAME location
896
+ // that `lastSyncedPath` describes — the suppressed-push branch tests
897
+ // `isEchoOfLastSync` before it calls this method — so the caller read its own
898
+ // record as an echo and returned. The actor then stayed on a route that resolves
899
+ // to no URL, the location it was given reached it never, and no error said so.
900
+ this.lastSyncedPath = null;
901
+ return false;
902
+ }
903
+ this.pushHoldingNavigation(route, resolved);
904
+ return true;
905
+ }
906
+ /**
907
+ * Tests a route of the actor against the last synchronized location.
908
+ *
909
+ * The exact string test covers a stateId, for example `"#home"`, which
910
+ * `pushResolvedRoute` writes to the field when it cannot resolve a path. The query
911
+ * test covers a location that carries a query: the order of the keys of a query is
912
+ * not significant, so `?b=2&a=1` and `?a=1&b=2` are the same location, and the
913
+ * bridge must push neither of them back.
914
+ *
915
+ * The FRAGMENT counts in this direction, and the inbound test ignores it. The two
916
+ * are different questions. `navigateRouter` writes the whole value that the actor
917
+ * gave — `buildRouteUrl` appends `context.hash` — so `"/docs"` and `"/docs#intro"`
918
+ * are two locations to write, and a test that read them as one left the fragment out
919
+ * of the address bar for ever. `isEchoOfLastSync` compares against what the ROUTER
920
+ * reports, and `sanitizePathname` has removed the fragment from that already.
921
+ */
922
+ testEchoOfLastPush(route) {
923
+ // The FIRST question: did the bridge account for this value of the signal already?
924
+ // The test is an exact string compare, because it asks about the VALUE. A route
925
+ // that resolves to no URL — an unknown stateId, a parameterized pattern — is
926
+ // therefore covered too, and the bridge tries it one time only.
927
+ if (route === this.lastActorRoute)
928
+ return { isEcho: true };
929
+ const location = this.lastSyncedPath;
930
+ // The bridge placed no location yet, so it knows nothing about the address bar.
931
+ // Resolve for the caller, and let it push.
932
+ if (location === null) {
933
+ return { isEcho: false, resolved: this.resolveNavigationPath(route) };
934
+ }
935
+ // The SECOND question: does the router hold the location that this route describes
936
+ // already? A route that IS a path answers it directly.
937
+ if (sameLocation(route, location))
938
+ return { isEcho: true };
939
+ // A route that is a stateId answers it only after the resolution. The machine that
940
+ // mirrors an inbound `play.route` back into its route signal reaches this line:
941
+ // the bridge wrote "/about" and the actor answers "#about", which is ONE location.
942
+ // A compare of the raw value alone missed, and the bridge pushed a location that
943
+ // the router already held — one history entry that BACK cannot escape.
944
+ //
945
+ // The resolution travels back to the caller, so `resolveNavigationPath` runs one
946
+ // time: the method is `protected`, a subclass can override it, and a second call
947
+ // can answer differently from the first.
948
+ const resolved = this.resolveNavigationPath(route);
949
+ if (resolved !== null && sameLocation(resolved, location))
950
+ return { isEcho: true };
951
+ return { isEcho: false, resolved };
277
952
  }
278
953
  /**
279
954
  * Resolves an actor route to its concrete URL path, and pushes that path to the
@@ -290,13 +965,28 @@ export class RouterBridgeBase {
290
965
  * literal `*` into the browser URL. The method still writes the raw route to
291
966
  * lastSyncedPath. Therefore the dedup guard fires correctly on the next identical
292
967
  * value of the signal.
968
+ *
969
+ * @param route - The raw value of the actor route.
970
+ * @param resolved - The concrete path of that route. The default resolves it here. A
971
+ * caller that resolved it already passes it, so that `resolveNavigationPath` runs
972
+ * one time: the method is `protected`, and a subclass can override it.
293
973
  */
294
- pushResolvedRoute(route) {
295
- const resolved = this.resolveNavigationPath(route);
296
- this.lastSyncedPath = resolved ?? route;
974
+ pushResolvedRoute(route, resolved = this.resolveNavigationPath(route)) {
975
+ // The VALUE is accounted for either way, so the dedup stops the next identical fire
976
+ // of the signal even when the route resolves to no URL.
977
+ this.lastActorRoute = route;
978
+ // The LOCATION record takes the concrete path only. A raw stateId here described no
979
+ // location that a router ever showed, and `isEchoOfLastSync` compares this field
980
+ // against the location that the router reports: it read a real move as an echo of
981
+ // this bridge, and the actor heard nothing of it.
297
982
  if (resolved === null)
298
983
  return;
299
- this.navigateRouter(resolved);
984
+ this.lastSyncedPath = resolved;
985
+ // This is the ONE place that adds the base path. `lastSyncedPath` keeps the
986
+ // machine-side path above, with no prefix, so it still compares directly against
987
+ // an actor route in `syncRouterFromActor`; `navigateRouter` receives the location
988
+ // of the host router, because that is the location the framework navigates to.
989
+ this.navigateRouter(joinBasePath(this.mount.path, resolved));
300
990
  }
301
991
  /**
302
992
  * Writes the actor state when the location of the router changes.
@@ -322,14 +1012,37 @@ export class RouterBridgeBase {
322
1012
  const sanitized = sanitizePathname(pathname);
323
1013
  if (sanitized === null)
324
1014
  return; // The path is too long. Refuse it
325
- if (sanitized === this.lastSyncedPath)
1015
+ // Take the machine half of the location. A `null` value means that the location
1016
+ // lies OUTSIDE the mount, so it belongs to the host: return without a send of an
1017
+ // event, and without a correction of the URL. That silence lets the routes of the
1018
+ // host and the routes of the machine share one router.
1019
+ const machinePath = stripBasePath(sanitized, this.mount.path);
1020
+ if (machinePath === null) {
1021
+ this.markOutside();
1022
+ return;
1023
+ }
1024
+ // The test runs BEFORE the echo suppression. The location of a return matches
1025
+ // `lastSyncedPath` in the common case — the host comes back to the mount point it
1026
+ // left — and an early stop there would drop the suppressed push for good.
1027
+ if (this.takeReturnUnderMount(machinePath, search))
1028
+ return;
1029
+ // The location lies under the mount, and this bridge is about to place it, so the
1030
+ // first synchronization of this mount is owed no more. `takeReturnUnderMount`
1031
+ // records it for a RETURN, and it returns at its first line for a bridge that never
1032
+ // left, so the debt survived a first synchronization that reached no decision — an
1033
+ // adapter whose `getInitialRouterPath()` gave `undefined`, or gave `null` to an
1034
+ // actor with no route yet. The first later excursion of the host then came back to
1035
+ // the mount point and read that stale debt as a restore: it pushed the route of the
1036
+ // actor over the location that the user chose, with no `play.route` event for it.
1037
+ this.hasSynchronizedUnderMount = true;
1038
+ if (this.isEchoOfLastSync(machinePath, search))
326
1039
  return;
327
1040
  if (this.isProcessingNavigation)
328
1041
  return;
329
1042
  this.isProcessingNavigation = true;
330
1043
  try {
331
1044
  const nextRoute = buildPlayRouteEvent({
332
- pathname,
1045
+ pathname: machinePath,
333
1046
  search,
334
1047
  match: (nextPathname) => matchRouteMap(nextPathname, this.routeMap, (resolvedPathname, stateId) => this.extractParams(resolvedPathname, stateId)),
335
1048
  });
@@ -356,8 +1069,29 @@ export class RouterBridgeBase {
356
1069
  }
357
1070
  return;
358
1071
  }
1072
+ // Record the location BEFORE the send, and record it with its query.
1073
+ //
1074
+ // The route signal of the actor notifies the watcher of the other direction
1075
+ // for this same move, and `syncRouterFromActor` then tests the route of the
1076
+ // actor against this field. Two details decide that test:
1077
+ //
1078
+ // - The write must happen first. A write after the send leaves the previous
1079
+ // path in the field while the watcher runs, so the test misses and the
1080
+ // bridge pushes the location that the router already holds. On a press on
1081
+ // BACK that push is a new history entry, and it replaces the entry that
1082
+ // FORWARD returns to.
1083
+ // - The write must carry the query. A machine that republishes the query
1084
+ // holds `/second?id=1` as its route, and a pathname alone in this field
1085
+ // never matches it.
1086
+ this.lastSyncedPath = withSearch(nextRoute.pathname, search);
1087
+ // The router moved, and the actor has not answered yet, so the value that the
1088
+ // bridge accounted for describes the agreement of the two sides NO MORE. A value
1089
+ // that stayed read the answer of the actor as a repeat of the signal: a guard
1090
+ // redirect BACK to the route the actor came from — the route that the seed of
1091
+ // `connect()` recorded — stopped at the dedup, the bridge pushed nothing, and the
1092
+ // address bar kept the location that the guard refused.
1093
+ this.lastActorRoute = null;
359
1094
  this.actor.send(nextRoute.event);
360
- this.lastSyncedPath = nextRoute.pathname;
361
1095
  }
362
1096
  catch (error) {
363
1097
  // A URLPatternUnavailableError needs an action of the user: it goes to the caller
@@ -410,16 +1144,79 @@ export class RouterBridgeBase {
410
1144
  * through `lookupPathByStateId`. Therefore a route map of your own that holds one
411
1145
  * form only still resolves the path.
412
1146
  *
1147
+ * The result is MACHINE-side: it carries no `basePath`, because it is also the
1148
+ * value that `lastSyncedPath` stores and that the code compares against an actor
1149
+ * route. The code adds the prefix one time, at the `navigateRouter()` call in
1150
+ * `pushResolvedRoute`.
1151
+ *
413
1152
  * @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
1153
+ * @returns The concrete machine-side URL path, or `null` when the bridge must skip the navigation
415
1154
  */
416
1155
  resolveNavigationPath(route) {
417
1156
  const mapped = this.lookupPathByStateId(route);
418
1157
  const path = mapped ?? (route.startsWith("/") ? route : null);
419
- if (path === null || isParameterizedPattern(path))
1158
+ if (path === null)
1159
+ return null;
1160
+ // The PATHNAME half alone decides whether this is a pattern. A query holds a ":"
1161
+ // and a "*" legally — a time in "?at=10:30", a URL in "?next=https://x", a glob in
1162
+ // "?q=a*" — and a route of the actor carries its query, because `context.query`
1163
+ // travels in `currentRoute`. A test of the whole string read such a route as a
1164
+ // pattern: `pushResolvedRoute` wrote `lastSyncedPath` and navigated nowhere, so the
1165
+ // URL stopped following the actor, and the dedup guard then suppressed the next
1166
+ // attempt at the same route as an echo.
1167
+ if (isParameterizedPattern(pathnameOf(path)))
420
1168
  return null;
421
1169
  return path;
422
1170
  }
1171
+ /**
1172
+ * Tells you whether the actor already stands at a location of the router.
1173
+ *
1174
+ * The `currentRoute` of the actor can be a stateId, for example `"#app.home"`, while a
1175
+ * location of the router is a URL path, for example `"/home"`. Therefore the method
1176
+ * resolves the path to a stateId before it compares. Without this step, a false "the
1177
+ * two are different" starts a synchronization that nothing needs — and, in
1178
+ * `takeReturnUnderMount`, it reads a location that the HOST chose as a restore of the
1179
+ * actor and writes over it.
1180
+ *
1181
+ * The actor is at the location in three cases: the stateId of the match IS the actor
1182
+ * route, the path of the match IS the actor route, or the actor route is a stateId
1183
+ * that resolves to the same registered path as the stateId of the match. Each lookup
1184
+ * goes through {@link lookupPathByStateId}, which tries the form `"#stateId"` and also
1185
+ * the bare form `"stateId"`. Therefore a route map with `"#about"` recognizes an actor
1186
+ * route of `"about"`, and the opposite also works, and this is correct for a
1187
+ * structural map of your own that holds one form. An actor route with a concrete path,
1188
+ * which starts with `"/"`, goes to a direct comparison with the path of the router
1189
+ * only.
1190
+ *
1191
+ * Each lookup runs ONE time. `getPathByStateId` belongs to the route map: a map of a
1192
+ * consumer can answer differently on a second call, and `RouteMap` pays a cache lookup
1193
+ * for each of them.
1194
+ *
1195
+ * @param machinePath - The machine half of the location of the router.
1196
+ * @param actorRoute - The raw value of the actor route.
1197
+ */
1198
+ isActorAtLocation(machinePath, actorRoute) {
1199
+ if (actorRoute == null)
1200
+ return false;
1201
+ // The identical string answers the question with no map at all, and it has to run
1202
+ // FIRST: the lookup below returns nothing for a location that the map does not
1203
+ // hold, and the early return then reported an actor standing exactly at that
1204
+ // location as "somewhere else". `performInitialSync` read that as a deep link and
1205
+ // drove the actor into the state it was in already. The seed of the constructor hid
1206
+ // this for as long as one field carried the route of the actor AND the location.
1207
+ if (machinePath === actorRoute)
1208
+ return true;
1209
+ const resolvedStateId = this.routeMap.getStateIdByPath(machinePath);
1210
+ if (resolvedStateId === null || resolvedStateId === undefined)
1211
+ return false;
1212
+ if (resolvedStateId === actorRoute)
1213
+ return true;
1214
+ if (actorRoute.startsWith("/"))
1215
+ return false;
1216
+ const actorStateIdPath = this.lookupPathByStateId(actorRoute);
1217
+ return (actorStateIdPath != null &&
1218
+ actorStateIdPath === this.lookupPathByStateId(resolvedStateId));
1219
+ }
423
1220
  /**
424
1221
  * Returns the path of the route map for a value of an actor route, and it tries both
425
1222
  * forms of a stateId.
@@ -451,6 +1248,42 @@ export class RouterBridgeBase {
451
1248
  extractQuery(search) {
452
1249
  return extractQuery(search);
453
1250
  }
1251
+ /**
1252
+ * True when a location of the router is the location that this bridge pushed.
1253
+ *
1254
+ * `lastSyncedPath` holds the value of `actor.currentRoute`, and that value carries
1255
+ * the query string of `context.query`, for example `"/dates?trip=one-way"`. A router
1256
+ * callback reports the pathname and the search SEPARATELY, and `sanitizePathname`
1257
+ * removes the query. A comparison of the pathname alone therefore misses for every
1258
+ * route with a query, and the push of this bridge comes back as a `play.route` event
1259
+ * of its own navigation. That echo is not only wasted work: it writes to the signal
1260
+ * graph from inside the watcher callback of the actor route, and the watcher then
1261
+ * arms itself again on a computed that is dirty already, which ends the direction
1262
+ * from the actor to the router for the rest of the session.
1263
+ *
1264
+ * The query is compared as a MAP, and not as a string: a framework can re-serialize
1265
+ * a search string in another order, and `URLSearchParams` also accepts the form with
1266
+ * a leading `?` and the form without one.
1267
+ *
1268
+ * @param sanitized - The clean pathname of the router callback, with no query.
1269
+ * @param search - The search string of the router callback, when it has one.
1270
+ * @returns True when the location is the echo of the last push of this bridge.
1271
+ */
1272
+ isEchoOfLastSync(sanitized, search) {
1273
+ const last = this.lastSyncedPath;
1274
+ if (last === null)
1275
+ return false;
1276
+ if (sanitized === last && (search ?? "") === "")
1277
+ return true;
1278
+ // Compare the query of BOTH sides. A pathname test alone reports an echo for a
1279
+ // move that keeps the path and changes the query, which a filter link does, and
1280
+ // which a `validateSearch` of a route does when it injects a default. The actor
1281
+ // then never hears of the move, and the URL and the actor diverge.
1282
+ const [lastPath, lastQuery] = splitLocation(last);
1283
+ if (lastPath !== sanitized)
1284
+ return false;
1285
+ return sortedQuery(lastQuery) === sortedQuery(search ?? "");
1286
+ }
454
1287
  /**
455
1288
  * Cleans a raw URL pathname of the router, and checks it.
456
1289
  *
@@ -510,6 +1343,70 @@ export class RouterBridgeBase {
510
1343
  return undefined;
511
1344
  }
512
1345
  }
1346
+ /**
1347
+ * The pathname half of a location: everything in front of the first `?` or `#`.
1348
+ *
1349
+ * A pattern test reads this half alone, because a query and a fragment carry their own
1350
+ * characters and say nothing about the shape of the path.
1351
+ */
1352
+ function pathnameOf(location) {
1353
+ const mark = firstMarkIndex(location);
1354
+ return mark === -1 ? location : location.slice(0, mark);
1355
+ }
1356
+ /**
1357
+ * Splits a location into its path, its query with no leading `?`, and its fragment
1358
+ * with its leading `#`.
1359
+ *
1360
+ * A '#' after index 0 ends the pathname. `pushResolvedRoute` writes what the actor
1361
+ * gave — "/docs#intro" — while the callback of the router reports "/docs", because
1362
+ * `sanitizePathname` strips the fragment: an echo test that compared the two whole
1363
+ * strings missed, so the bridge answered its own push with an event and a history entry
1364
+ * that BACK cannot escape.
1365
+ *
1366
+ * The fragment comes back as the THIRD field, and each caller decides whether it
1367
+ * counts: `isEchoOfLastPush` compares it, because `navigateRouter` writes it, and
1368
+ * `isEchoOfLastSync` drops it, because the router reports it never.
1369
+ *
1370
+ * At index 0 the '#' is NOT a fragment: `lastSyncedPath` legitimately holds a bare
1371
+ * stateId such as "#home", which `pushResolvedRoute` stores when it can resolve no
1372
+ * path. Cutting there would turn that value into the empty string.
1373
+ */
1374
+ function splitLocation(location) {
1375
+ const fragmentAt = location.indexOf("#", 1);
1376
+ const withoutFragment = fragmentAt === -1 ? location : location.slice(0, fragmentAt);
1377
+ const fragment = fragmentAt === -1 ? "" : location.slice(fragmentAt);
1378
+ const mark = withoutFragment.indexOf("?");
1379
+ if (mark === -1)
1380
+ return [withoutFragment, "", fragment];
1381
+ return [withoutFragment.slice(0, mark), withoutFragment.slice(mark + 1), fragment];
1382
+ }
1383
+ /** Joins a pathname and a query into one location value. An empty query adds nothing. */
1384
+ function withSearch(pathname, search) {
1385
+ const query = (search ?? "").replace(/^\?/, "");
1386
+ return query === "" ? pathname : `${pathname}?${query}`;
1387
+ }
1388
+ /**
1389
+ * The canonical form of a query string, for a comparison that ignores the order:
1390
+ * each `key=value` pair, sorted, joined with `&`. An empty search gives `""`.
1391
+ *
1392
+ * Each name AND each value are escaped, because a raw join ALIASES: with "=" and "&"
1393
+ * as literal separators, `?a=1&b=2` and `?a=1%26b%3D2` gave one canonical form for two
1394
+ * different locations. `isEchoOfLastSync` then read a real navigation as the echo of
1395
+ * its own push, so the actor never heard of the move and the URL and the actor state
1396
+ * diverged. `mountKey` of `provider-lifecycle.ts` escapes for the same reason.
1397
+ *
1398
+ * `sort()` in place, and not `toSorted()`: the array is a fresh one of `map()`, so
1399
+ * nothing else holds a reference to it and the mutation escapes nowhere. Either one
1400
+ * compiles — every example package builds against the ESNext baseline now, and
1401
+ * `mountKey` of `provider-lifecycle.ts` calls `toSorted()` on this same shared source
1402
+ * — so this is a choice of style and not a constraint of the toolchain.
1403
+ */
1404
+ function sortedQuery(search) {
1405
+ const pairs = [...new URLSearchParams(search).entries()].map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`);
1406
+ // oxlint-disable-next-line unicorn/no-array-sort
1407
+ pairs.sort();
1408
+ return pairs.join("&");
1409
+ }
513
1410
  function noopCleanup() { }
514
1411
  function createRouteWatcher(signal, onRoute) {
515
1412
  let cleanup = noopCleanup;
@@ -524,4 +1421,22 @@ function createRouteWatcher(signal, onRoute) {
524
1421
  watcher.watch(signal);
525
1422
  return watcher;
526
1423
  }
1424
+ /**
1425
+ * True when two machine-side locations are the same place.
1426
+ *
1427
+ * The order of the keys of a query is not significant, so `?b=2&a=1` and `?a=1&b=2` are
1428
+ * one location. The FRAGMENT counts: `navigateRouter` writes the whole value that the
1429
+ * actor gave — `buildRouteUrl` appends `context.hash` — so `/docs` and `/docs#intro` are
1430
+ * two locations to write, and a test that read them as one left the fragment out of the
1431
+ * address bar for ever.
1432
+ */
1433
+ function sameLocation(a, b) {
1434
+ const [aPath, aQuery, aFragment] = splitLocation(a);
1435
+ const [bPath, bQuery, bFragment] = splitLocation(b);
1436
+ if (aPath !== bPath)
1437
+ return false;
1438
+ if (aFragment !== bFragment)
1439
+ return false;
1440
+ return sortedQuery(aQuery) === sortedQuery(bQuery);
1441
+ }
527
1442
  //# sourceMappingURL=router-bridge-base.js.map