@xmachines/play-router 6.0.0 → 7.0.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 (40) hide show
  1. package/README.md +79 -23
  2. package/dist/base-route-map.js +240 -270
  3. package/dist/base-route-map.js.map +1 -1
  4. package/dist/build-tree.js +84 -96
  5. package/dist/build-tree.js.map +1 -1
  6. package/dist/create-route-map-from-tree.js +50 -47
  7. package/dist/create-route-map-from-tree.js.map +1 -1
  8. package/dist/errors.js +124 -133
  9. package/dist/errors.js.map +1 -1
  10. package/dist/find-route.js +88 -105
  11. package/dist/find-route.js.map +1 -1
  12. package/dist/index.js +10 -28
  13. package/dist/provider-lifecycle.js +109 -143
  14. package/dist/provider-lifecycle.js.map +1 -1
  15. package/dist/router-bridge-base.d.ts +5 -5
  16. package/dist/router-bridge-base.d.ts.map +1 -1
  17. package/dist/router-bridge-base.js +1026 -1601
  18. package/dist/router-bridge-base.js.map +1 -1
  19. package/dist/router-sync.js +107 -139
  20. package/dist/router-sync.js.map +1 -1
  21. package/dist/types.d.ts +17 -17
  22. package/dist/types.d.ts.map +1 -1
  23. package/dist/validate-routes.js +58 -66
  24. package/dist/validate-routes.js.map +1 -1
  25. package/dist/xstate/create-route-map.js +36 -35
  26. package/dist/xstate/create-route-map.js.map +1 -1
  27. package/dist/xstate/extract-routes.js +66 -83
  28. package/dist/xstate/extract-routes.js.map +1 -1
  29. package/dist/xstate/index.js +5 -33
  30. package/dist/xstate/machine-to-graph.js +98 -109
  31. package/dist/xstate/machine-to-graph.js.map +1 -1
  32. package/dist/xstate/query.js +190 -212
  33. package/dist/xstate/query.js.map +1 -1
  34. package/package.json +16 -16
  35. package/dist/index.js.map +0 -1
  36. package/dist/types.js +0 -2
  37. package/dist/types.js.map +0 -1
  38. package/dist/xstate/index.js.map +0 -1
  39. package/dist/xstate/machine-types.js +0 -2
  40. package/dist/xstate/machine-types.js.map +0 -1
@@ -1,1622 +1,1047 @@
1
- /**
2
- * RouterBridgeBase — the abstract base class of every framework router adapter
3
- *
4
- * The class holds the logic that each router bridge shares, and that is 90% of the
5
- * code of every bridge:
6
- * - The atom 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
11
- *
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
16
- *
17
- * @example
18
- * ```typescript
19
- * export class MyRouterBridge extends RouterBridgeBase {
20
- * private unsubscribe: (() => void) | null = null;
21
- *
22
- * constructor(private router: MyRouter, actor: RoutableActor, routeMap: { getStateIdByPath(path: string): string | undefined; getPathByStateId(id: string): string | undefined; }) {
23
- * super(actor, routeMap);
24
- * }
25
- *
26
- * protected navigateRouter(path: string): void {
27
- * this.router.navigate(path);
28
- * }
29
- *
30
- * protected watchRouterChanges(): void {
31
- * this.unsubscribe = this.router.subscribe((state) => {
32
- * this.syncActorFromRouter(state.location.pathname, state.location.search);
33
- * });
34
- * }
35
- *
36
- * protected unwatchRouterChanges(): void {
37
- * this.unsubscribe?.();
38
- * this.unsubscribe = null;
39
- * }
40
- * }
41
- * ```
42
- *
43
- * @see [Play RFC](../../docs/rfc/play.md) - invariant INV-04
44
- */
45
- import { NO_BASE_PATH, sanitizePathname, splitLocation, holdsUnsubstitutedParam, joinBasePath, parsePattern, resolveBasePath, stripBasePath, } from "@xmachines/play-url";
1
+ import { DuplicateBridgeError, InvalidRoutePatternError, RouterSyncError } from "./errors.js";
2
+ import { buildPlayRouteEvent, extractQuery, extractRouteParams, matchRouteMap } from "./router-sync.js";
3
+ import { NO_BASE_PATH, holdsUnsubstitutedParam, joinBasePath, parsePattern, resolveBasePath, sanitizePathname, splitLocation, stripBasePath } from "@xmachines/play-url";
46
4
  import { watchAtom } from "@xmachines/play-atom";
47
- import { DuplicateBridgeError, RouterSyncError, InvalidRoutePatternError } from "./errors.js";
48
- import { buildPlayRouteEvent, extractQuery, extractRouteParams, matchRouteMap, } from "./router-sync.js";
5
+ //#region packages/play-router/src/router-bridge-base.ts
49
6
  /**
50
- * The registry of the actors with an active bridge connection, at the module level.
51
- *
52
- * The key is the actor instance, and the registry is a WeakMap, which holds no
53
- * reference. The class uses it to find and refuse a second `connect()` call of the
54
- * same actor. Such a call makes two `play.route` events for one navigation, and it
55
- * makes two `lastSyncedPath` values of the echo suppression that contradict each
56
- * other.
57
- *
58
- * @internal
59
- */
60
- const activeBridges = new WeakMap();
61
- export class RouterBridgeBase {
62
- actor;
63
- routeMap;
64
- // ── The common state. It is identical in each of the 4 bridges ──
65
- isConnected = false;
66
- hasConnectedOnce = false;
67
- /**
68
- * The machine-side location that the router holds, as far as this bridge knows. It
69
- * carries the query and the fragment, and it carries NO `basePath`.
70
- *
71
- * Only code that saw a location, or that wrote one, writes this field:
72
- * `performInitialSync` when it finds the two sides in step, `takeReturnUnderMount`
73
- * for the same test under a mount, `pushResolvedRoute` after it resolves a route to
74
- * a concrete path, and `syncActorFromRouter` for the location that the router
75
- * reports. `null` means that this bridge placed no location yet, so it knows nothing
76
- * about the address bar, and every test against this field must then answer "no".
77
- *
78
- * The field held the route of the actor as well, from the seed of the constructor
79
- * and from a route that resolves to no URL. Those two values describe no location
80
- * that a router ever showed, and each reader of this field asks about a location, so
81
- * each of them read them wrongly. {@link lastActorRoute} holds them now.
82
- */
83
- lastSyncedPath = null;
84
- /**
85
- * The raw value of `actor.currentRoute` that this bridge accounted for already.
86
- *
87
- * The field answers ONE question: "did the bridge see this value of the atom
88
- * before?". It is therefore a string, and not a location — a route that resolves to
89
- * no URL, such as an unknown stateId or a parameterized pattern, still stops a
90
- * second attempt at the same value.
91
- *
92
- * The constructor and `connect()` seed it, so that the first fire of the watcher of
93
- * the atom pushes nothing for a route that moved nowhere.
94
- */
95
- lastActorRoute = null;
96
- /**
97
- * The flag guards `syncActorFromRouter` against a re-entrant call from a guard
98
- * redirect of the actor itself. Such a call has this sequence: the bridge sends to
99
- * the actor, the atom fires, the bridge pushes to the router, and a second
100
- * `syncActorFromRouter` call starts before the first one returns.
101
- *
102
- * The flag is NOT the echo suppression of the direction from the actor to the
103
- * router. `lastSyncedPath` does that work alone: the bridge writes it before the
104
- * `navigateRouter()` call. Therefore each router callback of the same location stops
105
- * at the {@link isEchoOfLastSync} test in `syncActorFromRouter`.
106
- */
107
- isProcessingNavigation = false;
108
- /**
109
- * Who owns the address bar, and what this bridge owes it.
110
- *
111
- * - `"under-mount"` — the location lies under the mount, so this bridge owns the
112
- * address bar and it writes the location for every route of its actor.
113
- * - `"outside"` — the location lies outside the mount, so the HOST owns it. The
114
- * bridge sends no event and it corrects no URL, which is what lets the routes of
115
- * the host and the routes of the machine share one router.
116
- * - `"outside-push-owed"` — outside, AND the route of the actor changed while the
117
- * host owned the address bar. The push waits here instead of going to the router,
118
- * and {@link RouterBridgeBase.takeReturnUnderMount} spends it when the location
119
- * comes back. The machine therefore keeps its place while the user reads a page of
120
- * the host.
121
- *
122
- * ONE value, and not a latch plus a memory. The memory only ever means something
123
- * while the latch is up, so two fields could hold a fourth combination that says
124
- * "the bridge owns the address bar AND a push waits for a return" — a state with no
125
- * meaning, which a return would spend at a location the router already holds. Two
126
- * writes also cannot happen together: every site that lowered the latch wrote the
127
- * memory on the NEXT line, and the value between the two lines was that fourth
128
- * combination. `takeReturnUnderMount` reads the value it replaces instead.
129
- *
130
- * `syncActorFromRouter` is the one place that sees a location, so it is the one
131
- * place that decides between `"under-mount"` and `"outside"`.
132
- * `performInitialSync` decides it for the location that the page loaded with.
133
- *
134
- * A bridge with no mount never leaves its mount: `stripBasePath` returns the path
135
- * unchanged for an empty prefix, and never `null`. The value therefore stays
136
- * `"under-mount"` for every bridge that takes no `basePath`.
137
- */
138
- mountState = "under-mount";
139
- /**
140
- * True once the first synchronization ran against a location INSIDE the mount.
141
- *
142
- * `performInitialSync` separates a deep link from a restore, and it can make that
143
- * decision only for a location that lies under the mount. A bridge that connects
144
- * while the host owns the address bar therefore reaches no decision at all: it
145
- * raises the latch and returns. The flag says that the decision is still owed, and
146
- * `syncActorFromRouter` pays it at the first location that IS under the mount —
147
- * through {@link RouterBridgeBase.takeReturnUnderMount} for a RETURN, which needs the
148
- * decision itself, and directly for every other location under the mount.
149
- *
150
- * A bridge with no mount never leaves its mount, so `performInitialSync` writes this
151
- * flag on the first `connect()` and nothing reads it again.
152
- */
153
- hasSynchronizedUnderMount = false;
154
- routeWatcher = null;
155
- /**
156
- * The mount point of the machine inside the host router: the resolved prefix, and
157
- * the values of each `:param` of that prefix.
158
- *
159
- * The prefix lives here, and NOT in the route map, for two reasons. A route map is
160
- * static, it is shared, and it holds an LRU cache inside, while a mount point is
161
- * dynamic. One route map therefore serves every `machineId` without a rebuild. And
162
- * the bridge is the boundary between a location of the host router and a path of
163
- * the machine already, which is exactly what the prefix separates.
164
- *
165
- * `lastSyncedPath` stays MACHINE-side, and it carries no prefix. The code adds the
166
- * prefix at the one `navigateRouter()` call in `pushResolvedRoute`, and it removes
167
- * the prefix at the one inbound entry point `syncActorFromRouter`. Every test of
168
- * the echo suppression therefore keeps comparing like with like.
169
- */
170
- mount = NO_BASE_PATH;
171
- /**
172
- * The caches that every parse and every compilation of this bridge reads.
173
- *
174
- * `resolveNavigationPath` and `isActorAtPath` parse a CONCRETE location, and an
175
- * application writes one for each value that it puts in a param.
176
- */
177
- compileOptions;
178
- /**
179
- * @param actor - A `RoutableActor`, with `currentRoute`, `initialRoute`, and `send`.
180
- * @param routeMap - The route map of both directions, for the resolution between a
181
- * `stateId` and a `path`. Give `getStateIdByPath` and `getPathByStateId`. A
182
- * framework adapter usually wraps the result of `createRouteMap(machine)`, or an
183
- * equivalent value. The key of `getPathByStateId` is the form `"#stateId"` or the
184
- * bare form `"stateId"`. The bridge tries both forms. Therefore an implementation
185
- * of your own, for example a plain test object, handles one form only.
186
- */
187
- constructor(actor, routeMap, options) {
188
- this.actor = actor;
189
- this.routeMap = routeMap;
190
- // The caches of this bridge, or the shared defaults. `resolveNavigationPath` and
191
- // `isActorAtPath` parse a CONCRETE location — one for each value that the
192
- // application puts in a param — so a server that holds a bridge for each request
193
- // passes caches that the request owns and drops.
194
- this.compileOptions = {
195
- patternCache: options?.patternCache,
196
- compiledCache: options?.compiledCache,
197
- };
198
- // Resolve the mount BEFORE the seed of lastSyncedPath: an invalid basePath must
199
- // fail here, at the construction, and not on the first navigation.
200
- this.mount = resolveBasePath(options?.basePath, options?.basePathParams);
201
- // Record the route of the actor as accounted for. This stops a loop in the first
202
- // synchronization: the watcher of the atom fires once when `connect()` arms it,
203
- // and a route that moved nowhere must push nothing.
204
- //
205
- // The seed goes to `lastActorRoute`, and NOT to `lastSyncedPath`. The route of the
206
- // actor describes no location that the router ever showed — the bridge has written
207
- // nothing yet — and every reader of `lastSyncedPath` asks about a location.
208
- this.lastActorRoute = this.actor.currentRoute.get() ?? null;
209
- }
210
- /**
211
- * The resolved URL prefix that the machine of this bridge is mounted under, or `""`
212
- * when the machine owns the complete router.
213
- */
214
- get basePath() {
215
- return this.mount.path;
216
- }
217
- /**
218
- * The resolved values of the `:param` segments of the mount, or `{}` for a prefix
219
- * without a param.
220
- *
221
- * These params belong to the HOST: the host wrote the prefix and resolved the
222
- * values, and they describe the route of the host and not the route of the machine.
223
- * They therefore travel in NO `play.route` event. The machine is authoritative over
224
- * its own params, and `event.params` holds what the pattern of the machine
225
- * declares, and nothing else.
226
- *
227
- * They live here instead, because a value that reached the actor on a navigation
228
- * ALONE would go stale: `setBasePath()` can move the mount while the machine stays
229
- * on the same route, no event goes out, and `event.params` would then contradict
230
- * `basePath`. A read of this accessor cannot go stale.
231
- *
232
- * A machine that needs the identity of its host — a `machineId`, a tenant — takes
233
- * it through the `input` of the actor, where it belongs: that identity decides
234
- * WHICH machine runs, and it is not a param of a route inside the machine.
235
- *
236
- * @example
237
- * ```typescript
238
- * bridge.basePath; // "/abc123/play"
239
- * bridge.basePathParams; // { machineId: "abc123" }
240
- * ```
241
- */
242
- get basePathParams() {
243
- return this.mount.params;
244
- }
245
- /**
246
- * Moves the machine to a different mount point, while it stays connected.
247
- *
248
- * This is the "load and unload" half of a shared router. It moves WHERE an actor is
249
- * mounted, and never WHICH actor is mounted: the actor, the route map, and its LRU
250
- * cache all stay, and nothing goes away.
251
- *
252
- * An actor never changes identity. A prefix that IDENTIFIES the actor — a
253
- * `machineId` that names the document it runs — therefore never moves through this
254
- * method: a new identity is a new actor, and a new actor takes a new bridge, because
255
- * `connect()` permits one bridge for each actor. The segments that move here are the
256
- * ones that LOCATE: a region, a locale, a tenant, a workspace slug.
257
- *
258
- * The call then brings the location in step with the NEW prefix. A location inside
259
- * the new mount runs the same first-synchronization decision as `connect()`: it
260
- * drives the actor, and a restore keeps the route of the actor. A location OUTSIDE
261
- * the new mount is the old mount in practice, so the bridge writes the new one
262
- * itself, and it keeps the route of the actor while it does so — nothing else moves
263
- * the address bar. A call that resolves to the current prefix changes no location,
264
- * and it therefore reconciles nothing.
265
- *
266
- * @param basePath - The new prefix, as a pattern or as a concrete path. An absent
267
- * value, `""`, and `"/"` all remove the prefix, and they give the machine the
268
- * complete router again.
269
- * @param basePathParams - The values of the `:param` segments of `basePath`.
270
- * @throws {InvalidBasePathError} For a prefix that resolves to one concrete path never.
271
- * @throws {MissingBasePathParamError} When a `:param` of the prefix has no value.
272
- *
273
- * @example
274
- * ```typescript
275
- * // The host moved this actor to another place in its URL space. `machineId` names
276
- * // WHICH document the actor runs, and it does not change: a new identity is a new
277
- * // actor, and therefore a new bridge, because one actor takes one bridge.
278
- * bridge.setBasePath("/:region/:machineId/play", { region: "us", machineId });
279
- * ```
280
- */
281
- setBasePath(basePath, basePathParams) {
282
- const next = resolveBasePath(basePath, basePathParams);
283
- const samePath = next.path === this.mount.path;
284
- // Take the new mount in every case, also when the prefix STRING does not change: a
285
- // caller can move between a literal that holds the value already ("/abc123/play")
286
- // and the equal pattern ("/:machineId/play" with `{ machineId }`). The two give the
287
- // same path and different params, and `basePathParams` reports those params. They
288
- // travel in NO `play.route` event: the machine is authoritative over its own
289
- // params, and a param of the mount belongs to the host.
290
- this.mount = next;
291
- // The same prefix means that no location changed, so there is nothing to bring in
292
- // step, and `lastSyncedPath` still describes a location that is still current.
293
- if (samePath)
294
- return;
295
- if (this.isConnected) {
296
- // The machine was at no location under the NEW prefix yet, and lastSyncedPath
297
- // describes the old one. A clear of that field lets the first synchronization
298
- // below push or send without a stop at the dedup guard.
299
- //
300
- // The clear stays INSIDE this guard, with the three records below: each of them
301
- // is a statement about a connection, and a bridge that is not connected has
302
- // none to correct. `connect()` clears the same record for the next one.
303
- this.lastSyncedPath = null;
304
- // Both records of the OLD mount go with it. The latch says "the location lies
305
- // outside the mount", and the memory says "the actor is not where the address bar
306
- // says" — each of them is a statement about the prefix that this call replaces, so
307
- // neither survives it. `reconcileLocationWithMount` writes both again for a
308
- // location that it can read; for a location that it CANNOT read — an adapter whose
309
- // `getInitialRouterPath()` returns `null` or `undefined` at this moment — a stale
310
- // latch silenced the direction from the actor to the router for the rest of the
311
- // session, at a mount that may well cover the location already.
312
- this.mountState = "under-mount";
313
- // The first synchronization of the NEW prefix is owed again. The one that ran
314
- // against the old prefix says nothing about this one: a location that the old
315
- // mount covered can lie outside the new mount, and the decision between a deep
316
- // link and a restore therefore has to run again for the prefix that replaces it.
317
- this.hasSynchronizedUnderMount = false;
318
- // No rollback here, unlike in `connect()`. A throw there escapes a call that
319
- // returned never, so the caller holds nothing for a cleanup. Here the bridge is
320
- // connected already, and the caller holds its `disconnect` already. A teardown
321
- // on a failed move of the mount would make that handle a silent no-op, and it
322
- // would stop a bridge that the caller still believes in. A throw from here leaves
323
- // the NEW mount in place — `resolveBasePath` above refuses an invalid prefix
324
- // before the assignment — so the caller sees the throw and it decides.
325
- this.reconcileLocationWithMount();
326
- }
327
- }
328
- /**
329
- * Brings the location of the router in step with a mount that just moved.
330
- *
331
- * **The location lies under the new mount:** run the same first-synchronization
332
- * decision as `connect()`, so a location inside the mount drives the actor and a
333
- * restore keeps the route of the actor.
334
- *
335
- * **The location lies outside it:** the bridge writes the new mount ITSELF, and it
336
- * keeps the route of the actor while it does so. A host that re-points a mount
337
- * changes no location by itself, and nothing else moves the address bar. Without
338
- * this push the machine sits at a mount that no location is under: it hears no
339
- * `play.route`, `lastSyncedPath` is clear already, and the stale prefix stays in the
340
- * URL until the route of the actor happens to change — which then jumps the URL with
341
- * no reason that the history of the user explains.
342
- *
343
- * `pushResolvedRoute` writes `lastSyncedPath` before it navigates, so the callback
344
- * of the router for this push stops at the echo suppression, and it sends no event.
345
- */
346
- reconcileLocationWithMount() {
347
- const routerPath = this.getInitialRouterPath();
348
- // `sanitizePathname` refuses a path of more than 2048 characters, and the RAW value
349
- // is no substitute for the clean one: it still carries a query, a fragment, and a
350
- // duplicate slash, so a compare against the mount reads a location of the host as
351
- // "inside the mount", or the opposite. Such a location is one that this bridge
352
- // cannot place, exactly like a location that the adapter cannot read.
353
- const sanitized = typeof routerPath === "string" ? sanitizePathname(routerPath) : null;
354
- // A location that reads as `null` because the adapter cannot read its router
355
- // synchronously is NOT a location outside the mount. Leave that case to
356
- // `performInitialSync`, which handles an absent location already, and which
357
- // refuses a path that is too long.
358
- if (typeof routerPath !== "string" || sanitized === null) {
359
- this.performInitialSync(routerPath);
360
- return;
361
- }
362
- const machinePath = stripBasePath(sanitized, this.mount.path);
363
- if (machinePath !== null) {
364
- // Hand the location on, so that the adapter is asked for it ONE time: a read of
365
- // `getInitialRouterPath()` belongs to the adapter, it can walk the state of a
366
- // router, and a second read can answer differently from the first.
367
- this.performInitialSync(routerPath);
368
- return;
369
- }
370
- const currentActorRoute = this.actor.currentRoute.get();
371
- if (!currentActorRoute) {
372
- // Nothing to write yet. The location still belongs to the HOST, and recording
373
- // that is the whole job of this branch: without it the first route of the actor
374
- // would write over the page of the host.
375
- this.markOutside();
376
- return;
377
- }
378
- // The route of the actor has to resolve to a concrete path BEFORE the latch comes
379
- // down. `pushResolvedRoute` writes nothing for an unknown stateId and for a
380
- // parameterized pattern, and it reports nothing either: a lowered latch then said
381
- // that the bridge owns an address bar that still holds a location of the HOST, and
382
- // the next route change of the actor — an `after` timer, a guard that settles —
383
- // wrote over the page of the host with no URL event that asked for it.
384
- //
385
- // Keep the latch UP for that case, and remember the push, exactly as the branch
386
- // above does for an actor with no route yet.
387
- const resolved = this.resolveNavigationPath(currentActorRoute);
388
- if (resolved === null) {
389
- this.mountState = "outside-push-owed";
390
- return;
391
- }
392
- // The bridge owns the address bar again the moment it writes the new mount.
393
- this.mountState = "under-mount";
394
- // The push below IS the first synchronization of this mount: the actor won it, and
395
- // the bridge wrote the location. A debt that stayed said the opposite, and
396
- // `takeReturnUnderMount` paid it at the first LATER return under the mount: it read
397
- // the route of the actor as a restore and pushed it, so a deliberate navigation of
398
- // the host to the mount point landed the user somewhere else, with no `play.route`
399
- // event for the location they chose.
400
- this.hasSynchronizedUnderMount = true;
401
- // The resolution above travels with the route, so `resolveNavigationPath` runs one
402
- // time: the method is `protected`, a subclass can override it, and a second call
403
- // can answer differently from the first.
404
- this.pushHoldingNavigation(currentActorRoute, resolved);
405
- }
406
- /**
407
- * Pushes a route to the router, and it holds {@link isProcessingNavigation} across
408
- * the write.
409
- *
410
- * Every corrective push of the bridge needs that flag: `navigateRouter` makes the
411
- * router call `syncActorFromRouter` again inside it, and a router that NORMALIZES the
412
- * location it receives — a `validateSearch` that injects a default, a trailing slash
413
- * that a history adds — reports a value that the echo test misses. The bridge would
414
- * then answer its own write with a `play.route` event, and on a press of BACK that
415
- * answer is a history entry that FORWARD cannot return to.
416
- *
417
- * The one push that does NOT come through here is `syncRouterFromActor`: that push
418
- * follows the actor, and the echo suppression of `lastSyncedPath` is what stops it.
419
- *
420
- * @param route - The raw value of the actor route.
421
- * @param resolved - The concrete path of that route, when the caller resolved it
422
- * already. `undefined` resolves it in `pushResolvedRoute`.
423
- */
424
- pushHoldingNavigation(route, resolved) {
425
- this.isProcessingNavigation = true;
426
- try {
427
- this.pushResolvedRoute(route, resolved);
428
- }
429
- finally {
430
- this.isProcessingNavigation = false;
431
- }
432
- }
433
- // ── The RouterBridge protocol. It is final: a subclass must override nothing here ──
434
- /**
435
- * Connects the router bridge to the Actor.
436
- *
437
- * The method installs the atom watcher of the direction from the actor to the
438
- * router. It then starts the watch of the router changes, which each framework does
439
- * in its own way.
440
- *
441
- * The order of these steps is part of the contract of the bridge:
442
- * - The constructor seeds `lastActorRoute` from `actor.currentRoute`, and it records no location
443
- * - The method installs the actor watcher before the router subscriptions of the adapter
444
- * - The first synchronization then separates a deep link from a restore, with `actor.initialRoute`
445
- *
446
- * An adapter that needs a different behavior of the first synchronization overrides
447
- * `getInitialRouterPath()`. It does not change the order of the steps of
448
- * `connect()`.
449
- */
450
- connect() {
451
- if (this.isConnected) {
452
- return;
453
- }
454
- // Permit one bridge for each actor: two connected bridges of the same actor both
455
- // receive every navigation event of the router. They therefore send two play.route
456
- // events, and their lastSyncedPath values of the echo suppression contradict each
457
- // other.
458
- const existingBridge = activeBridges.get(this.actor);
459
- if (existingBridge !== undefined && existingBridge !== this) {
460
- throw new DuplicateBridgeError();
461
- }
462
- activeBridges.set(this.actor, this);
463
- // Seed the dedup again, exactly as the constructor does, and for the same reason:
464
- // the field must describe the route of the actor at the moment this connection
465
- // starts. `disconnect()` leaves the value of the PREVIOUS connection there, and the
466
- // actor can move while nothing watches it — a provider that unmounts and mounts
467
- // again does that, and so does the pair of `<StrictMode>`. The write also covers
468
- // the gap between the constructor and the first `connect()`, which the Vue provider
469
- // reaches through `await router.isReady()`.
470
- //
471
- this.lastActorRoute = this.actor.currentRoute.get() ?? null;
472
- // This connection has observed NO location yet: `performInitialSync` below is the
473
- // first read of the router. Say so, rather than keep the location of the previous
474
- // connection — `disconnect()` leaves that value behind, and `setBasePath` can move
475
- // the prefix while nothing is connected, so the value can describe a location under
476
- // a prefix that is gone. `isEchoOfLastSync` then read the real location of the
477
- // router as an echo of this bridge, and the first synchronization sent no
478
- // `play.route` for it: the URL and the actor separated with no error.
479
- this.lastSyncedPath = null;
480
- this.isConnected = true;
481
- this.hasConnectedOnce = true;
482
- // From here on, connect() gives all or nothing. THREE steps below can throw, and
483
- // the rollback has to cover each of them:
484
- // - `createRouteWatcher`, on an atom that refuses a watcher
485
- // - `watchRouterChanges()`, which belongs to the adapter: `afterNavigate()` of
486
- // SvelteKit raises `lifecycle_outside_component` outside the initialization of
487
- // a component, and a host reaches that by calling `connectRouter` from a module
488
- // scope or a store
489
- // - `performInitialSync()`, with an InvalidRoutePatternError for a route that
490
- // URLPattern refuses, and a RouterSyncError for each other failure of the send
491
- // to the actor
492
- //
493
- // The throw needs an action of the caller, and it therefore goes to the caller
494
- // without a change.
495
- //
496
- // connect() then returns never, and the caller holds therefore NO handle for a
497
- // cleanup. Every part above must go away again, or it leaks:
498
- // - the router listener keeps firing, and it drives the actor through a bridge
499
- // that the caller believes to be dead
500
- // - the atom watcher keeps pushing each actor route to the router
501
- // - the activeBridges entry keeps the actor, so the NEXT connect() call of the
502
- // caller for this actor throws a DuplicateBridgeError. A repair of the real
503
- // cause, for example a correction of the route pattern, rescues that actor
504
- // never.
505
- try {
506
- // The atom watcher of the direction from the actor to the router.
507
- this.routeWatcher = createRouteWatcher(this.actor.currentRoute, (route) => {
508
- this.syncRouterFromActor(route);
509
- });
510
- // The watch of the router changes. Each framework does this in its own way.
511
- this.watchRouterChanges();
512
- this.performInitialSync(this.getInitialRouterPath());
513
- }
514
- catch (error) {
515
- // disconnect() is exactly this teardown, and it is idempotent. It runs inside a
516
- // guard of its own, because `unwatchRouterChanges()` belongs to the adapter and
517
- // it can throw: a Vue scope that stops, a Solid disposer, a SvelteKit teardown.
518
- // That error must NOT replace the one the rollback exists to surface — a caller
519
- // that receives it learns neither which route pattern URLPattern refused nor
520
- // which event the actor refused.
521
- try {
522
- this.disconnect();
523
- }
524
- catch {
525
- // The teardown failed, so the slot of the actor may still be taken. The
526
- // original error still reaches the caller, which is the one that names a
527
- // cause it can repair.
528
- }
529
- throw error;
530
- }
531
- }
532
- /**
533
- * Brings the actor and the router in step one time, and decides which side wins.
534
- *
535
- * The method is separate from `connect()`, so that `connect()` can roll the whole
536
- * first synchronization back when it throws. See the `try` block there.
537
- *
538
- * The first synchronization: the direction from the router to the actor has the
539
- * priority over the other direction.
540
- *
541
- * The page can load on a URL that is not the initial state of the actor. For
542
- * example, the user types "/about", or the user follows a deep link. The router
543
- * holds the correct path then, and the actor is still at its initial state ("/").
544
- * The code must therefore drive the actor to the URL, and it must not write the
545
- * default of the actor to the URL.
546
- *
547
- * router.subscribe() fires on a *later* navigation event only. It does NOT fire for
548
- * the location that the page loaded. A subclass that can read the current location
549
- * of its router synchronously overrides getInitialRouterPath().
550
- *
551
- * @param initialRouterPath - The location of the router. Every caller reads it and
552
- * passes it, and this parameter carries NO default: a default fires for an
553
- * explicit `undefined` too, so `reconcileLocationWithMount` asked the adapter a
554
- * SECOND time for exactly the value that says "the adapter does the first
555
- * synchronization itself" — and a read belongs to the adapter, which can walk the
556
- * state of a router and answer differently on the second call.
557
- */
558
- performInitialSync(initialRouterPath) {
559
- const initialActorRoute = this.actor.currentRoute.get();
560
- // ONE read of the query of the router, and a LAZY one. The read belongs to the
561
- // adapter and it can walk the state of a router, so a second call can answer
562
- // differently — and most branches below reach no query at all.
563
- let search;
564
- let searchRead = false;
565
- const routerSearch = () => {
566
- if (!searchRead) {
567
- search = this.getInitialRouterSearch();
568
- searchRead = true;
569
- }
570
- return search;
571
- };
572
- if (typeof initialRouterPath === "string") {
573
- // `sanitizePathname` refuses a path of more than 2048 characters, and the RAW
574
- // value is no substitute for the clean one: it still carries a query, a
575
- // fragment, and a duplicate slash, so a compare against the mount reads a
576
- // location of the host as "inside the mount", or the opposite. The route
577
- // resolution refuses such a path anyway — `syncActorFromRouter` sanitizes it
578
- // again and returns — so touch no latch for it and reconcile nothing.
579
- const sanitized = sanitizePathname(initialRouterPath);
580
- if (sanitized === null)
581
- return;
582
- // Take the machine half of the location of the router. A location OUTSIDE the
583
- // mount belongs to the host, so the bridge stays out of it completely: it sends
584
- // no event, and — this part makes a shared router work — it runs no corrective
585
- // navigation either. A correction here would drag the user off a page of the
586
- // host at the moment when the bridge connects.
587
- const machinePath = stripBasePath(sanitized, this.mount.path);
588
- if (machinePath === null) {
589
- this.markOutside();
590
- return;
591
- }
592
- // The symmetric write, and the reason this method has to make it: a location
593
- // that lies INSIDE the mount means the bridge owns the address bar, and this
594
- // method is reached without a router event — by `connect()`, and by a
595
- // `setBasePath()` that moves the mount to where the location already is. A
596
- // latch that only ever went up silenced the machine for the rest of the
597
- // session, because nothing else here lowers it.
598
- // The synchronization below brings the two sides in step, so a push that waited
599
- // for the address bar to come back is spent with the same write. A memory that
600
- // survived would replay that push on the next return, at a location the router
601
- // already holds.
602
- this.mountState = "under-mount";
603
- // The decision below IS the first synchronization, and it runs against a location
604
- // under the mount, so nothing is owed any more.
605
- this.hasSynchronizedUnderMount = true;
606
- // Test if the actor is at the current location of the router already. The PATH
607
- // and the QUERY are two answers of ONE lookup: the path decides whether a restore
608
- // is possible at all, and the query decides whether the two sides are in step
609
- // there. `routerSearch` runs behind the `&&`, so a branch that answers on the
610
- // path alone reads no query.
611
- const actorAtRouterPath = this.isActorAtPath(machinePath, initialActorRoute);
612
- const actorAlreadyAtRouterLocation = actorAtRouterPath &&
613
- initialActorRoute != null &&
614
- sameQuery(initialActorRoute, routerSearch());
615
- // The two are in step already, so nothing is synchronized — but the location has
616
- // to be RECORDED, or the echo suppression starts disarmed. `setBasePath` clears
617
- // `lastSyncedPath` on a move of the prefix, and this branch left it null: the
618
- // next callback of the router for that same location then read as a real move
619
- // and sent a redundant `play.route`, and the next route change of the actor
620
- // pushed a location the router already held.
621
- //
622
- // The write is UNCONDITIONAL, and a test for a null field is wrong here: the field
623
- // must describe the location that this method just found in step, whatever it
624
- // held before. `setBasePath` can re-point the mount between two synchronizations
625
- // of one connection, and the record of the old prefix says nothing about this
626
- // location.
627
- if (actorAlreadyAtRouterLocation) {
628
- this.lastSyncedPath = withSearch(machinePath, routerSearch());
629
- }
630
- else {
631
- // The path of the router is different from the actor route. Is this a deep link, or
632
- // is it a restore?
633
- //
634
- // A deep link: the router is at a URL that is not the initial one, and the actor saw
635
- // that URL not yet.
636
- // → the router wins: syncActorFromRouter. The guards then decide the access.
637
- //
638
- // A restore: the browser is at the initial URL of the machine, and a snapshot
639
- // restored the actor to a different route.
640
- // → the actor wins: push the restored route of the actor to the router.
641
- //
642
- // The detection: the URL of the router is the initial route of the machine, AND the
643
- // actor is at a different route. This is then a restore.
644
- //
645
- // The branch reads the LOCATION test above, through the `else` that carries it,
646
- // and not the path test alone: a fragment that the actor carries and the router
647
- // reports never — `sanitizePathname` cuts it off — is a difference that only
648
- // this push can place in the address bar. A query of the ACTOR is such a
649
- // difference too, and issue #18 carries the decision about it.
650
- //
651
- // `carriesNoOwnSuffix` is what keeps the two apart. An actor that stands on the
652
- // PATH of the router and carries no query and no fragment of its own restores
653
- // NOTHING: the address bar already shows its place, and a push only rewrites the
654
- // location that the user opened. The raw comparison against `initialRoute` cannot
655
- // see that, because a stateId is never string-equal to a path — an actor at
656
- // `"#home"` against a router at `"/?ref=nav"` read as a move, so the bridge
657
- // pushed `"/"` over the query the user opened and sent no `play.route` that
658
- // carried it. The router keeps the priority for such a location, which is the
659
- // answer that the same bridge gives for the actor route `"/"`.
660
- if (initialActorRoute &&
661
- machinePath === this.actor.initialRoute &&
662
- initialActorRoute !== this.actor.initialRoute &&
663
- !(actorAtRouterPath && carriesNoOwnSuffix(initialActorRoute))) {
664
- this.pushHoldingNavigation(initialActorRoute);
665
- }
666
- else {
667
- // The search is read through `routerSearch`, which reads it one time and only
668
- // when a branch needs it: the read belongs to the adapter, and it can walk
669
- // the state of a router.
670
- this.syncActorFromRouter(initialRouterPath, routerSearch());
671
- }
672
- }
673
- }
674
- else if (initialActorRoute && initialRouterPath === null) {
675
- // An explicit null: no path of the router is available, from a restore or from a
676
- // bootstrap → push the actor route.
677
- // The value undefined means that the adapter does the first synchronization itself.
678
- // Continue.
679
- this.pushHoldingNavigation(initialActorRoute);
680
- // The push IS the first synchronization of this mount, and the actor won it. A
681
- // debt that stayed said the opposite, and `takeReturnUnderMount` paid it at the
682
- // first LATER return under the mount: it read the route of the actor as a restore
683
- // and pushed it, so a deliberate navigation of the host to the mount point landed
684
- // the user somewhere else, with no `play.route` event for the location they chose.
685
- this.hasSynchronizedUnderMount = true;
686
- }
687
- else if (initialActorRoute && initialActorRoute !== this.lastActorRoute) {
688
- // No path of the router replaces the actor route → write the router from the actor, as before
689
- this.syncRouterFromActor(initialActorRoute);
690
- this.hasSynchronizedUnderMount = true;
691
- }
692
- }
693
- /**
694
- * Disconnects the router bridge from the Actor.
695
- *
696
- * The method stops the watch of the atom, and it removes the router listener of the
697
- * framework.
698
- */
699
- disconnect() {
700
- const hadRouteWatcher = this.routeWatcher !== null;
701
- if (this.routeWatcher) {
702
- try {
703
- this.routeWatcher.unwatch();
704
- }
705
- catch {
706
- // Ignore an error of a detached watcher. disconnect() therefore stays idempotent.
707
- }
708
- }
709
- this.routeWatcher = null;
710
- // `unwatchRouterChanges()` belongs to the adapter, and it can throw: a Vue scope
711
- // that stops, a disposed Solid root, a SvelteKit teardown. Everything below it
712
- // still has to run — a `finally`, and not a plain sequence. Without it the state
713
- // stayed connected and the slot of the actor stayed taken, so every later
714
- // `connect()` for that actor threw a DuplicateBridgeError, which is the one error
715
- // that naming the real cause cannot repair. That also makes `disconnect()`
716
- // idempotent, which the rollback of `connect()` depends on.
717
- try {
718
- if (this.isConnected || hadRouteWatcher) {
719
- this.unwatchRouterChanges();
720
- }
721
- }
722
- catch {
723
- // Ignore an error of the teardown of the adapter, exactly as the watcher above
724
- // ignores one. `disconnect()` is what a provider calls on an unmount, and a
725
- // failure to release one subscription must not break that unmount — nor the
726
- // rollback of `connect()`, which calls this method and needs the error of the
727
- // first synchronization to reach the caller.
728
- }
729
- finally {
730
- this.isProcessingNavigation = false;
731
- this.isConnected = false;
732
- // A push that waited for the address bar to come back dies with the connection,
733
- // and so does the record of where the location lay. A later `connect()` runs its
734
- // own first synchronization, and it decides all three again.
735
- this.mountState = "under-mount";
736
- this.hasSynchronizedUnderMount = false;
737
- // Free the slot of the actor, so that a new bridge can connect to the same
738
- // actor. Clear the slot only when this bridge is the registered one: a second
739
- // bridge that connect() refused must not remove the legitimate bridge.
740
- if (activeBridges.get(this.actor) === this) {
741
- activeBridges.delete(this.actor);
742
- }
743
- }
744
- }
745
- // ── The sync methods. They are protected, and a subclass overrides one for its own behavior ──
746
- /**
747
- * Writes the location of the router when the route atom of the actor changes.
748
- *
749
- * The method resolves the actor route to a concrete URL path, then it calls
750
- * navigateRouter() for the navigation of the framework. When it cannot resolve the
751
- * route, which happens for an unknown stateId and for a parameterized pattern
752
- * without concrete values, it skips the push completely: `navigateRouter` receives a
753
- * raw stateId or a pattern never.
754
- *
755
- * `lastSyncedPath` does the complete echo suppression, which stops the callback of
756
- * the router from a send to the actor: the method writes the resolved path to
757
- * `lastSyncedPath` before the `navigateRouter()` call. Therefore each
758
- * `syncActorFromRouter` call of the same path stops at the
759
- * `sanitized === lastSyncedPath` test, and it sends no event. This is correct for a
760
- * synchronous callback and for an asynchronous callback.
761
- *
762
- * The method does NOT set `isProcessingNavigation`. That flag lives inside
763
- * `syncActorFromRouter`, and it guards against a re-entrant loop of a guard
764
- * redirect.
765
- */
766
- syncRouterFromActor(route) {
767
- if (this.hasConnectedOnce && !this.isConnected)
768
- return;
769
- if (!route || typeof route !== "string")
770
- return;
771
- // The host owns the address bar, so this bridge writes no URL. The inbound
772
- // direction stays silent for the same location, and the two directions have to
773
- // agree: a route that changes for a reason that is NOT a URL event — an `after`
774
- // timer, an async guard that settles, a restore of a snapshot, a sibling that
775
- // sends an event — would otherwise drag the user off a page of the host.
776
- //
777
- // The memory holds ONE fact: the actor is not where the address bar says. The
778
- // test therefore runs on each change, and an echo CLEARS it — a route that
779
- // wanders away and back leaves the URL correct, and a memory that stayed set
780
- // would push a location the router already holds, which is a history entry that
781
- // BACK cannot escape.
782
- const echo = this.testEchoOfLastPush(route);
783
- if (this.mountState !== "under-mount") {
784
- this.mountState = echo.isEcho ? "outside" : "outside-push-owed";
785
- return;
786
- }
787
- if (echo.isEcho)
788
- return;
789
- this.pushResolvedRoute(route, echo.resolved);
790
- }
791
- /**
792
- * Records that the location lies outside the mount, so the HOST owns the address bar.
793
- *
794
- * A bridge that is outside ALREADY keeps the value it holds: `"outside-push-owed"`
795
- * says that a push waits for the address bar to come back, and a second location of
796
- * the host is no reason to forget it.
797
- */
798
- markOutside() {
799
- if (this.mountState === "under-mount")
800
- this.mountState = "outside";
801
- }
802
- /**
803
- * Takes the return of the address bar under the mount.
804
- *
805
- * The actor wins that return when it moved while the host owned the location AND the
806
- * address bar came back to the location it still held: the method pushes the route of
807
- * the actor, and the caller sends no `play.route` event. A return to any other route
808
- * of the mount belongs to the host, so the memory goes and the caller drives the
809
- * actor from the location instead.
810
- *
811
- * The method also pays the first synchronization that the mount still owes. A bridge
812
- * that connected while the host owned the address bar reached no decision between a
813
- * deep link and a restore: `performInitialSync` can decide only for a location under
814
- * the mount, and it found none. This is the first such location, so the decision runs
815
- * here, with the same rule.
816
- *
817
- * The method holds `isProcessingNavigation` across each push, because
818
- * `navigateRouter` makes the router call `syncActorFromRouter` again.
819
- *
820
- * @returns `true` when the method handled the location, so the caller returns.
821
- */
822
- takeReturnUnderMount(machinePath, search) {
823
- const previous = this.mountState;
824
- if (previous === "under-mount")
825
- return false;
826
- // The bridge owns the address bar again from here. The memory of a push that waited
827
- // travels in `previous`, and the write below spends it in the SAME step: no branch
828
- // has to clear it, and no branch can forget to.
829
- this.mountState = "under-mount";
830
- // The first synchronization of this mount never ran: `connect()`, or a
831
- // `setBasePath()` that moved the mount, found the location outside the prefix, so
832
- // `performInitialSync` raised the latch and returned before it could separate a
833
- // deep link from a restore. Make that decision HERE, at the first location that
834
- // lies under the mount, with the rule of `performInitialSync`: the router is at
835
- // the initial route of the machine AND the actor is at another one, so a snapshot
836
- // restored the actor and the actor wins.
837
- //
838
- // Without this the restore was lost the moment the host showed the machine: the
839
- // location drove the actor back to the route of the mount point, while the SAME
840
- // bridge connected at the SAME location kept the restored route. Which of the two
841
- // happened depended on where the one shared router stood at the moment of
842
- // `connect()`, which is the arbitrary part.
843
- const owesFirstSync = !this.hasSynchronizedUnderMount;
844
- this.hasSynchronizedUnderMount = true;
845
- const restoredRoute = this.actor.currentRoute.get();
846
- // `isActorAtPath` is the test that `performInitialSync` makes BEFORE it looks
847
- // for a restore, and both branches of the owed synchronization below need it. The
848
- // actor route can be a stateId, and a stateId is never string-equal to
849
- // `initialRoute`, which is a path: an actor that sits at "#home" of the mount point
850
- // read as a restore, so the bridge pushed "/abc123/play" over the location the HOST
851
- // chose, dropped its query, and returned `true` — so no `play.route` carried that
852
- // location to the machine either.
853
- //
854
- // ONE call, because `getStateIdByPath` and `getPathByStateId` belong to the route
855
- // map: a map of a consumer can answer differently on a second call, and the two
856
- // branches would then disagree about the same location. The PATH and the QUERY are
857
- // two answers of that one lookup, exactly as `performInitialSync` reads them.
858
- const actorAtPath = owesFirstSync && this.isActorAtPath(machinePath, restoredRoute);
859
- const actorAtLocation = actorAtPath && restoredRoute != null && sameQuery(restoredRoute, search);
860
- // The RESTORE reads the LOCATION test, exactly as `performInitialSync` does, and it
861
- // refuses the same empty restore: an actor that stands on the PATH of the location
862
- // and carries no query and no fragment of its own has nothing to put in the address
863
- // bar. Without that test an actor at `"#home"` met a host that came back to the
864
- // mount point with a query, and the bridge pushed the mount point over it and
865
- // returned `true` — so no `play.route` carried that location to the machine either,
866
- // which is the fault that the comment above this block names.
867
- if (owesFirstSync &&
868
- restoredRoute &&
869
- machinePath === this.actor.initialRoute &&
870
- restoredRoute !== this.actor.initialRoute &&
871
- !actorAtLocation &&
872
- !(actorAtPath && carriesNoOwnSuffix(restoredRoute))) {
873
- return this.pushUnderMount(restoredRoute);
874
- }
875
- if (owesFirstSync) {
876
- // The restore does not apply, and the bridge held the address bar under this
877
- // mount NEVER: `performInitialSync` found the location outside the prefix and
878
- // returned before it could record one. This location is therefore the first one
879
- // that the mount can place.
880
- //
881
- // The memory is dropped already, so hand the location back to the caller. The
882
- // caller then drives the actor from it, which is the deep-link half of the rule
883
- // that `performInitialSync` applies.
884
- //
885
- // The FIRST half of that rule, and this branch owes it too: `performInitialSync`
886
- // asks whether the actor stands at the location already, and it records the
887
- // location and sends NOTHING when it does. Without the same test here, the same
888
- // two situations answered differently — the bridge that connected under the mount
889
- // stayed quiet, and the bridge that connected outside it re-entered the state the
890
- // actor was already in, which runs every entry action of that state again. Which
891
- // of the two happened depended on where the one shared router stood at the moment
892
- // of `connect()`, which is the arbitrary part.
893
- if (actorAtLocation) {
894
- this.lastSyncedPath = withSearch(machinePath, search);
895
- return true;
896
- }
897
- this.lastSyncedPath = null;
898
- return false;
899
- }
900
- if (previous !== "outside-push-owed")
901
- return false;
902
- // The actor wins a return to the location that the address bar STILL HELD. That is
903
- // the machine keeping its place while the user read a page of the host.
904
- //
905
- // It must not win a return to another route of the mount. A location that the
906
- // address bar did not hold is a choice of the host — a link that the user pressed,
907
- // a deep link they opened — and a push of the route of the actor would land them
908
- // on a page they did not ask for, with no explanation. Drop the memory instead,
909
- // and let the caller send the event for the location they chose.
910
- if (!this.isEchoOfLastSync(machinePath, search))
911
- return false;
912
- // The route that the block above read already. One read, because
913
- // `actor.currentRoute` belongs to the consumer and a second read can answer
914
- // differently from the first.
915
- //
916
- // A falsy route has nothing to write, and the bridge owns the address bar again
917
- // from the top of this method: `syncRouterFromActor` pushes the first real route of
918
- // the actor by itself, so no replay is needed. A memory kept here could be spent
919
- // only by a LATER return, at which point it pushes a location the router already
920
- // holds — a history entry that BACK cannot escape.
921
- if (!restoredRoute)
922
- return false;
923
- return this.pushUnderMount(restoredRoute);
924
- }
925
- /**
926
- * Writes the route of the actor to a router that just came under the mount.
927
- *
928
- * The method holds `isProcessingNavigation` across the push, because
929
- * `navigateRouter` makes the router call `syncActorFromRouter` again inside it.
930
- *
931
- * A re-entrant call pushes nothing inside another push, and the memory of a
932
- * suppressed push DOES survive that case: the outer push is still in flight, and a
933
- * clear here would throw the remembered route away with nothing left to replay it.
934
- *
935
- * @param route - The raw value of the actor route.
936
- * @returns `true` when the method wrote the location, so that the caller returns and
937
- * sends no event. `false` when the route resolves to no concrete path, so that the
938
- * caller drives the actor from the location instead.
939
- */
940
- pushUnderMount(route) {
941
- if (this.isProcessingNavigation)
942
- return true;
943
- // The route has to resolve to a concrete path BEFORE the method reports that it
944
- // handled the location. `pushResolvedRoute` navigates nowhere for an unknown stateId
945
- // and for a parameterized pattern, and it records the RAW value in `lastActorRoute`
946
- // alone: a `true` return then left the location in neither side — the URL kept the
947
- // page of the host, and the actor heard no `play.route`. The record of the location
948
- // that the address bar holds says nothing about it either, so no later return could
949
- // be recognized. `reconcileLocationWithMount` guards the same case.
950
- //
951
- // The resolution travels with the route, so `resolveNavigationPath` runs one time:
952
- // the method is `protected`, a subclass can override it, and a second call can
953
- // answer differently from the first.
954
- const resolved = this.resolveNavigationPath(route);
955
- if (resolved === null) {
956
- // Clear the record of the location, or the `false` return reaches the caller
957
- // and changes nothing. The caller hands the location back for the SAME location
958
- // that `lastSyncedPath` describes — the suppressed-push branch tests
959
- // `isEchoOfLastSync` before it calls this method — so the caller read its own
960
- // record as an echo and returned. The actor then stayed on a route that resolves
961
- // to no URL, the location it was given reached it never, and no error said so.
962
- this.lastSyncedPath = null;
963
- return false;
964
- }
965
- this.pushHoldingNavigation(route, resolved);
966
- return true;
967
- }
968
- /**
969
- * Tests a route of the actor against the last synchronized location.
970
- *
971
- * The exact string test covers a stateId, for example `"#home"`, which
972
- * `pushResolvedRoute` writes to the field when it cannot resolve a path. The query
973
- * test covers a location that carries a query: the order of the keys of a query is
974
- * not significant, so `?b=2&a=1` and `?a=1&b=2` are the same location, and the
975
- * bridge must push neither of them back.
976
- *
977
- * The FRAGMENT counts in this direction, and the inbound test ignores it. The two
978
- * are different questions. `navigateRouter` writes the whole value that the actor
979
- * gave — `buildRouteUrl` appends `context.hash` — so `"/docs"` and `"/docs#intro"`
980
- * are two locations to write, and a test that read them as one left the fragment out
981
- * of the address bar for ever. `isEchoOfLastSync` compares against what the ROUTER
982
- * reports, and `sanitizePathname` has removed the fragment from that already.
983
- */
984
- testEchoOfLastPush(route) {
985
- // The FIRST question: did the bridge account for this value of the atom already?
986
- // The test is an exact string compare, because it asks about the VALUE. A route
987
- // that resolves to no URL — an unknown stateId, a parameterized pattern — is
988
- // therefore covered too, and the bridge tries it one time only.
989
- if (route === this.lastActorRoute)
990
- return { isEcho: true };
991
- const location = this.lastSyncedPath;
992
- // The bridge placed no location yet, so it knows nothing about the address bar.
993
- // Resolve for the caller, and let it push.
994
- if (location === null) {
995
- return { isEcho: false, resolved: this.resolveNavigationPath(route) };
996
- }
997
- // The SECOND question: does the router hold the location that this route describes
998
- // already? A route that IS a path answers it directly.
999
- if (sameLocation(route, location))
1000
- return { isEcho: true };
1001
- // A route that is a stateId answers it only after the resolution. The machine that
1002
- // mirrors an inbound `play.route` back into its route atom reaches this line:
1003
- // the bridge wrote "/about" and the actor answers "#about", which is ONE location.
1004
- // A compare of the raw value alone missed, and the bridge pushed a location that
1005
- // the router already held — one history entry that BACK cannot escape.
1006
- //
1007
- // The resolution travels back to the caller, so `resolveNavigationPath` runs one
1008
- // time: the method is `protected`, a subclass can override it, and a second call
1009
- // can answer differently from the first.
1010
- const resolved = this.resolveNavigationPath(route);
1011
- if (resolved !== null && sameLocation(resolved, location))
1012
- return { isEcho: true };
1013
- return { isEcho: false, resolved };
1014
- }
1015
- /**
1016
- * Resolves an actor route to its concrete URL path, and pushes that path to the
1017
- * router.
1018
- *
1019
- * lastSyncedPath must hold the concrete path, because the callback of the router
1020
- * fires with the concrete path. The two values therefore match, and
1021
- * syncActorFromRouter stops. A raw stateId in that field, for example "#home", does
1022
- * not match the sanitized path ("/") in the watcher.
1023
- *
1024
- * When resolveNavigationPath returns null, which happens for a parameterized
1025
- * pattern, for a wildcard pattern, and for an unknown id, the method skips the push
1026
- * completely: a push of the raw value writes a stateId, a `:param` pattern, or a
1027
- * literal `*` into the browser URL. The method still writes the raw route to
1028
- * lastActorRoute, and it leaves lastSyncedPath as it stands, because that field
1029
- * records a LOCATION and no location changed. Therefore the dedup guard fires
1030
- * correctly on the next identical value of the atom.
1031
- *
1032
- * @param route - The raw value of the actor route.
1033
- * @param resolved - The concrete path of that route. The default resolves it here. A
1034
- * caller that resolved it already passes it, so that `resolveNavigationPath` runs
1035
- * one time: the method is `protected`, and a subclass can override it.
1036
- */
1037
- pushResolvedRoute(route, resolved = this.resolveNavigationPath(route)) {
1038
- // The VALUE is accounted for either way, so the dedup stops the next identical fire
1039
- // of the atom even when the route resolves to no URL.
1040
- this.lastActorRoute = route;
1041
- // The LOCATION record takes the concrete path only. A raw stateId here described no
1042
- // location that a router ever showed, and `isEchoOfLastSync` compares this field
1043
- // against the location that the router reports: it read a real move as an echo of
1044
- // this bridge, and the actor heard nothing of it.
1045
- if (resolved === null)
1046
- return;
1047
- this.lastSyncedPath = resolved;
1048
- // This is the ONE place that adds the base path. `lastSyncedPath` keeps the
1049
- // machine-side path above, with no prefix, so it still compares directly against
1050
- // an actor route in `syncRouterFromActor`; `navigateRouter` receives the location
1051
- // of the host router, because that is the location the framework navigates to.
1052
- this.navigateRouter(joinBasePath(this.mount.path, resolved));
1053
- }
1054
- /**
1055
- * Writes the actor state when the location of the router changes.
1056
- *
1057
- * **A known path:** the method sends a `play.route` event to the actor, with the
1058
- * stateId of the match, the params, and the query. The `isProcessingNavigation` flag
1059
- * stops a circular update.
1060
- *
1061
- * **An unknown path, or a path with no entry in the map:** the method sends NO
1062
- * `play.route` event, and the actor state stays as it is. It corrects the browser URL
1063
- * instead, with a `navigateRouter(actor.currentRoute.get())` call. The URL therefore
1064
- * follows the actor state, also when the user types an invalid path in the address
1065
- * bar, or when the code pushes such a path during a session. The method writes the
1066
- * resolved concrete path to `lastSyncedPath` before the `navigateRouter` call.
1067
- * Therefore the callback of the router for that navigation stops at the guard of the
1068
- * echo suppression, and it sends no false event.
1069
- */
1070
- syncActorFromRouter(pathname, search) {
1071
- if (this.hasConnectedOnce && !this.isConnected)
1072
- return;
1073
- if (typeof pathname !== "string")
1074
- return;
1075
- const sanitized = sanitizePathname(pathname);
1076
- if (sanitized === null)
1077
- return; // The path is too long. Refuse it
1078
- // Take the machine half of the location. A `null` value means that the location
1079
- // lies OUTSIDE the mount, so it belongs to the host: return without a send of an
1080
- // event, and without a correction of the URL. That silence lets the routes of the
1081
- // host and the routes of the machine share one router.
1082
- const machinePath = stripBasePath(sanitized, this.mount.path);
1083
- if (machinePath === null) {
1084
- this.markOutside();
1085
- return;
1086
- }
1087
- // The test runs BEFORE the echo suppression. The location of a return matches
1088
- // `lastSyncedPath` in the common case — the host comes back to the mount point it
1089
- // left — and an early stop there would drop the suppressed push for good.
1090
- if (this.takeReturnUnderMount(machinePath, search))
1091
- return;
1092
- // The location lies under the mount, and this bridge is about to place it, so the
1093
- // first synchronization of this mount is owed no more. `takeReturnUnderMount`
1094
- // records it for a RETURN, and it returns at its first line for a bridge that never
1095
- // left, so the debt survived a first synchronization that reached no decision — an
1096
- // adapter whose `getInitialRouterPath()` gave `undefined`, or gave `null` to an
1097
- // actor with no route yet. The first later excursion of the host then came back to
1098
- // the mount point and read that stale debt as a restore: it pushed the route of the
1099
- // actor over the location that the user chose, with no `play.route` event for it.
1100
- this.hasSynchronizedUnderMount = true;
1101
- if (this.isEchoOfLastSync(machinePath, search))
1102
- return;
1103
- if (this.isProcessingNavigation)
1104
- return;
1105
- this.isProcessingNavigation = true;
1106
- try {
1107
- const nextRoute = buildPlayRouteEvent({
1108
- pathname: machinePath,
1109
- search,
1110
- match: (nextPathname) => matchRouteMap(nextPathname, this.routeMap, (resolvedPathname, stateId) => this.extractParams(resolvedPathname, stateId)),
1111
- });
1112
- if (!nextRoute) {
1113
- // The path is unknown, because the map holds no route of it.
1114
- // Push the current route of the actor back to the router. The browser URL therefore
1115
- // follows the actor state, and this corrects an invalid entry in the address bar on a
1116
- // cold load, and also a navigation to a path that is absent during a session.
1117
- //
1118
- // The code calls pushResolvedRoute() directly, and not syncRouterFromActor(). It
1119
- // therefore passes around the dedup guard, because lastSyncedPath holds the route of
1120
- // the actor already. pushResolvedRoute sets lastSyncedPath to the resolved concrete
1121
- // path before the push. Therefore the callback of the router for this navigation, a
1122
- // hashchange event or a history event, stops at the
1123
- // sanitized === lastSyncedPath guard, and it sends no false play.route event to the
1124
- // actor.
1125
- //
1126
- // isProcessingNavigation stays true here. The finally block below clears it after
1127
- // navigateRouter returns. That block therefore guards synchronously against a
1128
- // re-entrance of each router callback that fires at once.
1129
- const currentActorRoute = this.actor.currentRoute.get();
1130
- if (currentActorRoute) {
1131
- this.pushResolvedRoute(currentActorRoute);
1132
- }
1133
- return;
1134
- }
1135
- // Record the location BEFORE the send, and record it with its query.
1136
- //
1137
- // The route atom of the actor notifies the watcher of the other direction
1138
- // for this same move, and `syncRouterFromActor` then tests the route of the
1139
- // actor against this field. Two details decide that test:
1140
- //
1141
- // - The write must happen first. A write after the send leaves the previous
1142
- // path in the field while the watcher runs, so the test misses and the
1143
- // bridge pushes the location that the router already holds. On a press on
1144
- // BACK that push is a new history entry, and it replaces the entry that
1145
- // FORWARD returns to.
1146
- // - The write must carry the query. A machine that republishes the query
1147
- // holds `/second?id=1` as its route, and a pathname alone in this field
1148
- // never matches it.
1149
- this.lastSyncedPath = withSearch(nextRoute.pathname, search);
1150
- // The router moved, and the actor has not answered yet, so the value that the
1151
- // bridge accounted for describes the agreement of the two sides NO MORE. A value
1152
- // that stayed read the answer of the actor as a repeat of the atom: a guard
1153
- // redirect BACK to the route the actor came from — the route that the seed of
1154
- // `connect()` recorded — stopped at the dedup, the bridge pushed nothing, and the
1155
- // address bar kept the location that the guard refused.
1156
- this.lastActorRoute = null;
1157
- this.actor.send(nextRoute.event);
1158
- }
1159
- catch (error) {
1160
- // An InvalidRoutePatternError needs an action of the DEVELOPER: a route of the
1161
- // machine declares a pattern that URLPattern refuses, and no location can fix it.
1162
- // It goes to the caller without a change, so that the caller can find it and read
1163
- // the pattern that it names.
1164
- if (error instanceof InvalidRoutePatternError) {
1165
- throw error;
1166
- }
1167
- throw new RouterSyncError("Failed to sync actor state from router location.", {
1168
- cause: error,
1169
- });
1170
- }
1171
- finally {
1172
- this.isProcessingNavigation = false;
1173
- }
1174
- }
1175
- // ── The utilities. They are protected, and a subclass overrides one to read the params of its framework ──
1176
- /**
1177
- * Reads the path parameters of a URL, with the URLPattern API.
1178
- *
1179
- * The API is always present: `@xmachines/play-router` uses the native URLPattern of the
1180
- * runtime, and it falls back to `urlpattern-polyfill`, which it carries as an ordinary
1181
- * dependency. A consumer therefore loads nothing, whatever the runtime.
1182
- *
1183
- * @param pathname - The real URL path, for example '/profile/john'
1184
- * @param stateId - The stateId of the match, for the lookup of the route pattern
1185
- * @returns The path parameters of the read, or an empty object when nothing matches
1186
- * @throws {InvalidRoutePatternError} When URLPattern cannot compile the route pattern
1187
- */
1188
- extractParams(pathname, stateId) {
1189
- const pattern = this.routeMap.getPathByStateId(stateId);
1190
- if (!pattern)
1191
- return {};
1192
- return extractRouteParams(pathname, pattern, this.compileOptions);
1193
- }
1194
- /**
1195
- * Resolves a value of an actor route to a concrete URL path for a navigation.
1196
- *
1197
- * A bridge that receives a raw `actor.currentRoute` value in `navigateRouter` calls
1198
- * this method to convert a stateId, with a `#` prefix or without one, into a path.
1199
- * The method returns `null` when a navigation is not possible:
1200
- * - an unknown stateId, with no entry in the route map
1201
- * - a parameterized pattern or a wildcard pattern, for example `/profile/:id` or
1202
- * `/docs/*`, because no concrete value is available for a real URL
1203
- * - a string that is no path and no known stateId
1204
- *
1205
- * A LITERAL route of the map comes back with each escape resolved: `/tags/c\+\+` gives
1206
- * the path `/tags/c++`, which is the path that the route matches.
1207
- *
1208
- * A lookup of a stateId tries the form `"#stateId"` and the bare form `"stateId"`,
1209
- * through `lookupPathByStateId`. Therefore a route map of your own that holds one
1210
- * form only still resolves the path.
1211
- *
1212
- * The result is MACHINE-side: it carries no `basePath`, because it is also the
1213
- * value that `lastSyncedPath` stores and that the code compares against an actor
1214
- * route. The code adds the prefix one time, at the `navigateRouter()` call in
1215
- * `pushResolvedRoute`.
1216
- *
1217
- * @param route - The raw value of the actor route: a stateId, a stateId with a `#`, or a concrete path
1218
- * @returns The concrete machine-side URL path, or `null` when the bridge must skip the navigation
1219
- */
1220
- resolveNavigationPath(route) {
1221
- const mapped = this.lookupPathByStateId(route);
1222
- const path = mapped ?? (route.startsWith("/") ? route : null);
1223
- if (path === null)
1224
- return null;
1225
- // A LITERAL route resolves to the one path that it matches, and the source of a
1226
- // pattern is not always that path: `/tags/c\+\+` matches `/tags/c++`, and
1227
- // `/i18n/{en}` matches `/i18n/en`. A bridge that pushed the source wrote the
1228
- // backslashes and the braces into the address bar, where `getStateIdByPath` answered
1229
- // that location never — so the URL stopped following the actor, and its next move to
1230
- // the same route was suppressed as an echo of that push.
1231
- //
1232
- // The two branches above resolve DIFFERENT amounts, and the difference is what the
1233
- // string is:
1234
- //
1235
- // - A route of the MAP is a pattern source, so every construct in it is grammar and
1236
- // the parse resolves all of it.
1237
- // - A route that the ACTOR built is a path already. `buildRouteUrl` of
1238
- // `@xmachines/play-xstate` reads the whole grammar and resolves every escape, so
1239
- // a route that IT built carries no backslash. An actor of another kind can still
1240
- // write one, and a BACKSLASH is the one construct that means grammar in such a
1241
- // path — and nothing else does. A brace in such a path is a real brace, and
1242
- // `/i18n/{en}` from an actor is the location `/i18n/{en}`.
1243
- //
1244
- // A route of the map is a pattern source WHOLE, and its `?` is a modifier and never
1245
- // a query. The parse therefore reads all of it, and its answer is complete: a `null`
1246
- // literal path says that the pattern describes a SET of paths, so the bridge pushes
1247
- // nothing. A fall to the concrete-path test below answered that question with the
1248
- // characters of the string instead, and it let every pattern whose grammar carries
1249
- // no ":" and no "*" through — `/docs/rfc(2119)`, `/books{/v2}?` and `/files/(\d+)`
1250
- // all reached the address bar as they stand, where `getStateIdByPath` answers them
1251
- // never.
1252
- if (mapped != null)
1253
- return parsePattern(path, this.compileOptions).literalPath;
1254
- // A route of the ACTOR carries its query, because `context.query` travels in
1255
- // `currentRoute`, and the PATHNAME half alone reaches the grammar for it. A query
1256
- // holds a ":" and a "*" legally — a time in "?at=10:30", a URL in "?next=https://x",
1257
- // a glob in "?q=a*" — and it holds the "?" that the grammar reads as a modifier. A
1258
- // parse of the whole string called such a route malformed and resolved nothing of
1259
- // it, so `/tags/c\+\+?ref=nav` went to the address bar with both backslashes; a
1260
- // PATTERN test of the whole string called it a pattern, so `pushResolvedRoute` wrote
1261
- // `lastSyncedPath` and navigated nowhere, and the dedup guard then suppressed the
1262
- // next attempt at the same route as an echo. The suffix carries no grammar, and it
1263
- // travels to the router unchanged.
1264
- const { pathname, suffix } = splitLocation(path);
1265
- // A BACKSLASH is the one construct that reaches an actor route unresolved, and this
1266
- // is where it resolves.
1267
- if (pathname.includes("\\")) {
1268
- const literal = parsePattern(pathname, this.compileOptions).literalPath;
1269
- // The resolution FAILED, so this route resolves to no concrete path and the
1270
- // bridge pushes nothing. A fall to the test below refused none of it — a backslash
1271
- // is neither a ":" nor a "*" — so the SOURCE went to the address bar with its
1272
- // backslashes, where `getStateIdByPath` answers that location never, and
1273
- // `pushResolvedRoute` then recorded it and suppressed the next attempt at the same
1274
- // route as an echo. `encodeURIComponent` leaves "(", ")", "!", "'" and "~" as they
1275
- // stand, so a param value of `f(x)` under a template that carries an escape —
1276
- // `/files/c\+\+/:name` — reaches this line and derives no literal path.
1277
- return literal === null ? null : `${literal}${suffix}`;
1278
- }
1279
- // The test is `holdsUnsubstitutedParam` and NOT `isParameterizedPattern`, because
1280
- // this string is a concrete path and not a pattern. Its own documentation gives the
1281
- // reason: a real param value carries a character of the grammar often enough, and
1282
- // the grammar reads a modifier in "/tags/c++" and a group in "/i18n/{en}".
1283
- if (holdsUnsubstitutedParam(pathname))
1284
- return null;
1285
- return path;
1286
- }
1287
- /**
1288
- * Tells you whether the actor already stands on the PATH of a location of the router.
1289
- *
1290
- * The `currentRoute` of the actor can be a stateId, for example `"#app.home"`, while a
1291
- * location of the router is a URL path, for example `"/home"`. Therefore the method
1292
- * resolves the path to a stateId before it compares. Without this step, a false "the
1293
- * two are different" starts a synchronization that nothing needs — and, in
1294
- * `takeReturnUnderMount`, it reads a location that the HOST chose as a restore of the
1295
- * actor and writes over it.
1296
- *
1297
- * The actor is on the path in three cases: the stateId of the match IS the actor
1298
- * route, the path of the match IS the actor route, or the actor route is a stateId
1299
- * that resolves to the same registered path as the stateId of the match. Each lookup
1300
- * goes through {@link lookupPathByStateId}, which tries the form `"#stateId"` and also
1301
- * the bare form `"stateId"`. Therefore a route map with `"#about"` recognizes an actor
1302
- * route of `"about"`, and the opposite also works, and this is correct for a
1303
- * structural map of your own that holds one form.
1304
- *
1305
- * An actor route with a concrete path, which starts with `"/"`, goes to a direct
1306
- * comparison of the pathname against `machinePath`. That comparison runs BEFORE the
1307
- * map, because a location the map does not hold is still a location the actor can
1308
- * stand on.
1309
- *
1310
- * The QUERY is a question of its own, and {@link sameQuery} answers it. Each caller
1311
- * asks BOTH, and the two answers do different work. "The two sides are in step" needs
1312
- * the path AND the query, so a router that carries the only query is a deep link that
1313
- * the actor has to follow — for a stateId route as much as for a path route. The
1314
- * RESTORE needs the path, beside `carriesNoOwnSuffix`: an actor that stands here and
1315
- * carries no query and no fragment of its own has nothing to put in the address bar,
1316
- * and a push would only rewrite the location the user opened.
1317
- *
1318
- * A query that the ACTOR carries is a difference that this branch still reads as a
1319
- * restore, so an actor at `/?ref=nav` wins against a router at `/?other=1`. `main`
1320
- * answers the same way, and whether that is a deep link or a restore is a decision
1321
- * about the rule: issue #18 carries it, and a test pins the behaviour.
1322
- *
1323
- * Each lookup runs ONE time, and a caller that needs both answers calls this method
1324
- * one time too. `getPathByStateId` belongs to the route map: a map of a consumer can
1325
- * answer differently on a second call, and `RouteMap` pays a cache lookup for each of
1326
- * them.
1327
- *
1328
- * @param machinePath - The machine half of the location of the router, with no query.
1329
- * @param actorRoute - The raw value of the actor route. It CAN carry a query.
1330
- */
1331
- isActorAtPath(machinePath, actorRoute) {
1332
- if (actorRoute == null)
1333
- return false;
1334
- // The PATHNAME half of the actor route.
1335
- //
1336
- // An actor route carries its QUERY, because `context.query` travels in
1337
- // `currentRoute`, and `machinePath` carries none — `sanitizePathname` cut it off
1338
- // before the caller stripped the prefix. The two are therefore never the identical
1339
- // string for a route with a query, and a comparison of the whole strings reported an
1340
- // actor standing exactly at this location as "somewhere else": `performInitialSync`
1341
- // read that as a deep link and sent a `play.route` for the state the actor was in
1342
- // already, so every entry action of that state ran a second time.
1343
- const { pathname: actorPath } = splitLocation(actorRoute);
1344
- // An ESCAPED literal route resolves on that same half, and its two forms are never
1345
- // the identical string: the actor carries `/tags/c\+\+`, which is what the template
1346
- // writes, and the router stands at `/tags/c++`, which is the path that the route
1347
- // matches and the path that `resolveNavigationPath` pushes. The resolution reads the
1348
- // PATHNAME, and not the whole string, for two reasons: a `?` is a modifier of the
1349
- // grammar that makes `/tags/c\+\+?ref=nav` malformed, and a query would key the
1350
- // bounded parse cache on a string that no route map holds.
1351
- const literalPath = actorPath.includes("\\")
1352
- ? parsePattern(actorPath, this.compileOptions).literalPath
1353
- : null;
1354
- // The pathname test answers with no map at all, and it has to run FIRST: the lookup
1355
- // below returns nothing for a location that the map does not hold, and the early
1356
- // return then reported an actor standing exactly at that location as "somewhere
1357
- // else". The seed of the constructor hid this for as long as one field carried the
1358
- // route of the actor AND the location.
1359
- if (machinePath === (literalPath ?? actorPath))
1360
- return true;
1361
- const resolvedStateId = this.routeMap.getStateIdByPath(machinePath);
1362
- if (resolvedStateId === null || resolvedStateId === undefined)
1363
- return false;
1364
- if (resolvedStateId === actorRoute)
1365
- return true;
1366
- if (actorRoute.startsWith("/"))
1367
- return false;
1368
- const actorStateIdPath = this.lookupPathByStateId(actorRoute);
1369
- return (actorStateIdPath != null &&
1370
- actorStateIdPath === this.lookupPathByStateId(resolvedStateId));
1371
- }
1372
- /**
1373
- * Returns the path of the route map for a value of an actor route, and it tries both
1374
- * forms of a stateId.
1375
- *
1376
- * The actor can emit `"#stateId"` while a route map of your own holds the bare form
1377
- * `"stateId"`, and also the opposite. `RouteMap` makes the canonical form
1378
- * internally, but the constructor accepts every structural
1379
- * `{ getStateIdByPath, getPathByStateId }` object: a plain test object and a map of
1380
- * one adapter are legal, and the documentation says so. Therefore this method tries
1381
- * both forms: the raw form first, then the form without the `#`, then the form with
1382
- * the `#`.
1383
- *
1384
- * @param route - The raw value of the actor route: a stateId, with a `#` prefix or without one
1385
- * @returns The path of the map, or `null` or `undefined` when no form is registered
1386
- */
1387
- lookupPathByStateId(route) {
1388
- const direct = this.routeMap.getPathByStateId(route);
1389
- if (direct != null)
1390
- return direct;
1391
- const alternate = route.startsWith("#") ? route.slice(1) : `#${route}`;
1392
- return this.routeMap.getPathByStateId(alternate);
1393
- }
1394
- /**
1395
- * Reads the query parameters of a URL search string.
1396
- *
1397
- * @param search - The URL search string, for example '?tab=security&page=1'
1398
- * @returns The query parameters of the read, or an empty object
1399
- */
1400
- extractQuery(search) {
1401
- return extractQuery(search);
1402
- }
1403
- /**
1404
- * True when a location of the router is the location that this bridge pushed.
1405
- *
1406
- * `lastSyncedPath` holds the value of `actor.currentRoute`, and that value carries
1407
- * the query string of `context.query`, for example `"/dates?trip=one-way"`. A router
1408
- * callback reports the pathname and the search SEPARATELY, and `sanitizePathname`
1409
- * removes the query. A comparison of the pathname alone therefore misses for every
1410
- * route with a query, and the push of this bridge comes back as a `play.route` event
1411
- * of its own navigation. That echo is not only wasted work: the bridge sends a
1412
- * `play.route` for a location that the actor stands on already, so every entry action
1413
- * of that state runs a second time, and a guard that refuses the repeat moves the
1414
- * actor to a state that no user asked for.
1415
- *
1416
- * The query is compared as a MAP, and not as a string: a framework can re-serialize
1417
- * a search string in another order, and `URLSearchParams` also accepts the form with
1418
- * a leading `?` and the form without one.
1419
- *
1420
- * @param sanitized - The clean pathname of the router callback, with no query.
1421
- * @param search - The search string of the router callback, when it has one.
1422
- * @returns True when the location is the echo of the last push of this bridge.
1423
- */
1424
- isEchoOfLastSync(sanitized, search) {
1425
- const last = this.lastSyncedPath;
1426
- if (last === null)
1427
- return false;
1428
- if (sanitized === last && (search ?? "") === "")
1429
- return true;
1430
- // Compare the query of BOTH sides. A pathname test alone reports an echo for a
1431
- // move that keeps the path and changes the query, which a filter link does, and
1432
- // which a `validateSearch` of a route does when it injects a default. The actor
1433
- // then never hears of the move, and the URL and the actor diverge.
1434
- const { pathname: lastPath, query: lastQuery } = splitLocation(last);
1435
- if (lastPath !== sanitized)
1436
- return false;
1437
- return sortedQuery(lastQuery) === sortedQuery(search ?? "");
1438
- }
1439
- /**
1440
- * Cleans a raw URL pathname of the router, and checks it.
1441
- *
1442
- * The method applies the limit of the path length, which is 2048 characters. It
1443
- * removes a query string and a fragment from the segment of the pathname, and it
1444
- * normalizes each sequence of slashes.
1445
- *
1446
- * An implementation that passes around `syncActorFromRouter()`, for example with a
1447
- * reactive watcher of its framework that receives a route object of a parse before,
1448
- * MUST call this method before it gives the path to a lookup in the route map.
1449
- * `syncActorFromRouter()` calls the method internally. Therefore a bridge that uses
1450
- * that method calls `sanitizePath` never itself.
1451
- *
1452
- * @param pathname - The raw URL pathname of the framework router.
1453
- * @returns The clean pathname, or `null` when the path is invalid or too long.
1454
- */
1455
- sanitizePath(pathname) {
1456
- return sanitizePathname(pathname);
1457
- }
1458
- /**
1459
- * Returns the current pathname of the router at the moment of `connect()`.
1460
- *
1461
- * `connect()` calls this method one time, for the first synchronization from the URL
1462
- * to the actor. router.subscribe() fires on a *later* navigation event only. It does
1463
- * not repeat the location that the router loaded before. A subclass that can read the
1464
- * current location of its router synchronously, for example with
1465
- * `router.state.location.pathname`, overrides this method. A deep link and a direct
1466
- * URL then drive the actor to the correct state, and they leave it not at the default
1467
- * of its machine.
1468
- *
1469
- * The meaning of each return value:
1470
- * - `string` → the router has a current path, and the base `connect()` writes the actor state from the router
1471
- * - `null` → the router is active, but it has no current path yet, and the base `connect()` writes the router from the actor
1472
- * - `undefined` → the adapter does the first synchronization itself, and the base `connect()` does nothing
1473
- *
1474
- * The default value is `undefined`. Therefore a bridge without this hook keeps its
1475
- * earlier behavior.
1476
- */
1477
- getInitialRouterPath() {
1478
- return undefined;
1479
- }
1480
- /**
1481
- * Returns the current search string of the router at the moment of `connect()`.
1482
- *
1483
- * This method is the pair of `getInitialRouterPath()`. `connect()` calls it one time,
1484
- * to give the query string of the first URL to `syncActorFromRouter()`. It returns
1485
- * `undefined` when the current URL of the router has no search string, and also when
1486
- * the subclass overrides the method not. `syncActorFromRouter` then makes an empty
1487
- * `query: {}` in the event.
1488
- *
1489
- * A subclass that overrides `getInitialRouterPath()` and has a query string
1490
- * overrides this method too.
1491
- *
1492
- * @returns The URL search string, for example `"?tab=security"`, or `undefined` when it is not available.
1493
- */
1494
- getInitialRouterSearch() {
1495
- return undefined;
1496
- }
1497
- }
7
+ * RouterBridgeBase — the abstract base class of every framework router adapter
8
+ *
9
+ * The class holds the logic that each router bridge shares, and that is 90% of the
10
+ * code of every bridge:
11
+ * - The atom watcher of the direction from the actor to the router
12
+ * - `lastSyncedPath`, for the echo suppression in the direction from the actor to the router
13
+ * - The `isProcessingNavigation` flag, which stops a loop of a guard redirect in `syncActorFromRouter` only
14
+ * - `syncRouterFromActor` and `syncActorFromRouter`, with the read of each URL parameter
15
+ * - The connect and disconnect lifecycle of the RouterBridge protocol
16
+ *
17
+ * A subclass implements the 3 abstract methods of its framework only:
18
+ * - navigateRouter(path): how the bridge tells the framework router to navigate
19
+ * - watchRouterChanges(): how the bridge subscribes to each location change of the router
20
+ * - unwatchRouterChanges(): how the bridge cancels that subscription
21
+ *
22
+ * @example
23
+ * ```typescript
24
+ * export class MyRouterBridge extends RouterBridgeBase {
25
+ * private unsubscribe: (() => void) | null = null;
26
+ *
27
+ * constructor(private router: MyRouter, actor: RoutableActor, routeMap: { getStateIdByPath(path: string): string | undefined; getPathByStateId(id: string): string | undefined; }) {
28
+ * super(actor, routeMap);
29
+ * }
30
+ *
31
+ * protected navigateRouter(path: string): void {
32
+ * this.router.navigate(path);
33
+ * }
34
+ *
35
+ * protected watchRouterChanges(): void {
36
+ * this.unsubscribe = this.router.subscribe((state) => {
37
+ * this.syncActorFromRouter(state.location.pathname, state.location.search);
38
+ * });
39
+ * }
40
+ *
41
+ * protected unwatchRouterChanges(): void {
42
+ * this.unsubscribe?.();
43
+ * this.unsubscribe = null;
44
+ * }
45
+ * }
46
+ * ```
47
+ *
48
+ * @see [Play RFC](../../docs/rfc/play.md) - invariant INV-04
49
+ */
50
+ /**
51
+ * The registry of the actors with an active bridge connection, at the module level.
52
+ *
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.
58
+ *
59
+ * @internal
60
+ */
61
+ var activeBridges = /* @__PURE__ */ new WeakMap();
62
+ var RouterBridgeBase = class {
63
+ actor;
64
+ routeMap;
65
+ isConnected = false;
66
+ hasConnectedOnce = false;
67
+ /**
68
+ * The machine-side location that the router holds, as far as this bridge knows. It
69
+ * carries the query and the fragment, and it carries NO `basePath`.
70
+ *
71
+ * Only code that saw a location, or that wrote one, writes this field:
72
+ * `performInitialSync` when it finds the two sides in step, `takeReturnUnderMount`
73
+ * for the same test under a mount, `pushResolvedRoute` after it resolves a route to
74
+ * a concrete path, and `syncActorFromRouter` for the location that the router
75
+ * reports. `null` means that this bridge placed no location yet, so it knows nothing
76
+ * about the address bar, and every test against this field must then answer "no".
77
+ *
78
+ * The field held the route of the actor as well, from the seed of the constructor
79
+ * and from a route that resolves to no URL. Those two values describe no location
80
+ * that a router ever showed, and each reader of this field asks about a location, so
81
+ * each of them read them wrongly. {@link lastActorRoute} holds them now.
82
+ */
83
+ lastSyncedPath = null;
84
+ /**
85
+ * The raw value of `actor.currentRoute` that this bridge accounted for already.
86
+ *
87
+ * The field answers ONE question: "did the bridge see this value of the atom
88
+ * before?". It is therefore a string, and not a location — a route that resolves to
89
+ * no URL, such as an unknown stateId or a parameterized pattern, still stops a
90
+ * second attempt at the same value.
91
+ *
92
+ * The constructor and `connect()` seed it, so that the first fire of the watcher of
93
+ * the atom pushes nothing for a route that moved nowhere.
94
+ */
95
+ lastActorRoute = null;
96
+ /**
97
+ * The flag guards `syncActorFromRouter` against a re-entrant call from a guard
98
+ * redirect of the actor itself. Such a call has this sequence: the bridge sends to
99
+ * the actor, the atom fires, the bridge pushes to the router, and a second
100
+ * `syncActorFromRouter` call starts before the first one returns.
101
+ *
102
+ * The flag is NOT the echo suppression of the direction from the actor to the
103
+ * router. `lastSyncedPath` does that work alone: the bridge writes it before the
104
+ * `navigateRouter()` call. Therefore each router callback of the same location stops
105
+ * at the {@link isEchoOfLastSync} test in `syncActorFromRouter`.
106
+ */
107
+ isProcessingNavigation = false;
108
+ /**
109
+ * Who owns the address bar, and what this bridge owes it.
110
+ *
111
+ * - `"under-mount"` — the location lies under the mount, so this bridge owns the
112
+ * address bar and it writes the location for every route of its actor.
113
+ * - `"outside"` — the location lies outside the mount, so the HOST owns it. The
114
+ * bridge sends no event and it corrects no URL, which is what lets the routes of
115
+ * the host and the routes of the machine share one router.
116
+ * - `"outside-push-owed"` — outside, AND the route of the actor changed while the
117
+ * host owned the address bar. The push waits here instead of going to the router,
118
+ * and {@link RouterBridgeBase.takeReturnUnderMount} spends it when the location
119
+ * comes back. The machine therefore keeps its place while the user reads a page of
120
+ * the host.
121
+ *
122
+ * ONE value, and not a latch plus a memory. The memory only ever means something
123
+ * while the latch is up, so two fields could hold a fourth combination that says
124
+ * "the bridge owns the address bar AND a push waits for a return" — a state with no
125
+ * meaning, which a return would spend at a location the router already holds. Two
126
+ * writes also cannot happen together: every site that lowered the latch wrote the
127
+ * memory on the NEXT line, and the value between the two lines was that fourth
128
+ * combination. `takeReturnUnderMount` reads the value it replaces instead.
129
+ *
130
+ * `syncActorFromRouter` is the one place that sees a location, so it is the one
131
+ * place that decides between `"under-mount"` and `"outside"`.
132
+ * `performInitialSync` decides it for the location that the page loaded with.
133
+ *
134
+ * A bridge with no mount never leaves its mount: `stripBasePath` returns the path
135
+ * unchanged for an empty prefix, and never `null`. The value therefore stays
136
+ * `"under-mount"` for every bridge that takes no `basePath`.
137
+ */
138
+ mountState = "under-mount";
139
+ /**
140
+ * True once the first synchronization ran against a location INSIDE the mount.
141
+ *
142
+ * `performInitialSync` separates a deep link from a restore, and it can make that
143
+ * decision only for a location that lies under the mount. A bridge that connects
144
+ * while the host owns the address bar therefore reaches no decision at all: it
145
+ * raises the latch and returns. The flag says that the decision is still owed, and
146
+ * `syncActorFromRouter` pays it at the first location that IS under the mount —
147
+ * through {@link RouterBridgeBase.takeReturnUnderMount} for a RETURN, which needs the
148
+ * decision itself, and directly for every other location under the mount.
149
+ *
150
+ * A bridge with no mount never leaves its mount, so `performInitialSync` writes this
151
+ * flag on the first `connect()` and nothing reads it again.
152
+ */
153
+ hasSynchronizedUnderMount = false;
154
+ routeWatcher = null;
155
+ /**
156
+ * The mount point of the machine inside the host router: the resolved prefix, and
157
+ * the values of each `:param` of that prefix.
158
+ *
159
+ * The prefix lives here, and NOT in the route map, for two reasons. A route map is
160
+ * static, it is shared, and it holds an LRU cache inside, while a mount point is
161
+ * dynamic. One route map therefore serves every `machineId` without a rebuild. And
162
+ * the bridge is the boundary between a location of the host router and a path of
163
+ * the machine already, which is exactly what the prefix separates.
164
+ *
165
+ * `lastSyncedPath` stays MACHINE-side, and it carries no prefix. The code adds the
166
+ * prefix at the one `navigateRouter()` call in `pushResolvedRoute`, and it removes
167
+ * the prefix at the one inbound entry point `syncActorFromRouter`. Every test of
168
+ * the echo suppression therefore keeps comparing like with like.
169
+ */
170
+ mount = NO_BASE_PATH;
171
+ /**
172
+ * The caches that every parse and every compilation of this bridge reads.
173
+ *
174
+ * `resolveNavigationPath` and `isActorAtPath` parse a CONCRETE location, and an
175
+ * application writes one for each value that it puts in a param.
176
+ */
177
+ compileOptions;
178
+ /**
179
+ * @param actor - A `RoutableActor`, with `currentRoute`, `initialRoute`, and `send`.
180
+ * @param routeMap - The route map of both directions, for the resolution between a
181
+ * `stateId` and a `path`. Give `getStateIdByPath` and `getPathByStateId`. A
182
+ * framework adapter usually wraps the result of `createRouteMap(machine)`, or an
183
+ * equivalent value. The key of `getPathByStateId` is the form `"#stateId"` or the
184
+ * bare form `"stateId"`. The bridge tries both forms. Therefore an implementation
185
+ * of your own, for example a plain test object, handles one form only.
186
+ */
187
+ constructor(actor, routeMap, options) {
188
+ this.actor = actor;
189
+ this.routeMap = routeMap;
190
+ this.compileOptions = {
191
+ patternCache: options?.patternCache,
192
+ compiledCache: options?.compiledCache
193
+ };
194
+ this.mount = resolveBasePath(options?.basePath, options?.basePathParams);
195
+ this.lastActorRoute = this.actor.currentRoute.get() ?? null;
196
+ }
197
+ /**
198
+ * The resolved URL prefix that the machine of this bridge is mounted under, or `""`
199
+ * when the machine owns the complete router.
200
+ */
201
+ get basePath() {
202
+ return this.mount.path;
203
+ }
204
+ /**
205
+ * The resolved values of the `:param` segments of the mount, or `{}` for a prefix
206
+ * without a param.
207
+ *
208
+ * These params belong to the HOST: the host wrote the prefix and resolved the
209
+ * values, and they describe the route of the host and not the route of the machine.
210
+ * They therefore travel in NO `play.route` event. The machine is authoritative over
211
+ * its own params, and `event.params` holds what the pattern of the machine
212
+ * declares, and nothing else.
213
+ *
214
+ * They live here instead, because a value that reached the actor on a navigation
215
+ * ALONE would go stale: `setBasePath()` can move the mount while the machine stays
216
+ * on the same route, no event goes out, and `event.params` would then contradict
217
+ * `basePath`. A read of this accessor cannot go stale.
218
+ *
219
+ * A machine that needs the identity of its host — a `machineId`, a tenant — takes
220
+ * it through the `input` of the actor, where it belongs: that identity decides
221
+ * WHICH machine runs, and it is not a param of a route inside the machine.
222
+ *
223
+ * @example
224
+ * ```typescript
225
+ * bridge.basePath; // "/abc123/play"
226
+ * bridge.basePathParams; // { machineId: "abc123" }
227
+ * ```
228
+ */
229
+ get basePathParams() {
230
+ return this.mount.params;
231
+ }
232
+ /**
233
+ * Moves the machine to a different mount point, while it stays connected.
234
+ *
235
+ * This is the "load and unload" half of a shared router. It moves WHERE an actor is
236
+ * mounted, and never WHICH actor is mounted: the actor, the route map, and its LRU
237
+ * cache all stay, and nothing goes away.
238
+ *
239
+ * An actor never changes identity. A prefix that IDENTIFIES the actor — a
240
+ * `machineId` that names the document it runs — therefore never moves through this
241
+ * method: a new identity is a new actor, and a new actor takes a new bridge, because
242
+ * `connect()` permits one bridge for each actor. The segments that move here are the
243
+ * ones that LOCATE: a region, a locale, a tenant, a workspace slug.
244
+ *
245
+ * The call then brings the location in step with the NEW prefix. A location inside
246
+ * the new mount runs the same first-synchronization decision as `connect()`: it
247
+ * drives the actor, and a restore keeps the route of the actor. A location OUTSIDE
248
+ * the new mount is the old mount in practice, so the bridge writes the new one
249
+ * itself, and it keeps the route of the actor while it does so — nothing else moves
250
+ * the address bar. A call that resolves to the current prefix changes no location,
251
+ * and it therefore reconciles nothing.
252
+ *
253
+ * @param basePath - The new prefix, as a pattern or as a concrete path. An absent
254
+ * value, `""`, and `"/"` all remove the prefix, and they give the machine the
255
+ * complete router again.
256
+ * @param basePathParams - The values of the `:param` segments of `basePath`.
257
+ * @throws {InvalidBasePathError} For a prefix that resolves to one concrete path never.
258
+ * @throws {MissingBasePathParamError} When a `:param` of the prefix has no value.
259
+ *
260
+ * @example
261
+ * ```typescript
262
+ * // The host moved this actor to another place in its URL space. `machineId` names
263
+ * // WHICH document the actor runs, and it does not change: a new identity is a new
264
+ * // actor, and therefore a new bridge, because one actor takes one bridge.
265
+ * bridge.setBasePath("/:region/:machineId/play", { region: "us", machineId });
266
+ * ```
267
+ */
268
+ setBasePath(basePath, basePathParams) {
269
+ const next = resolveBasePath(basePath, basePathParams);
270
+ const samePath = next.path === this.mount.path;
271
+ this.mount = next;
272
+ if (samePath) return;
273
+ if (this.isConnected) {
274
+ this.lastSyncedPath = null;
275
+ this.mountState = "under-mount";
276
+ this.hasSynchronizedUnderMount = false;
277
+ this.reconcileLocationWithMount();
278
+ }
279
+ }
280
+ /**
281
+ * Brings the location of the router in step with a mount that just moved.
282
+ *
283
+ * **The location lies under the new mount:** run the same first-synchronization
284
+ * decision as `connect()`, so a location inside the mount drives the actor and a
285
+ * restore keeps the route of the actor.
286
+ *
287
+ * **The location lies outside it:** the bridge writes the new mount ITSELF, and it
288
+ * keeps the route of the actor while it does so. A host that re-points a mount
289
+ * changes no location by itself, and nothing else moves the address bar. Without
290
+ * this push the machine sits at a mount that no location is under: it hears no
291
+ * `play.route`, `lastSyncedPath` is clear already, and the stale prefix stays in the
292
+ * URL until the route of the actor happens to change — which then jumps the URL with
293
+ * no reason that the history of the user explains.
294
+ *
295
+ * `pushResolvedRoute` writes `lastSyncedPath` before it navigates, so the callback
296
+ * of the router for this push stops at the echo suppression, and it sends no event.
297
+ */
298
+ reconcileLocationWithMount() {
299
+ const routerPath = this.getInitialRouterPath();
300
+ const sanitized = typeof routerPath === "string" ? sanitizePathname(routerPath) : null;
301
+ if (typeof routerPath !== "string" || sanitized === null) {
302
+ this.performInitialSync(routerPath);
303
+ return;
304
+ }
305
+ if (stripBasePath(sanitized, this.mount.path) !== null) {
306
+ this.performInitialSync(routerPath);
307
+ return;
308
+ }
309
+ const currentActorRoute = this.actor.currentRoute.get();
310
+ if (!currentActorRoute) {
311
+ this.markOutside();
312
+ return;
313
+ }
314
+ const resolved = this.resolveNavigationPath(currentActorRoute);
315
+ if (resolved === null) {
316
+ this.mountState = "outside-push-owed";
317
+ return;
318
+ }
319
+ this.mountState = "under-mount";
320
+ this.hasSynchronizedUnderMount = true;
321
+ this.pushHoldingNavigation(currentActorRoute, resolved);
322
+ }
323
+ /**
324
+ * Pushes a route to the router, and it holds {@link isProcessingNavigation} across
325
+ * the write.
326
+ *
327
+ * Every corrective push of the bridge needs that flag: `navigateRouter` makes the
328
+ * router call `syncActorFromRouter` again inside it, and a router that NORMALIZES the
329
+ * location it receives — a `validateSearch` that injects a default, a trailing slash
330
+ * that a history adds — reports a value that the echo test misses. The bridge would
331
+ * then answer its own write with a `play.route` event, and on a press of BACK that
332
+ * answer is a history entry that FORWARD cannot return to.
333
+ *
334
+ * The one push that does NOT come through here is `syncRouterFromActor`: that push
335
+ * follows the actor, and the echo suppression of `lastSyncedPath` is what stops it.
336
+ *
337
+ * @param route - The raw value of the actor route.
338
+ * @param resolved - The concrete path of that route, when the caller resolved it
339
+ * already. `undefined` resolves it in `pushResolvedRoute`.
340
+ */
341
+ pushHoldingNavigation(route, resolved) {
342
+ this.isProcessingNavigation = true;
343
+ try {
344
+ this.pushResolvedRoute(route, resolved);
345
+ } finally {
346
+ this.isProcessingNavigation = false;
347
+ }
348
+ }
349
+ /**
350
+ * Connects the router bridge to the Actor.
351
+ *
352
+ * The method installs the atom watcher of the direction from the actor to the
353
+ * router. It then starts the watch of the router changes, which each framework does
354
+ * in its own way.
355
+ *
356
+ * The order of these steps is part of the contract of the bridge:
357
+ * - The constructor seeds `lastActorRoute` from `actor.currentRoute`, and it records no location
358
+ * - The method installs the actor watcher before the router subscriptions of the adapter
359
+ * - The first synchronization then separates a deep link from a restore, with `actor.initialRoute`
360
+ *
361
+ * An adapter that needs a different behavior of the first synchronization overrides
362
+ * `getInitialRouterPath()`. It does not change the order of the steps of
363
+ * `connect()`.
364
+ */
365
+ connect() {
366
+ if (this.isConnected) return;
367
+ const existingBridge = activeBridges.get(this.actor);
368
+ if (existingBridge !== void 0 && existingBridge !== this) throw new DuplicateBridgeError();
369
+ activeBridges.set(this.actor, this);
370
+ this.lastActorRoute = this.actor.currentRoute.get() ?? null;
371
+ this.lastSyncedPath = null;
372
+ this.isConnected = true;
373
+ this.hasConnectedOnce = true;
374
+ try {
375
+ this.routeWatcher = createRouteWatcher(this.actor.currentRoute, (route) => {
376
+ this.syncRouterFromActor(route);
377
+ });
378
+ this.watchRouterChanges();
379
+ this.performInitialSync(this.getInitialRouterPath());
380
+ } catch (error) {
381
+ try {
382
+ this.disconnect();
383
+ } catch {}
384
+ throw error;
385
+ }
386
+ }
387
+ /**
388
+ * Brings the actor and the router in step one time, and decides which side wins.
389
+ *
390
+ * The method is separate from `connect()`, so that `connect()` can roll the whole
391
+ * first synchronization back when it throws. See the `try` block there.
392
+ *
393
+ * The first synchronization: the direction from the router to the actor has the
394
+ * priority over the other direction.
395
+ *
396
+ * The page can load on a URL that is not the initial state of the actor. For
397
+ * example, the user types "/about", or the user follows a deep link. The router
398
+ * holds the correct path then, and the actor is still at its initial state ("/").
399
+ * The code must therefore drive the actor to the URL, and it must not write the
400
+ * default of the actor to the URL.
401
+ *
402
+ * router.subscribe() fires on a *later* navigation event only. It does NOT fire for
403
+ * the location that the page loaded. A subclass that can read the current location
404
+ * of its router synchronously overrides getInitialRouterPath().
405
+ *
406
+ * @param initialRouterPath - The location of the router. Every caller reads it and
407
+ * passes it, and this parameter carries NO default: a default fires for an
408
+ * explicit `undefined` too, so `reconcileLocationWithMount` asked the adapter a
409
+ * SECOND time for exactly the value that says "the adapter does the first
410
+ * synchronization itself" — and a read belongs to the adapter, which can walk the
411
+ * state of a router and answer differently on the second call.
412
+ */
413
+ performInitialSync(initialRouterPath) {
414
+ const initialActorRoute = this.actor.currentRoute.get();
415
+ let search;
416
+ let searchRead = false;
417
+ const routerSearch = () => {
418
+ if (!searchRead) {
419
+ search = this.getInitialRouterSearch();
420
+ searchRead = true;
421
+ }
422
+ return search;
423
+ };
424
+ if (typeof initialRouterPath === "string") {
425
+ const sanitized = sanitizePathname(initialRouterPath);
426
+ if (sanitized === null) return;
427
+ const machinePath = stripBasePath(sanitized, this.mount.path);
428
+ if (machinePath === null) {
429
+ this.markOutside();
430
+ return;
431
+ }
432
+ this.mountState = "under-mount";
433
+ this.hasSynchronizedUnderMount = true;
434
+ const actorAtRouterPath = this.isActorAtPath(machinePath, initialActorRoute);
435
+ if (actorAtRouterPath && initialActorRoute != null && sameQuery(initialActorRoute, routerSearch())) this.lastSyncedPath = withSearch(machinePath, routerSearch());
436
+ else if (initialActorRoute && isAtInitialLocation(this.actor.initialRoute, machinePath, routerSearch()) && initialActorRoute !== this.actor.initialRoute && !(actorAtRouterPath && carriesNoOwnSuffix(initialActorRoute))) this.pushHoldingNavigation(initialActorRoute);
437
+ else this.syncActorFromRouter(initialRouterPath, routerSearch());
438
+ } else if (initialActorRoute && initialRouterPath === null) {
439
+ this.pushHoldingNavigation(initialActorRoute);
440
+ this.hasSynchronizedUnderMount = true;
441
+ } else if (initialActorRoute && initialActorRoute !== this.lastActorRoute) {
442
+ this.syncRouterFromActor(initialActorRoute);
443
+ this.hasSynchronizedUnderMount = true;
444
+ }
445
+ }
446
+ /**
447
+ * Disconnects the router bridge from the Actor.
448
+ *
449
+ * The method stops the watch of the atom, and it removes the router listener of the
450
+ * framework.
451
+ */
452
+ disconnect() {
453
+ const hadRouteWatcher = this.routeWatcher !== null;
454
+ if (this.routeWatcher) try {
455
+ this.routeWatcher.unwatch();
456
+ } catch {}
457
+ this.routeWatcher = null;
458
+ try {
459
+ if (this.isConnected || hadRouteWatcher) this.unwatchRouterChanges();
460
+ } catch {} finally {
461
+ this.isProcessingNavigation = false;
462
+ this.isConnected = false;
463
+ this.mountState = "under-mount";
464
+ this.hasSynchronizedUnderMount = false;
465
+ if (activeBridges.get(this.actor) === this) activeBridges.delete(this.actor);
466
+ }
467
+ }
468
+ /**
469
+ * Writes the location of the router when the route atom of the actor changes.
470
+ *
471
+ * The method resolves the actor route to a concrete URL path, then it calls
472
+ * navigateRouter() for the navigation of the framework. When it cannot resolve the
473
+ * route, which happens for an unknown stateId and for a parameterized pattern
474
+ * without concrete values, it skips the push completely: `navigateRouter` receives a
475
+ * raw stateId or a pattern never.
476
+ *
477
+ * `lastSyncedPath` does the complete echo suppression, which stops the callback of
478
+ * the router from a send to the actor: the method writes the resolved path to
479
+ * `lastSyncedPath` before the `navigateRouter()` call. Therefore each
480
+ * `syncActorFromRouter` call of the same path stops at the
481
+ * `sanitized === lastSyncedPath` test, and it sends no event. This is correct for a
482
+ * synchronous callback and for an asynchronous callback.
483
+ *
484
+ * The method does NOT set `isProcessingNavigation`. That flag lives inside
485
+ * `syncActorFromRouter`, and it guards against a re-entrant loop of a guard
486
+ * redirect.
487
+ */
488
+ syncRouterFromActor(route) {
489
+ if (this.hasConnectedOnce && !this.isConnected) return;
490
+ if (!route || typeof route !== "string") return;
491
+ const echo = this.testEchoOfLastPush(route);
492
+ if (this.mountState !== "under-mount") {
493
+ this.mountState = echo.isEcho ? "outside" : "outside-push-owed";
494
+ return;
495
+ }
496
+ if (echo.isEcho) return;
497
+ this.pushResolvedRoute(route, echo.resolved);
498
+ }
499
+ /**
500
+ * Records that the location lies outside the mount, so the HOST owns the address bar.
501
+ *
502
+ * A bridge that is outside ALREADY keeps the value it holds: `"outside-push-owed"`
503
+ * says that a push waits for the address bar to come back, and a second location of
504
+ * the host is no reason to forget it.
505
+ */
506
+ markOutside() {
507
+ if (this.mountState === "under-mount") this.mountState = "outside";
508
+ }
509
+ /**
510
+ * Takes the return of the address bar under the mount.
511
+ *
512
+ * The actor wins that return when it moved while the host owned the location AND the
513
+ * address bar came back to the location it still held: the method pushes the route of
514
+ * the actor, and the caller sends no `play.route` event. A return to any other route
515
+ * of the mount belongs to the host, so the memory goes and the caller drives the
516
+ * actor from the location instead.
517
+ *
518
+ * The method also pays the first synchronization that the mount still owes. A bridge
519
+ * that connected while the host owned the address bar reached no decision between a
520
+ * deep link and a restore: `performInitialSync` can decide only for a location under
521
+ * the mount, and it found none. This is the first such location, so the decision runs
522
+ * here, with the same rule.
523
+ *
524
+ * The method holds `isProcessingNavigation` across each push, because
525
+ * `navigateRouter` makes the router call `syncActorFromRouter` again.
526
+ *
527
+ * @returns `true` when the method handled the location, so the caller returns.
528
+ */
529
+ takeReturnUnderMount(machinePath, search) {
530
+ const previous = this.mountState;
531
+ if (previous === "under-mount") return false;
532
+ this.mountState = "under-mount";
533
+ const owesFirstSync = !this.hasSynchronizedUnderMount;
534
+ this.hasSynchronizedUnderMount = true;
535
+ const restoredRoute = this.actor.currentRoute.get();
536
+ const actorAtPath = owesFirstSync && this.isActorAtPath(machinePath, restoredRoute);
537
+ const actorAtLocation = actorAtPath && restoredRoute != null && sameQuery(restoredRoute, search);
538
+ if (owesFirstSync && restoredRoute && isAtInitialLocation(this.actor.initialRoute, machinePath, search) && restoredRoute !== this.actor.initialRoute && !actorAtLocation && !(actorAtPath && carriesNoOwnSuffix(restoredRoute))) return this.pushUnderMount(restoredRoute);
539
+ if (owesFirstSync) {
540
+ if (actorAtLocation) {
541
+ this.lastSyncedPath = withSearch(machinePath, search);
542
+ return true;
543
+ }
544
+ this.lastSyncedPath = null;
545
+ return false;
546
+ }
547
+ if (previous !== "outside-push-owed") return false;
548
+ if (!this.isEchoOfLastSync(machinePath, search)) return false;
549
+ if (!restoredRoute) return false;
550
+ return this.pushUnderMount(restoredRoute);
551
+ }
552
+ /**
553
+ * Writes the route of the actor to a router that just came under the mount.
554
+ *
555
+ * The method holds `isProcessingNavigation` across the push, because
556
+ * `navigateRouter` makes the router call `syncActorFromRouter` again inside it.
557
+ *
558
+ * A re-entrant call pushes nothing inside another push, and the memory of a
559
+ * suppressed push DOES survive that case: the outer push is still in flight, and a
560
+ * clear here would throw the remembered route away with nothing left to replay it.
561
+ *
562
+ * @param route - The raw value of the actor route.
563
+ * @returns `true` when the method wrote the location, so that the caller returns and
564
+ * sends no event. `false` when the route resolves to no concrete path, so that the
565
+ * caller drives the actor from the location instead.
566
+ */
567
+ pushUnderMount(route) {
568
+ if (this.isProcessingNavigation) return true;
569
+ const resolved = this.resolveNavigationPath(route);
570
+ if (resolved === null) {
571
+ this.lastSyncedPath = null;
572
+ return false;
573
+ }
574
+ this.pushHoldingNavigation(route, resolved);
575
+ return true;
576
+ }
577
+ /**
578
+ * Tests a route of the actor against the last synchronized location.
579
+ *
580
+ * The exact string test covers a stateId, for example `"#home"`, which
581
+ * `pushResolvedRoute` writes to the field when it cannot resolve a path. The query
582
+ * test covers a location that carries a query: the order of the keys of a query is
583
+ * not significant, so `?b=2&a=1` and `?a=1&b=2` are the same location, and the
584
+ * bridge must push neither of them back.
585
+ *
586
+ * The FRAGMENT counts in this direction, and the inbound test ignores it. The two
587
+ * are different questions. `navigateRouter` writes the whole value that the actor
588
+ * gave — `buildRouteUrl` appends `context.hash` — so `"/docs"` and `"/docs#intro"`
589
+ * are two locations to write, and a test that read them as one left the fragment out
590
+ * of the address bar for ever. `isEchoOfLastSync` compares against what the ROUTER
591
+ * reports, and `sanitizePathname` has removed the fragment from that already.
592
+ */
593
+ testEchoOfLastPush(route) {
594
+ if (route === this.lastActorRoute) return { isEcho: true };
595
+ const location = this.lastSyncedPath;
596
+ if (location === null) return {
597
+ isEcho: false,
598
+ resolved: this.resolveNavigationPath(route)
599
+ };
600
+ if (sameLocation(route, location)) return { isEcho: true };
601
+ const resolved = this.resolveNavigationPath(route);
602
+ if (resolved !== null && sameLocation(resolved, location)) return { isEcho: true };
603
+ return {
604
+ isEcho: false,
605
+ resolved
606
+ };
607
+ }
608
+ /**
609
+ * Resolves an actor route to its concrete URL path, and pushes that path to the
610
+ * router.
611
+ *
612
+ * lastSyncedPath must hold the concrete path, because the callback of the router
613
+ * fires with the concrete path. The two values therefore match, and
614
+ * syncActorFromRouter stops. A raw stateId in that field, for example "#home", does
615
+ * not match the sanitized path ("/") in the watcher.
616
+ *
617
+ * When resolveNavigationPath returns null, which happens for a parameterized
618
+ * pattern, for a wildcard pattern, and for an unknown id, the method skips the push
619
+ * completely: a push of the raw value writes a stateId, a `:param` pattern, or a
620
+ * literal `*` into the browser URL. The method still writes the raw route to
621
+ * lastActorRoute, and it leaves lastSyncedPath as it stands, because that field
622
+ * records a LOCATION and no location changed. Therefore the dedup guard fires
623
+ * correctly on the next identical value of the atom.
624
+ *
625
+ * @param route - The raw value of the actor route.
626
+ * @param resolved - The concrete path of that route. The default resolves it here. A
627
+ * caller that resolved it already passes it, so that `resolveNavigationPath` runs
628
+ * one time: the method is `protected`, and a subclass can override it.
629
+ */
630
+ pushResolvedRoute(route, resolved = this.resolveNavigationPath(route)) {
631
+ this.lastActorRoute = route;
632
+ if (resolved === null) return;
633
+ this.lastSyncedPath = resolved;
634
+ this.navigateRouter(joinBasePath(this.mount.path, resolved));
635
+ }
636
+ /**
637
+ * Writes the actor state when the location of the router changes.
638
+ *
639
+ * **A known path:** the method sends a `play.route` event to the actor, with the
640
+ * stateId of the match, the params, and the query. The `isProcessingNavigation` flag
641
+ * stops a circular update.
642
+ *
643
+ * **An unknown path, or a path with no entry in the map:** the method sends NO
644
+ * `play.route` event, and the actor state stays as it is. It corrects the browser URL
645
+ * instead, with a `navigateRouter(actor.currentRoute.get())` call. The URL therefore
646
+ * follows the actor state, also when the user types an invalid path in the address
647
+ * bar, or when the code pushes such a path during a session. The method writes the
648
+ * resolved concrete path to `lastSyncedPath` before the `navigateRouter` call.
649
+ * Therefore the callback of the router for that navigation stops at the guard of the
650
+ * echo suppression, and it sends no false event.
651
+ */
652
+ syncActorFromRouter(pathname, search) {
653
+ if (this.hasConnectedOnce && !this.isConnected) return;
654
+ if (typeof pathname !== "string") return;
655
+ const sanitized = sanitizePathname(pathname);
656
+ if (sanitized === null) return;
657
+ const machinePath = stripBasePath(sanitized, this.mount.path);
658
+ if (machinePath === null) {
659
+ this.markOutside();
660
+ return;
661
+ }
662
+ if (this.takeReturnUnderMount(machinePath, search)) return;
663
+ this.hasSynchronizedUnderMount = true;
664
+ if (this.isEchoOfLastSync(machinePath, search)) return;
665
+ if (this.isProcessingNavigation) return;
666
+ this.isProcessingNavigation = true;
667
+ try {
668
+ const nextRoute = buildPlayRouteEvent({
669
+ pathname: machinePath,
670
+ search,
671
+ match: (nextPathname) => matchRouteMap(nextPathname, this.routeMap, (resolvedPathname, stateId) => this.extractParams(resolvedPathname, stateId))
672
+ });
673
+ if (!nextRoute) {
674
+ const currentActorRoute = this.actor.currentRoute.get();
675
+ if (currentActorRoute) this.pushResolvedRoute(currentActorRoute);
676
+ return;
677
+ }
678
+ this.lastSyncedPath = withSearch(nextRoute.pathname, search);
679
+ this.lastActorRoute = null;
680
+ this.actor.send(nextRoute.event);
681
+ } catch (error) {
682
+ if (error instanceof InvalidRoutePatternError) throw error;
683
+ throw new RouterSyncError("Failed to sync actor state from router location.", { cause: error });
684
+ } finally {
685
+ this.isProcessingNavigation = false;
686
+ }
687
+ }
688
+ /**
689
+ * Reads the path parameters of a URL, with the URLPattern API.
690
+ *
691
+ * The API is always present: `@xmachines/play-url` uses the native URLPattern of the
692
+ * runtime, and it falls back to `urlpattern-polyfill`, which it carries as an ordinary
693
+ * dependency. A consumer therefore loads nothing, whatever the runtime.
694
+ *
695
+ * @param pathname - The real URL path, for example '/profile/john'
696
+ * @param stateId - The stateId of the match, for the lookup of the route pattern
697
+ * @returns The path parameters of the read, or an empty object when nothing matches
698
+ * @throws {InvalidRoutePatternError} When URLPattern cannot compile the route pattern
699
+ */
700
+ extractParams(pathname, stateId) {
701
+ const pattern = this.routeMap.getPathByStateId(stateId);
702
+ if (!pattern) return {};
703
+ return extractRouteParams(pathname, pattern, this.compileOptions);
704
+ }
705
+ /**
706
+ * Resolves a value of an actor route to a concrete URL path for a navigation.
707
+ *
708
+ * A bridge that receives a raw `actor.currentRoute` value in `navigateRouter` calls
709
+ * this method to convert a stateId, with a `#` prefix or without one, into a path.
710
+ * The method returns `null` when a navigation is not possible:
711
+ * - an unknown stateId, with no entry in the route map
712
+ * - a parameterized pattern or a wildcard pattern, for example `/profile/:id` or
713
+ * `/docs/*`, because no concrete value is available for a real URL
714
+ * - a string that is no path and no known stateId
715
+ *
716
+ * A LITERAL route of the map comes back with each escape resolved: `/tags/c\+\+` gives
717
+ * the path `/tags/c++`, which is the path that the route matches.
718
+ *
719
+ * A lookup of a stateId tries the form `"#stateId"` and the bare form `"stateId"`,
720
+ * through `lookupPathByStateId`. Therefore a route map of your own that holds one
721
+ * form only still resolves the path.
722
+ *
723
+ * The result is MACHINE-side: it carries no `basePath`, because it is also the
724
+ * value that `lastSyncedPath` stores and that the code compares against an actor
725
+ * route. The code adds the prefix one time, at the `navigateRouter()` call in
726
+ * `pushResolvedRoute`.
727
+ *
728
+ * @param route - The raw value of the actor route: a stateId, a stateId with a `#`, or a concrete path
729
+ * @returns The concrete machine-side URL path, or `null` when the bridge must skip the navigation
730
+ */
731
+ resolveNavigationPath(route) {
732
+ const mapped = this.lookupPathByStateId(route);
733
+ const path = mapped ?? (route.startsWith("/") ? route : null);
734
+ if (path === null) return null;
735
+ if (mapped != null) return parsePattern(path, this.compileOptions).literalPath;
736
+ const { pathname, suffix } = splitLocation(path);
737
+ if (pathname.includes("\\")) {
738
+ const literal = parsePattern(pathname, this.compileOptions).literalPath;
739
+ return literal === null ? null : `${literal}${suffix}`;
740
+ }
741
+ if (holdsUnsubstitutedParam(pathname)) return null;
742
+ return path;
743
+ }
744
+ /**
745
+ * Tells you whether the actor already stands on the PATH of a location of the router.
746
+ *
747
+ * The `currentRoute` of the actor can be a stateId, for example `"#app.home"`, while a
748
+ * location of the router is a URL path, for example `"/home"`. Therefore the method
749
+ * resolves the path to a stateId before it compares. Without this step, a false "the
750
+ * two are different" starts a synchronization that nothing needs — and, in
751
+ * `takeReturnUnderMount`, it reads a location that the HOST chose as a restore of the
752
+ * actor and writes over it.
753
+ *
754
+ * The actor is on the path in three cases: the stateId of the match IS the actor
755
+ * route, the path of the match IS the actor route, or the actor route is a stateId
756
+ * that resolves to the same registered path as the stateId of the match. Each lookup
757
+ * goes through {@link lookupPathByStateId}, which tries the form `"#stateId"` and also
758
+ * the bare form `"stateId"`. Therefore a route map with `"#about"` recognizes an actor
759
+ * route of `"about"`, and the opposite also works, and this is correct for a
760
+ * structural map of your own that holds one form.
761
+ *
762
+ * An actor route with a concrete path, which starts with `"/"`, goes to a direct
763
+ * comparison of the pathname against `machinePath`. That comparison runs BEFORE the
764
+ * map, because a location the map does not hold is still a location the actor can
765
+ * stand on.
766
+ *
767
+ * The QUERY is a question of its own, and {@link sameQuery} answers it. Each caller
768
+ * asks BOTH, and the two answers do different work. "The two sides are in step" needs
769
+ * the path AND the query, so a router that carries the only query is a deep link that
770
+ * the actor has to follow — for a stateId route as much as for a path route. The
771
+ * RESTORE needs the path, beside `carriesNoOwnSuffix`: an actor that stands here and
772
+ * carries no query and no fragment of its own has nothing to put in the address bar,
773
+ * and a push would only rewrite the location the user opened.
774
+ *
775
+ * A restore also needs the router at the initial URL, and {@link isAtInitialLocation}
776
+ * compares the query there too. An actor at `/?ref=nav` therefore loses against a
777
+ * router at `/?other=1`: the user opened a URL that the machine does not start at,
778
+ * and that is a deep link (issue #18).
779
+ *
780
+ * Each lookup runs ONE time, and a caller that needs both answers calls this method
781
+ * one time too. `getPathByStateId` belongs to the route map: a map of a consumer can
782
+ * answer differently on a second call, and `RouteMap` pays a cache lookup for each of
783
+ * them.
784
+ *
785
+ * @param machinePath - The machine half of the location of the router, with no query.
786
+ * @param actorRoute - The raw value of the actor route. It CAN carry a query.
787
+ */
788
+ isActorAtPath(machinePath, actorRoute) {
789
+ if (actorRoute == null) return false;
790
+ const { pathname: actorPath } = splitLocation(actorRoute);
791
+ if (machinePath === ((actorPath.includes("\\") ? parsePattern(actorPath, this.compileOptions).literalPath : null) ?? actorPath)) return true;
792
+ const resolvedStateId = this.routeMap.getStateIdByPath(machinePath);
793
+ if (resolvedStateId === null || resolvedStateId === void 0) return false;
794
+ if (resolvedStateId === actorRoute) return true;
795
+ if (actorRoute.startsWith("/")) return false;
796
+ const actorStateIdPath = this.lookupPathByStateId(actorRoute);
797
+ return actorStateIdPath != null && actorStateIdPath === this.lookupPathByStateId(resolvedStateId);
798
+ }
799
+ /**
800
+ * Returns the path of the route map for a value of an actor route, and it tries both
801
+ * forms of a stateId.
802
+ *
803
+ * The actor can emit `"#stateId"` while a route map of your own holds the bare form
804
+ * `"stateId"`, and also the opposite. `RouteMap` makes the canonical form
805
+ * internally, but the constructor accepts every structural
806
+ * `{ getStateIdByPath, getPathByStateId }` object: a plain test object and a map of
807
+ * one adapter are legal, and the documentation says so. Therefore this method tries
808
+ * both forms: the raw form first, then the form without the `#`, then the form with
809
+ * the `#`.
810
+ *
811
+ * @param route - The raw value of the actor route: a stateId, with a `#` prefix or without one
812
+ * @returns The path of the map, or `null` or `undefined` when no form is registered
813
+ */
814
+ lookupPathByStateId(route) {
815
+ const direct = this.routeMap.getPathByStateId(route);
816
+ if (direct != null) return direct;
817
+ const alternate = route.startsWith("#") ? route.slice(1) : `#${route}`;
818
+ return this.routeMap.getPathByStateId(alternate);
819
+ }
820
+ /**
821
+ * Reads the query parameters of a URL search string.
822
+ *
823
+ * @param search - The URL search string, for example '?tab=security&page=1'
824
+ * @returns The query parameters of the read, or an empty object
825
+ */
826
+ extractQuery(search) {
827
+ return extractQuery(search);
828
+ }
829
+ /**
830
+ * True when a location of the router is the location that this bridge pushed.
831
+ *
832
+ * `lastSyncedPath` holds the value of `actor.currentRoute`, and that value carries
833
+ * the query string of `context.query`, for example `"/dates?trip=one-way"`. A router
834
+ * callback reports the pathname and the search SEPARATELY, and `sanitizePathname`
835
+ * removes the query. A comparison of the pathname alone therefore misses for every
836
+ * route with a query, and the push of this bridge comes back as a `play.route` event
837
+ * of its own navigation. That echo is not only wasted work: the bridge sends a
838
+ * `play.route` for a location that the actor stands on already, so every entry action
839
+ * of that state runs a second time, and a guard that refuses the repeat moves the
840
+ * actor to a state that no user asked for.
841
+ *
842
+ * The query is compared as a MAP, and not as a string: a framework can re-serialize
843
+ * a search string in another order, and `URLSearchParams` also accepts the form with
844
+ * a leading `?` and the form without one.
845
+ *
846
+ * @param sanitized - The clean pathname of the router callback, with no query.
847
+ * @param search - The search string of the router callback, when it has one.
848
+ * @returns True when the location is the echo of the last push of this bridge.
849
+ */
850
+ isEchoOfLastSync(sanitized, search) {
851
+ const last = this.lastSyncedPath;
852
+ if (last === null) return false;
853
+ if (sanitized === last && (search ?? "") === "") return true;
854
+ const { pathname: lastPath, query: lastQuery } = splitLocation(last);
855
+ if (lastPath !== sanitized) return false;
856
+ return sortedQuery(lastQuery) === sortedQuery(search ?? "");
857
+ }
858
+ /**
859
+ * Cleans a raw URL pathname of the router, and checks it.
860
+ *
861
+ * The method applies the limit of the path length, which is 2048 characters. It
862
+ * removes a query string and a fragment from the segment of the pathname, and it
863
+ * normalizes each sequence of slashes.
864
+ *
865
+ * An implementation that passes around `syncActorFromRouter()`, for example with a
866
+ * reactive watcher of its framework that receives a route object of a parse before,
867
+ * MUST call this method before it gives the path to a lookup in the route map.
868
+ * `syncActorFromRouter()` calls the method internally. Therefore a bridge that uses
869
+ * that method calls `sanitizePath` never itself.
870
+ *
871
+ * @param pathname - The raw URL pathname of the framework router.
872
+ * @returns The clean pathname, or `null` when the path is invalid or too long.
873
+ */
874
+ sanitizePath(pathname) {
875
+ return sanitizePathname(pathname);
876
+ }
877
+ /**
878
+ * Returns the current pathname of the router at the moment of `connect()`.
879
+ *
880
+ * `connect()` calls this method one time, for the first synchronization from the URL
881
+ * to the actor. router.subscribe() fires on a *later* navigation event only. It does
882
+ * not repeat the location that the router loaded before. A subclass that can read the
883
+ * current location of its router synchronously, for example with
884
+ * `router.state.location.pathname`, overrides this method. A deep link and a direct
885
+ * URL then drive the actor to the correct state, and they leave it not at the default
886
+ * of its machine.
887
+ *
888
+ * The meaning of each return value:
889
+ * - `string` → the router has a current path, and the base `connect()` writes the actor state from the router
890
+ * - `null` → the router is active, but it has no current path yet, and the base `connect()` writes the router from the actor
891
+ * - `undefined` → the adapter does the first synchronization itself, and the base `connect()` does nothing
892
+ *
893
+ * The default value is `undefined`. Therefore a bridge without this hook keeps its
894
+ * earlier behavior.
895
+ */
896
+ getInitialRouterPath() {}
897
+ /**
898
+ * Returns the current search string of the router at the moment of `connect()`.
899
+ *
900
+ * This method is the pair of `getInitialRouterPath()`. `connect()` calls it one time,
901
+ * to give the query string of the first URL to `syncActorFromRouter()`. It returns
902
+ * `undefined` when the current URL of the router has no search string, and also when
903
+ * the subclass overrides the method not. `syncActorFromRouter` then makes an empty
904
+ * `query: {}` in the event.
905
+ *
906
+ * A subclass that overrides `getInitialRouterPath()` and has a query string
907
+ * overrides this method too.
908
+ *
909
+ * @returns The URL search string, for example `"?tab=security"`, or `undefined` when it is not available.
910
+ */
911
+ getInitialRouterSearch() {}
912
+ };
1498
913
  /**
1499
- * True when the query of an actor route and the query of the router are the same query.
1500
- *
1501
- * `isActorAtPath` answers where the actor stands, and this function answers whether the
1502
- * two sides agree about the query there. The order of the keys is not significant, which
1503
- * is what `isEchoOfLastSync` says for the same pair of values.
1504
- *
1505
- * A query that DIFFERS answers `false`, and it must: the first synchronization gives the
1506
- * router the priority, so a location with another query is a deep link that the actor has
1507
- * to follow. An ABSENT query of the actor against a query of the router is such a
1508
- * difference — without this, a deep link of `/settings?ref=nav` reached an actor that
1509
- * stood at `/settings` never, and `context.query` stayed empty for the session while
1510
- * `lastSyncedPath` recorded the query and suppressed every later report of it. A stateId
1511
- * carries no query of its own, so it stands at a location with one never.
1512
- *
1513
- * @param actorRoute - The raw value of the actor route. It CAN carry a query.
1514
- * @param search - The query of the router, or `undefined` when it has none.
1515
- */
914
+ * True when the query of an actor route and the query of the router are the same query.
915
+ *
916
+ * `isActorAtPath` answers where the actor stands, and this function answers whether the
917
+ * two sides agree about the query there. The order of the keys is not significant, which
918
+ * is what `isEchoOfLastSync` says for the same pair of values.
919
+ *
920
+ * A query that DIFFERS answers `false`, and it must: the first synchronization gives the
921
+ * router the priority, so a location with another query is a deep link that the actor has
922
+ * to follow. An ABSENT query of the actor against a query of the router is such a
923
+ * difference — without this, a deep link of `/settings?ref=nav` reached an actor that
924
+ * stood at `/settings` never, and `context.query` stayed empty for the session while
925
+ * `lastSyncedPath` recorded the query and suppressed every later report of it. A stateId
926
+ * carries no query of its own, so it stands at a location with one never.
927
+ *
928
+ * @param actorRoute - The raw value of the actor route. It CAN carry a query.
929
+ * @param search - The query of the router, or `undefined` when it has none.
930
+ */
1516
931
  function sameQuery(actorRoute, search) {
1517
- const { query: actorQuery, fragment } = splitLocation(actorRoute);
1518
- // A FRAGMENT is a difference, always. `sanitizePathname` cuts it off, so the router
1519
- // reports one never and this comparison can confirm one never — and "unknown" must not
1520
- // read as "the same". An actor that carries `/#intro` against a router at `/` is the
1521
- // restore that puts the fragment in the address bar, and a `true` here swallowed it:
1522
- // the bridge recorded the location, sent nothing, and the fragment reached the URL on
1523
- // no later navigation either.
1524
- if (fragment !== "")
1525
- return false;
1526
- return sortedQuery(actorQuery) === sortedQuery(search ?? "");
932
+ const { query: actorQuery, fragment } = splitLocation(actorRoute);
933
+ if (fragment !== "") return false;
934
+ return sortedQuery(actorQuery) === sortedQuery(search ?? "");
1527
935
  }
1528
936
  /**
1529
- * True when an actor route carries no query and no fragment of its own.
1530
- *
1531
- * The RESTORE branch asks this beside {@link RouterBridgeBase.isActorAtPath}. An actor
1532
- * that stands on the path of the router and answers `true` here restores nothing: the
1533
- * address bar already shows its place, and a push writes the same path back and drops
1534
- * whatever the router carried beside it.
1535
- *
1536
- * A stateId answers `true`, because a stateId carries no query and no fragment. The
1537
- * leading `#` of `"#home"` is no fragment, which is what {@link splitLocation} says at
1538
- * index 0.
1539
- *
1540
- * @param actorRoute - The raw value of the actor route.
1541
- */
937
+ * True when an actor route carries no query and no fragment of its own.
938
+ *
939
+ * The RESTORE branch asks this beside {@link RouterBridgeBase.isActorAtPath}. An actor
940
+ * that stands on the path of the router and answers `true` here restores nothing: the
941
+ * address bar already shows its place, and a push writes the same path back and drops
942
+ * whatever the router carried beside it.
943
+ *
944
+ * A stateId answers `true`, because a stateId carries no query and no fragment. The
945
+ * leading `#` of `"#home"` is no fragment, which is what {@link splitLocation} says at
946
+ * index 0.
947
+ *
948
+ * @param actorRoute - The raw value of the actor route.
949
+ */
1542
950
  function carriesNoOwnSuffix(actorRoute) {
1543
- const { query, fragment } = splitLocation(actorRoute);
1544
- return query === "" && fragment === "";
951
+ const { query, fragment } = splitLocation(actorRoute);
952
+ return query === "" && fragment === "";
953
+ }
954
+ /**
955
+ * True when the router stands at the initial URL of the machine: at the path of
956
+ * `initialRoute` AND at its query.
957
+ *
958
+ * The restore test of `performInitialSync` and of `takeReturnUnderMount` asks this. It
959
+ * compared the path alone, so a router at `/?other=1` read as "at the initial URL `/`"
960
+ * and a restored actor at `/?ref=nav` won: the bridge pushed the query of the actor over
961
+ * the query that the user opened, and no `play.route` carried it (issue #18). A query
962
+ * that the machine did not put there is what a deep link looks like, so the router wins.
963
+ *
964
+ * The cost of the rule: after a reload, a query that the host or the browser added to the
965
+ * initial URL is a deep link too, and the actor loses its restored route to it.
966
+ *
967
+ * @param initialRoute - The initial route of the actor. `null` is no initial route.
968
+ * @param machinePath - The machine half of the location of the router, with no query.
969
+ * @param search - The query of the router.
970
+ */
971
+ function isAtInitialLocation(initialRoute, machinePath, search) {
972
+ if (typeof initialRoute !== "string") return false;
973
+ return splitLocation(initialRoute).pathname === machinePath && sameQuery(initialRoute, search);
1545
974
  }
1546
975
  /** Joins a pathname and a query into one location value. An empty query adds nothing. */
1547
976
  function withSearch(pathname, search) {
1548
- const query = (search ?? "").replace(/^\?/, "");
1549
- return query === "" ? pathname : `${pathname}?${query}`;
977
+ const query = (search ?? "").replace(/^\?/, "");
978
+ return query === "" ? pathname : `${pathname}?${query}`;
1550
979
  }
1551
980
  /**
1552
- * The canonical form of a query string, for a comparison that ignores the order:
1553
- * each `key=value` pair, sorted, joined with `&`. An empty search gives `""`.
1554
- *
1555
- * Each name AND each value are escaped, because a raw join ALIASES: with "=" and "&"
1556
- * as literal separators, `?a=1&b=2` and `?a=1%26b%3D2` gave one canonical form for two
1557
- * different locations. `isEchoOfLastSync` then read a real navigation as the echo of
1558
- * its own push, so the actor never heard of the move and the URL and the actor state
1559
- * diverged. `mountKey` of `provider-lifecycle.ts` escapes for the same reason.
1560
- *
1561
- * `toSorted()` and not `sort()`: an in-place sort is safe here, because the array is a
1562
- * fresh one of `map()` and the mutation escapes nowhere. It is still the second spelling
1563
- * of one operation. `mountKey` of `provider-lifecycle.ts` sorts this same shared source
1564
- * with `toSorted()`, so this site uses it too.
1565
- *
1566
- * The copy costs nothing that a profile can find. `resolveViewMeta` of play-xstate holds
1567
- * the measurement for the same question.
1568
- */
981
+ * The canonical form of a query string, for a comparison that ignores the order:
982
+ * each `key=value` pair, sorted, joined with `&`. An empty search gives `""`.
983
+ *
984
+ * Each name AND each value are escaped, because a raw join ALIASES: with "=" and "&"
985
+ * as literal separators, `?a=1&b=2` and `?a=1%26b%3D2` gave one canonical form for two
986
+ * different locations. `isEchoOfLastSync` then read a real navigation as the echo of
987
+ * its own push, so the actor never heard of the move and the URL and the actor state
988
+ * diverged. `mountKey` of `provider-lifecycle.ts` escapes for the same reason.
989
+ *
990
+ * `toSorted()` and not `sort()`: an in-place sort is safe here, because the array is a
991
+ * fresh one of `map()` and the mutation escapes nowhere. It is still the second spelling
992
+ * of one operation. `mountKey` of `provider-lifecycle.ts` sorts this same shared source
993
+ * with `toSorted()`, so this site uses it too.
994
+ *
995
+ * The copy costs nothing that a profile can find. `resolveViewMeta` of play-xstate holds
996
+ * the measurement for the same question.
997
+ */
1569
998
  function sortedQuery(search) {
1570
- const pairs = [...new URLSearchParams(search).entries()].map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`);
1571
- return pairs.toSorted().join("&");
999
+ return [...new URLSearchParams(search).entries()].map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`).toSorted().join("&");
1572
1000
  }
1573
- function noopCleanup() { }
1001
+ function noopCleanup() {}
1574
1002
  /**
1575
- * Watches the route atom of the actor.
1576
- *
1577
- * The bridge wraps the read in NOTHING. A subscription evaluates a computed atom inside
1578
- * the `set` of the actor state, so a throw from the derivation would unwind into
1579
- * `actor.send` and break the transition for every other observer — and `currentRoute`
1580
- * therefore contains its own failures, reports them through `onError` of the actor, and
1581
- * keeps its last route. Every subscriber benefits from that rule, and not this bridge
1582
- * alone.
1583
- */
1003
+ * Watches the route atom of the actor.
1004
+ *
1005
+ * The bridge wraps the read in NOTHING. A subscription evaluates a computed atom inside
1006
+ * the `set` of the actor state, so a throw from the derivation would unwind into
1007
+ * `actor.send` and break the transition for every other observer — and `currentRoute`
1008
+ * therefore contains its own failures, reports them through `onError` of the actor, and
1009
+ * keeps its last route. Every subscriber benefits from that rule, and not this bridge
1010
+ * alone.
1011
+ */
1584
1012
  function createRouteWatcher(atom, onRoute) {
1585
- let cleanup = noopCleanup;
1586
- const watcher = {
1587
- watch(nextAtom) {
1588
- // Release the watch that this handle holds already. `RouteWatcherHandle` is a
1589
- // published type, so a bridge of a consumer can repoint the handle at the atom of
1590
- // a new actor — and an assignment alone dropped the reference to the first
1591
- // cleanup, which left the first subscription live for the life of the process.
1592
- watcher.unwatch();
1593
- cleanup = watchAtom(nextAtom, onRoute);
1594
- },
1595
- unwatch() {
1596
- const release = cleanup;
1597
- cleanup = noopCleanup;
1598
- release();
1599
- },
1600
- };
1601
- watcher.watch(atom);
1602
- return watcher;
1013
+ let cleanup = noopCleanup;
1014
+ const watcher = {
1015
+ watch(nextAtom) {
1016
+ watcher.unwatch();
1017
+ cleanup = watchAtom(nextAtom, onRoute);
1018
+ },
1019
+ unwatch() {
1020
+ const release = cleanup;
1021
+ cleanup = noopCleanup;
1022
+ release();
1023
+ }
1024
+ };
1025
+ watcher.watch(atom);
1026
+ return watcher;
1603
1027
  }
1604
1028
  /**
1605
- * True when two machine-side locations are the same place.
1606
- *
1607
- * The order of the keys of a query is not significant, so `?b=2&a=1` and `?a=1&b=2` are
1608
- * one location. The FRAGMENT counts: `navigateRouter` writes the whole value that the
1609
- * actor gave — `buildRouteUrl` appends `context.hash` — so `/docs` and `/docs#intro` are
1610
- * two locations to write, and a test that read them as one left the fragment out of the
1611
- * address bar for ever.
1612
- */
1029
+ * True when two machine-side locations are the same place.
1030
+ *
1031
+ * The order of the keys of a query is not significant, so `?b=2&a=1` and `?a=1&b=2` are
1032
+ * one location. The FRAGMENT counts: `navigateRouter` writes the whole value that the
1033
+ * actor gave — `buildRouteUrl` appends `context.hash` — so `/docs` and `/docs#intro` are
1034
+ * two locations to write, and a test that read them as one left the fragment out of the
1035
+ * address bar for ever.
1036
+ */
1613
1037
  function sameLocation(a, b) {
1614
- const { pathname: aPath, query: aQuery, fragment: aFragment } = splitLocation(a);
1615
- const { pathname: bPath, query: bQuery, fragment: bFragment } = splitLocation(b);
1616
- if (aPath !== bPath)
1617
- return false;
1618
- if (aFragment !== bFragment)
1619
- return false;
1620
- return sortedQuery(aQuery) === sortedQuery(bQuery);
1038
+ const { pathname: aPath, query: aQuery, fragment: aFragment } = splitLocation(a);
1039
+ const { pathname: bPath, query: bQuery, fragment: bFragment } = splitLocation(b);
1040
+ if (aPath !== bPath) return false;
1041
+ if (aFragment !== bFragment) return false;
1042
+ return sortedQuery(aQuery) === sortedQuery(bQuery);
1621
1043
  }
1044
+ //#endregion
1045
+ export { RouterBridgeBase };
1046
+
1622
1047
  //# sourceMappingURL=router-bridge-base.js.map