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