@xmachines/play-router 2.0.0 → 2.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +93 -76
- package/dist/base-route-map.d.ts +63 -57
- package/dist/base-route-map.d.ts.map +1 -1
- package/dist/base-route-map.js +65 -59
- package/dist/base-route-map.js.map +1 -1
- package/dist/build-tree.d.ts +13 -12
- package/dist/build-tree.d.ts.map +1 -1
- package/dist/build-tree.js +30 -28
- package/dist/build-tree.js.map +1 -1
- package/dist/create-route-map-from-tree.d.ts +15 -15
- package/dist/create-route-map-from-tree.js +15 -15
- package/dist/create-route-map.d.ts +18 -16
- package/dist/create-route-map.d.ts.map +1 -1
- package/dist/create-route-map.js +10 -9
- package/dist/create-route-map.js.map +1 -1
- package/dist/errors.d.ts +40 -38
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +40 -38
- package/dist/errors.js.map +1 -1
- package/dist/extract-routes.d.ts +8 -7
- package/dist/extract-routes.d.ts.map +1 -1
- package/dist/extract-routes.js +31 -27
- package/dist/extract-routes.js.map +1 -1
- package/dist/find-route.d.ts +18 -15
- package/dist/find-route.d.ts.map +1 -1
- package/dist/find-route.js +42 -38
- package/dist/find-route.js.map +1 -1
- package/dist/index.d.ts +6 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +11 -10
- package/dist/index.js.map +1 -1
- package/dist/machine-to-graph.d.ts +3 -2
- package/dist/machine-to-graph.d.ts.map +1 -1
- package/dist/machine-to-graph.js +20 -19
- package/dist/machine-to-graph.js.map +1 -1
- package/dist/query.d.ts +39 -37
- package/dist/query.d.ts.map +1 -1
- package/dist/query.js +62 -57
- package/dist/query.js.map +1 -1
- package/dist/router-bridge-base.d.ts +240 -190
- package/dist/router-bridge-base.d.ts.map +1 -1
- package/dist/router-bridge-base.js +350 -228
- package/dist/router-bridge-base.js.map +1 -1
- package/dist/router-sync.d.ts +41 -35
- package/dist/router-sync.d.ts.map +1 -1
- package/dist/router-sync.js +53 -45
- package/dist/router-sync.js.map +1 -1
- package/dist/types.d.ts +165 -147
- package/dist/types.d.ts.map +1 -1
- package/dist/url-pattern-utils.d.ts +53 -47
- package/dist/url-pattern-utils.d.ts.map +1 -1
- package/dist/url-pattern-utils.js +61 -55
- package/dist/url-pattern-utils.js.map +1 -1
- package/dist/validate-routes.d.ts +32 -31
- package/dist/validate-routes.d.ts.map +1 -1
- package/dist/validate-routes.js +30 -29
- package/dist/validate-routes.js.map +1 -1
- package/package.json +6 -5
|
@@ -1,17 +1,18 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* RouterBridgeBase —
|
|
2
|
+
* RouterBridgeBase — the abstract base class of every framework router adapter
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* -
|
|
7
|
-
* - `
|
|
8
|
-
* - `
|
|
9
|
-
* -
|
|
4
|
+
* The class holds the logic that each router bridge shares, and that is 90% of the
|
|
5
|
+
* code of every bridge:
|
|
6
|
+
* - The TC39 Signal watcher of the direction from the actor to the router
|
|
7
|
+
* - `lastSyncedPath`, for the echo suppression in the direction from the actor to the router
|
|
8
|
+
* - The `isProcessingNavigation` flag, which stops a loop of a guard redirect in `syncActorFromRouter` only
|
|
9
|
+
* - `syncRouterFromActor` and `syncActorFromRouter`, with the read of each URL parameter
|
|
10
|
+
* - The connect and disconnect lifecycle of the RouterBridge protocol
|
|
10
11
|
*
|
|
11
|
-
*
|
|
12
|
-
* - navigateRouter(path):
|
|
13
|
-
* - watchRouterChanges():
|
|
14
|
-
* - unwatchRouterChanges():
|
|
12
|
+
* A subclass implements the 3 abstract methods of its framework only:
|
|
13
|
+
* - navigateRouter(path): how the bridge tells the framework router to navigate
|
|
14
|
+
* - watchRouterChanges(): how the bridge subscribes to each location change of the router
|
|
15
|
+
* - unwatchRouterChanges(): how the bridge cancels that subscription
|
|
15
16
|
*
|
|
16
17
|
* @example
|
|
17
18
|
* ```typescript
|
|
@@ -39,7 +40,7 @@
|
|
|
39
40
|
* }
|
|
40
41
|
* ```
|
|
41
42
|
*
|
|
42
|
-
* @see [Play RFC](../../docs/rfc/play.md) -
|
|
43
|
+
* @see [Play RFC](../../docs/rfc/play.md) - invariant INV-04
|
|
43
44
|
*/
|
|
44
45
|
import { Signal, watchSignal } from "@xmachines/play-signals";
|
|
45
46
|
import { DuplicateBridgeError, RouterSyncError } from "./errors.js";
|
|
@@ -47,79 +48,86 @@ import { buildPlayRouteEvent, extractQuery, extractRouteParams, matchRouteMap, s
|
|
|
47
48
|
import { URLPatternUnavailableError } from "./errors.js";
|
|
48
49
|
import { isParameterizedPattern } from "./url-pattern-utils.js";
|
|
49
50
|
/**
|
|
50
|
-
*
|
|
51
|
+
* The registry of the actors with an active bridge connection, at the module level.
|
|
51
52
|
*
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
* `play.route` events
|
|
53
|
+
* The key is the actor instance, and the registry is a WeakMap, which holds no
|
|
54
|
+
* reference. The class uses it to find and refuse a second `connect()` call of the
|
|
55
|
+
* same actor. Such a call makes two `play.route` events for one navigation, and it
|
|
56
|
+
* makes two `lastSyncedPath` values of the echo suppression that contradict each
|
|
57
|
+
* other.
|
|
55
58
|
*
|
|
56
59
|
* @internal
|
|
57
60
|
*/
|
|
58
61
|
const activeBridges = new WeakMap();
|
|
59
62
|
/**
|
|
60
|
-
*
|
|
63
|
+
* The abstract base class of every router adapter bridge of `@xmachines`.
|
|
61
64
|
*
|
|
62
|
-
*
|
|
63
|
-
*
|
|
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.
|
|
65
68
|
*/
|
|
66
69
|
export class RouterBridgeBase {
|
|
67
70
|
actor;
|
|
68
71
|
routeMap;
|
|
69
|
-
// ──
|
|
72
|
+
// ── The common state. It is identical in each of the 4 bridges ──
|
|
70
73
|
isConnected = false;
|
|
71
74
|
hasConnectedOnce = false;
|
|
72
75
|
lastSyncedPath = null;
|
|
73
76
|
/**
|
|
74
|
-
*
|
|
75
|
-
* actor
|
|
76
|
-
*
|
|
77
|
-
*
|
|
78
|
-
*
|
|
79
|
-
*
|
|
80
|
-
*
|
|
81
|
-
* `
|
|
77
|
+
* The flag guards `syncActorFromRouter` against a re-entrant call from a guard
|
|
78
|
+
* redirect of the actor itself. Such a call has this sequence: the bridge sends to
|
|
79
|
+
* the actor, the signal fires, the bridge pushes to the router, and a second
|
|
80
|
+
* `syncActorFromRouter` call starts before the first one returns.
|
|
81
|
+
*
|
|
82
|
+
* The flag is NOT the echo suppression of the direction from the actor to the
|
|
83
|
+
* router. `lastSyncedPath` does that work alone: the bridge writes it before the
|
|
84
|
+
* `navigateRouter()` call. Therefore each router callback of the same location stops
|
|
85
|
+
* at the {@link isEchoOfLastSync} test in `syncActorFromRouter`.
|
|
82
86
|
*/
|
|
83
87
|
isProcessingNavigation = false;
|
|
84
88
|
routeWatcher = null;
|
|
85
89
|
/**
|
|
86
|
-
* @param actor - A `RoutableActor
|
|
87
|
-
* @param routeMap -
|
|
88
|
-
*
|
|
89
|
-
*
|
|
90
|
-
*
|
|
91
|
-
* `"stateId"
|
|
92
|
-
*
|
|
90
|
+
* @param actor - A `RoutableActor`, with `currentRoute`, `initialRoute`, and `send`.
|
|
91
|
+
* @param routeMap - The route map of both directions, for the resolution between a
|
|
92
|
+
* `stateId` and a `path`. Give `getStateIdByPath` and `getPathByStateId`. A
|
|
93
|
+
* framework adapter usually wraps the result of `createRouteMap(machine)`, or an
|
|
94
|
+
* equivalent value. The key of `getPathByStateId` is the form `"#stateId"` or the
|
|
95
|
+
* bare form `"stateId"`. The bridge tries both forms. Therefore an implementation
|
|
96
|
+
* of your own, for example a plain test object, handles one form only.
|
|
93
97
|
*/
|
|
94
98
|
constructor(actor, routeMap) {
|
|
95
99
|
this.actor = actor;
|
|
96
100
|
this.routeMap = routeMap;
|
|
97
|
-
//
|
|
98
|
-
// null means "nothing
|
|
101
|
+
// Set lastSyncedPath to the current route of the actor. This stops a loop in the
|
|
102
|
+
// first synchronization. The value null means "nothing is in step yet", and it is
|
|
103
|
+
// therefore different from each real path string.
|
|
99
104
|
this.lastSyncedPath = this.actor.currentRoute.get() ?? null;
|
|
100
105
|
}
|
|
101
|
-
// ── RouterBridge protocol
|
|
106
|
+
// ── The RouterBridge protocol. It is final: a subclass must override nothing here ──
|
|
102
107
|
/**
|
|
103
|
-
*
|
|
108
|
+
* Connects the router bridge to the Actor.
|
|
104
109
|
*
|
|
105
|
-
*
|
|
106
|
-
* starts
|
|
110
|
+
* The method installs the TC39 Signal watcher of the direction from the actor to the
|
|
111
|
+
* router. It then starts the watch of the router changes, which each framework does
|
|
112
|
+
* in its own way.
|
|
107
113
|
*
|
|
108
|
-
*
|
|
109
|
-
* - `lastSyncedPath`
|
|
110
|
-
* - the actor watcher
|
|
111
|
-
* -
|
|
114
|
+
* The order of these steps is part of the contract of the bridge:
|
|
115
|
+
* - The constructor seeds `lastSyncedPath` from `actor.currentRoute`
|
|
116
|
+
* - The method installs the actor watcher before the router subscriptions of the adapter
|
|
117
|
+
* - The first synchronization then separates a deep link from a restore, with `actor.initialRoute`
|
|
112
118
|
*
|
|
113
|
-
*
|
|
114
|
-
* `getInitialRouterPath()
|
|
119
|
+
* An adapter that needs a different behavior of the first synchronization overrides
|
|
120
|
+
* `getInitialRouterPath()`. It does not change the order of the steps of
|
|
121
|
+
* `connect()`.
|
|
115
122
|
*/
|
|
116
123
|
connect() {
|
|
117
124
|
if (this.isConnected) {
|
|
118
125
|
return;
|
|
119
126
|
}
|
|
120
|
-
//
|
|
121
|
-
//
|
|
122
|
-
// events and
|
|
127
|
+
// Permit one bridge for each actor: two connected bridges of the same actor both
|
|
128
|
+
// receive every navigation event of the router. They therefore send two play.route
|
|
129
|
+
// events, and their lastSyncedPath values of the echo suppression contradict each
|
|
130
|
+
// other.
|
|
123
131
|
const existingBridge = activeBridges.get(this.actor);
|
|
124
132
|
if (existingBridge !== undefined && existingBridge !== this) {
|
|
125
133
|
throw new DuplicateBridgeError();
|
|
@@ -127,41 +135,42 @@ export class RouterBridgeBase {
|
|
|
127
135
|
activeBridges.set(this.actor, this);
|
|
128
136
|
this.isConnected = true;
|
|
129
137
|
this.hasConnectedOnce = true;
|
|
130
|
-
//
|
|
138
|
+
// Install the TC39 Signal watcher of the direction from the actor to the router
|
|
131
139
|
this.routeWatcher = createRouteWatcher(this.actor.currentRoute, (route) => {
|
|
132
140
|
this.syncRouterFromActor(route);
|
|
133
141
|
});
|
|
134
|
-
// Start
|
|
142
|
+
// Start the watch of the router changes. Each framework does this in its own way
|
|
135
143
|
this.watchRouterChanges();
|
|
136
|
-
//
|
|
144
|
+
// The first synchronization: the direction from the router to the actor has the
|
|
145
|
+
// priority over the other direction.
|
|
137
146
|
//
|
|
138
|
-
//
|
|
139
|
-
// user
|
|
140
|
-
//
|
|
141
|
-
//
|
|
142
|
-
// actor
|
|
147
|
+
// The page can load on a URL that is not the initial state of the actor. For
|
|
148
|
+
// example, the user types "/about", or the user follows a deep link. The router
|
|
149
|
+
// holds the correct path then, and the actor is still at its initial state ("/").
|
|
150
|
+
// The code must therefore drive the actor to the URL, and it must not write the
|
|
151
|
+
// default of the actor to the URL.
|
|
143
152
|
//
|
|
144
|
-
// router.subscribe()
|
|
145
|
-
//
|
|
146
|
-
//
|
|
153
|
+
// router.subscribe() fires on a *later* navigation event only. It does NOT fire for
|
|
154
|
+
// the location that the page loaded. A subclass that can read the current location
|
|
155
|
+
// of its router synchronously overrides getInitialRouterPath().
|
|
147
156
|
const initialRouterPath = this.getInitialRouterPath();
|
|
148
157
|
const initialRouterSearch = this.getInitialRouterSearch();
|
|
149
158
|
const initialActorRoute = this.actor.currentRoute.get();
|
|
150
159
|
if (typeof initialRouterPath === "string") {
|
|
151
|
-
//
|
|
152
|
-
//
|
|
153
|
-
// is a URL path
|
|
154
|
-
// to
|
|
160
|
+
// Test if the actor is at the current location of the router already.
|
|
161
|
+
// The currentRoute of the actor can be a stateId, for example "#app.home", and
|
|
162
|
+
// initialRouterPath is a URL path, for example "/home". Therefore resolve the path
|
|
163
|
+
// to a stateId before the comparison. Without this step, a false "the two are
|
|
164
|
+
// different" starts a first synchronization that nothing needs.
|
|
155
165
|
const resolvedStateId = this.routeMap.getStateIdByPath(sanitizePathname(initialRouterPath) ?? initialRouterPath);
|
|
156
|
-
// The actor is
|
|
157
|
-
// directly, or
|
|
158
|
-
//
|
|
159
|
-
//
|
|
160
|
-
//
|
|
161
|
-
//
|
|
162
|
-
//
|
|
163
|
-
//
|
|
164
|
-
// path only.
|
|
166
|
+
// The actor is at the location of the router in two cases: the two values match
|
|
167
|
+
// directly, or the actor route is a stateId that resolves to the same registered
|
|
168
|
+
// path as the stateId of the match. Each lookup goes through lookupPathByStateId,
|
|
169
|
+
// which tries the form "#stateId" and also the bare form "stateId". Therefore a
|
|
170
|
+
// route map with "#about" recognizes an actor route of "about", and the opposite
|
|
171
|
+
// also works, and this is correct for a structural map of your own that holds one
|
|
172
|
+
// form. An actor route with a concrete path, which starts with "/", goes to a direct
|
|
173
|
+
// comparison with the router path only.
|
|
165
174
|
const actorAlreadyAtRouterLocation = resolvedStateId !== null &&
|
|
166
175
|
resolvedStateId !== undefined &&
|
|
167
176
|
(resolvedStateId === initialActorRoute ||
|
|
@@ -172,17 +181,19 @@ export class RouterBridgeBase {
|
|
|
172
181
|
this.lookupPathByStateId(initialActorRoute) ===
|
|
173
182
|
this.lookupPathByStateId(resolvedStateId)));
|
|
174
183
|
if (!actorAlreadyAtRouterLocation) {
|
|
175
|
-
//
|
|
184
|
+
// The path of the router is different from the actor route. Is this a deep link, or
|
|
185
|
+
// is it a restore?
|
|
176
186
|
//
|
|
177
|
-
//
|
|
178
|
-
//
|
|
187
|
+
// A deep link: the router is at a URL that is not the initial one, and the actor saw
|
|
188
|
+
// that URL not yet.
|
|
189
|
+
// → the router wins: syncActorFromRouter. The guards then decide the access.
|
|
179
190
|
//
|
|
180
|
-
//
|
|
181
|
-
//
|
|
182
|
-
// → actor wins: push
|
|
191
|
+
// A restore: the browser is at the initial URL of the machine, and a snapshot
|
|
192
|
+
// restored the actor to a different route.
|
|
193
|
+
// → the actor wins: push the restored route of the actor to the router.
|
|
183
194
|
//
|
|
184
|
-
//
|
|
185
|
-
// actor is at a different route
|
|
195
|
+
// The detection: the URL of the router is the initial route of the machine, AND the
|
|
196
|
+
// actor is at a different route. This is then a restore.
|
|
186
197
|
if (initialActorRoute &&
|
|
187
198
|
initialRouterPath === this.actor.initialRoute &&
|
|
188
199
|
initialActorRoute !== this.actor.initialRoute) {
|
|
@@ -194,19 +205,22 @@ export class RouterBridgeBase {
|
|
|
194
205
|
}
|
|
195
206
|
}
|
|
196
207
|
else if (initialActorRoute && initialRouterPath === null) {
|
|
197
|
-
//
|
|
198
|
-
//
|
|
208
|
+
// An explicit null: no path of the router is available, from a restore or from a
|
|
209
|
+
// bootstrap → push the actor route.
|
|
210
|
+
// The value undefined means that the adapter does the first synchronization itself.
|
|
211
|
+
// Continue.
|
|
199
212
|
this.pushResolvedRoute(initialActorRoute);
|
|
200
213
|
}
|
|
201
214
|
else if (initialActorRoute && initialActorRoute !== this.lastSyncedPath) {
|
|
202
|
-
// No router
|
|
215
|
+
// No path of the router replaces the actor route → write the router from the actor, as before
|
|
203
216
|
this.syncRouterFromActor(initialActorRoute);
|
|
204
217
|
}
|
|
205
218
|
}
|
|
206
219
|
/**
|
|
207
|
-
*
|
|
220
|
+
* Disconnects the router bridge from the Actor.
|
|
208
221
|
*
|
|
209
|
-
*
|
|
222
|
+
* The method stops the watch of the signal, and it removes the router listener of the
|
|
223
|
+
* framework.
|
|
210
224
|
*/
|
|
211
225
|
disconnect() {
|
|
212
226
|
const hadRouteWatcher = this.routeWatcher !== null;
|
|
@@ -215,7 +229,7 @@ export class RouterBridgeBase {
|
|
|
215
229
|
this.routeWatcher.unwatch();
|
|
216
230
|
}
|
|
217
231
|
catch {
|
|
218
|
-
// Ignore detached watcher
|
|
232
|
+
// Ignore an error of a detached watcher. disconnect() therefore stays idempotent.
|
|
219
233
|
}
|
|
220
234
|
}
|
|
221
235
|
this.routeWatcher = null;
|
|
@@ -224,55 +238,79 @@ export class RouterBridgeBase {
|
|
|
224
238
|
}
|
|
225
239
|
this.isProcessingNavigation = false;
|
|
226
240
|
this.isConnected = false;
|
|
227
|
-
//
|
|
228
|
-
//
|
|
229
|
-
//
|
|
241
|
+
// Free the slot of the actor, so that a new bridge can connect to the same actor.
|
|
242
|
+
// Clear the slot only when this bridge is the registered one: a second bridge that
|
|
243
|
+
// connect() refused must not remove the legitimate bridge.
|
|
230
244
|
if (activeBridges.get(this.actor) === this) {
|
|
231
245
|
activeBridges.delete(this.actor);
|
|
232
246
|
}
|
|
233
247
|
}
|
|
234
|
-
// ──
|
|
248
|
+
// ── The sync methods. They are protected, and a subclass overrides one for its own behavior ──
|
|
235
249
|
/**
|
|
236
|
-
*
|
|
237
|
-
*
|
|
238
|
-
*
|
|
239
|
-
* for
|
|
240
|
-
*
|
|
241
|
-
*
|
|
242
|
-
* or pattern.
|
|
243
|
-
*
|
|
244
|
-
*
|
|
245
|
-
*
|
|
246
|
-
*
|
|
247
|
-
*
|
|
248
|
-
* `sanitized === lastSyncedPath`
|
|
249
|
-
*
|
|
250
|
-
*
|
|
251
|
-
*
|
|
252
|
-
* `syncActorFromRouter
|
|
250
|
+
* Writes the location of the router when the route signal of the actor changes.
|
|
251
|
+
*
|
|
252
|
+
* The method resolves the actor route to a concrete URL path, then it calls
|
|
253
|
+
* navigateRouter() for the navigation of the framework. When it cannot resolve the
|
|
254
|
+
* route, which happens for an unknown stateId and for a parameterized pattern
|
|
255
|
+
* without concrete values, it skips the push completely: `navigateRouter` receives a
|
|
256
|
+
* raw stateId or a pattern never.
|
|
257
|
+
*
|
|
258
|
+
* `lastSyncedPath` does the complete echo suppression, which stops the callback of
|
|
259
|
+
* the router from a send to the actor: the method writes the resolved path to
|
|
260
|
+
* `lastSyncedPath` before the `navigateRouter()` call. Therefore each
|
|
261
|
+
* `syncActorFromRouter` call of the same path stops at the
|
|
262
|
+
* `sanitized === lastSyncedPath` test, and it sends no event. This is correct for a
|
|
263
|
+
* synchronous callback and for an asynchronous callback.
|
|
264
|
+
*
|
|
265
|
+
* The method does NOT set `isProcessingNavigation`. That flag lives inside
|
|
266
|
+
* `syncActorFromRouter`, and it guards against a re-entrant loop of a guard
|
|
267
|
+
* redirect.
|
|
253
268
|
*/
|
|
254
269
|
syncRouterFromActor(route) {
|
|
255
270
|
if (this.hasConnectedOnce && !this.isConnected)
|
|
256
271
|
return;
|
|
257
272
|
if (!route || typeof route !== "string")
|
|
258
273
|
return;
|
|
259
|
-
if (
|
|
274
|
+
if (this.isEchoOfLastPush(route))
|
|
260
275
|
return;
|
|
261
276
|
this.pushResolvedRoute(route);
|
|
262
277
|
}
|
|
263
278
|
/**
|
|
264
|
-
*
|
|
265
|
-
*
|
|
266
|
-
*
|
|
267
|
-
*
|
|
268
|
-
*
|
|
269
|
-
*
|
|
270
|
-
*
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
279
|
+
* Tests a route of the actor against the last synchronized location.
|
|
280
|
+
*
|
|
281
|
+
* The exact string test covers a stateId, for example `"#home"`, which
|
|
282
|
+
* `pushResolvedRoute` writes to the field when it cannot resolve a path. The query
|
|
283
|
+
* test covers a location that carries a query: the order of the keys of a query is
|
|
284
|
+
* not significant, so `?b=2&a=1` and `?a=1&b=2` are the same location, and the
|
|
285
|
+
* bridge must push neither of them back.
|
|
286
|
+
*/
|
|
287
|
+
isEchoOfLastPush(route) {
|
|
288
|
+
const last = this.lastSyncedPath;
|
|
289
|
+
if (last === null)
|
|
290
|
+
return false;
|
|
291
|
+
if (route === last)
|
|
292
|
+
return true;
|
|
293
|
+
const [routePath, routeQuery] = splitLocation(route);
|
|
294
|
+
const [lastPath, lastQuery] = splitLocation(last);
|
|
295
|
+
if (routePath !== lastPath)
|
|
296
|
+
return false;
|
|
297
|
+
return sortedQuery(routeQuery) === sortedQuery(lastQuery);
|
|
298
|
+
}
|
|
299
|
+
/**
|
|
300
|
+
* Resolves an actor route to its concrete URL path, and pushes that path to the
|
|
301
|
+
* router.
|
|
302
|
+
*
|
|
303
|
+
* lastSyncedPath must hold the concrete path, because the callback of the router
|
|
304
|
+
* fires with the concrete path. The two values therefore match, and
|
|
305
|
+
* syncActorFromRouter stops. A raw stateId in that field, for example "#home", does
|
|
306
|
+
* not match the sanitized path ("/") in the watcher.
|
|
307
|
+
*
|
|
308
|
+
* When resolveNavigationPath returns null, which happens for a parameterized
|
|
309
|
+
* pattern, for a wildcard pattern, and for an unknown id, the method skips the push
|
|
310
|
+
* completely: a push of the raw value writes a stateId, a `:param` pattern, or a
|
|
311
|
+
* literal `*` into the browser URL. The method still writes the raw route to
|
|
312
|
+
* lastSyncedPath. Therefore the dedup guard fires correctly on the next identical
|
|
313
|
+
* value of the signal.
|
|
276
314
|
*/
|
|
277
315
|
pushResolvedRoute(route) {
|
|
278
316
|
const resolved = this.resolveNavigationPath(route);
|
|
@@ -282,20 +320,20 @@ export class RouterBridgeBase {
|
|
|
282
320
|
this.navigateRouter(resolved);
|
|
283
321
|
}
|
|
284
322
|
/**
|
|
285
|
-
*
|
|
286
|
-
*
|
|
287
|
-
* **
|
|
288
|
-
* stateId, params, and query.
|
|
289
|
-
*
|
|
290
|
-
*
|
|
291
|
-
* **
|
|
292
|
-
*
|
|
293
|
-
* `navigateRouter(actor.currentRoute.get())`
|
|
294
|
-
* actor state
|
|
295
|
-
* or pushes
|
|
296
|
-
* resolved concrete path
|
|
297
|
-
* callback for that navigation
|
|
298
|
-
* sends no
|
|
323
|
+
* Writes the actor state when the location of the router changes.
|
|
324
|
+
*
|
|
325
|
+
* **A known path:** the method sends a `play.route` event to the actor, with the
|
|
326
|
+
* stateId of the match, the params, and the query. The `isProcessingNavigation` flag
|
|
327
|
+
* stops a circular update.
|
|
328
|
+
*
|
|
329
|
+
* **An unknown path, or a path with no entry in the map:** the method sends NO
|
|
330
|
+
* `play.route` event, and the actor state stays as it is. It corrects the browser URL
|
|
331
|
+
* instead, with a `navigateRouter(actor.currentRoute.get())` call. The URL therefore
|
|
332
|
+
* follows the actor state, also when the user types an invalid path in the address
|
|
333
|
+
* bar, or when the code pushes such a path during a session. The method writes the
|
|
334
|
+
* resolved concrete path to `lastSyncedPath` before the `navigateRouter` call.
|
|
335
|
+
* Therefore the callback of the router for that navigation stops at the guard of the
|
|
336
|
+
* echo suppression, and it sends no false event.
|
|
299
337
|
*/
|
|
300
338
|
syncActorFromRouter(pathname, search) {
|
|
301
339
|
if (this.hasConnectedOnce && !this.isConnected)
|
|
@@ -304,8 +342,8 @@ export class RouterBridgeBase {
|
|
|
304
342
|
return;
|
|
305
343
|
const sanitized = sanitizePathname(pathname);
|
|
306
344
|
if (sanitized === null)
|
|
307
|
-
return; //
|
|
308
|
-
if (sanitized
|
|
345
|
+
return; // The path is too long. Refuse it
|
|
346
|
+
if (this.isEchoOfLastSync(sanitized, search))
|
|
309
347
|
return;
|
|
310
348
|
if (this.isProcessingNavigation)
|
|
311
349
|
return;
|
|
@@ -317,33 +355,49 @@ export class RouterBridgeBase {
|
|
|
317
355
|
match: (nextPathname) => matchRouteMap(nextPathname, this.routeMap, (resolvedPathname, stateId) => this.extractParams(resolvedPathname, stateId)),
|
|
318
356
|
});
|
|
319
357
|
if (!nextRoute) {
|
|
320
|
-
//
|
|
321
|
-
// Push the
|
|
322
|
-
//
|
|
323
|
-
//
|
|
358
|
+
// The path is unknown, because the map holds no route of it.
|
|
359
|
+
// Push the current route of the actor back to the router. The browser URL therefore
|
|
360
|
+
// follows the actor state, and this corrects an invalid entry in the address bar on a
|
|
361
|
+
// cold load, and also a navigation to a path that is absent during a session.
|
|
324
362
|
//
|
|
325
|
-
//
|
|
326
|
-
//
|
|
327
|
-
//
|
|
328
|
-
//
|
|
329
|
-
//
|
|
330
|
-
// guard and
|
|
363
|
+
// The code calls pushResolvedRoute() directly, and not syncRouterFromActor(). It
|
|
364
|
+
// therefore passes around the dedup guard, because lastSyncedPath holds the route of
|
|
365
|
+
// the actor already. pushResolvedRoute sets lastSyncedPath to the resolved concrete
|
|
366
|
+
// path before the push. Therefore the callback of the router for this navigation, a
|
|
367
|
+
// hashchange event or a history event, stops at the
|
|
368
|
+
// sanitized === lastSyncedPath guard, and it sends no false play.route event to the
|
|
369
|
+
// actor.
|
|
331
370
|
//
|
|
332
|
-
// isProcessingNavigation
|
|
333
|
-
//
|
|
334
|
-
// re-
|
|
371
|
+
// isProcessingNavigation stays true here. The finally block below clears it after
|
|
372
|
+
// navigateRouter returns. That block therefore guards synchronously against a
|
|
373
|
+
// re-entrance of each router callback that fires at once.
|
|
335
374
|
const currentActorRoute = this.actor.currentRoute.get();
|
|
336
375
|
if (currentActorRoute) {
|
|
337
376
|
this.pushResolvedRoute(currentActorRoute);
|
|
338
377
|
}
|
|
339
378
|
return;
|
|
340
379
|
}
|
|
380
|
+
// Record the location BEFORE the send, and record it with its query.
|
|
381
|
+
//
|
|
382
|
+
// The route signal of the actor notifies the watcher of the other direction
|
|
383
|
+
// for this same move, and `syncRouterFromActor` then tests the route of the
|
|
384
|
+
// actor against this field. Two details decide that test:
|
|
385
|
+
//
|
|
386
|
+
// - The write must happen first. A write after the send leaves the previous
|
|
387
|
+
// path in the field while the watcher runs, so the test misses and the
|
|
388
|
+
// bridge pushes the location that the router already holds. On a press on
|
|
389
|
+
// BACK that push is a new history entry, and it replaces the entry that
|
|
390
|
+
// FORWARD returns to.
|
|
391
|
+
// - The write must carry the query. A machine that republishes the query
|
|
392
|
+
// holds `/second?id=1` as its route, and a pathname alone in this field
|
|
393
|
+
// never matches it.
|
|
394
|
+
this.lastSyncedPath = withSearch(nextRoute.pathname, search);
|
|
341
395
|
this.actor.send(nextRoute.event);
|
|
342
|
-
this.lastSyncedPath = nextRoute.pathname;
|
|
343
396
|
}
|
|
344
397
|
catch (error) {
|
|
345
|
-
// URLPatternUnavailableError
|
|
346
|
-
//
|
|
398
|
+
// A URLPatternUnavailableError needs an action of the user: it goes to the caller
|
|
399
|
+
// without a change. The caller can therefore find it, and ask the user for a
|
|
400
|
+
// URLPattern polyfill.
|
|
347
401
|
if (error instanceof URLPatternUnavailableError) {
|
|
348
402
|
throw error;
|
|
349
403
|
}
|
|
@@ -355,19 +409,20 @@ export class RouterBridgeBase {
|
|
|
355
409
|
this.isProcessingNavigation = false;
|
|
356
410
|
}
|
|
357
411
|
}
|
|
358
|
-
// ──
|
|
412
|
+
// ── The utilities. They are protected, and a subclass overrides one to read the params of its framework ──
|
|
359
413
|
/**
|
|
360
|
-
*
|
|
414
|
+
* Reads the path parameters of a URL, with the URLPattern API.
|
|
361
415
|
*
|
|
362
|
-
*
|
|
363
|
-
*
|
|
364
|
-
*
|
|
365
|
-
*
|
|
416
|
+
* The method reads `globalThis.URLPattern` at run time, because this library imports
|
|
417
|
+
* no polyfill. When `URLPattern` is absent and the route of the match holds a
|
|
418
|
+
* parameterized segment, the method throws a `URLPatternUnavailableError`. A caller
|
|
419
|
+
* must therefore give a polyfill in an environment without the native URLPattern:
|
|
420
|
+
* Node.js < 24, and an older browser.
|
|
366
421
|
*
|
|
367
|
-
* @param pathname - The
|
|
368
|
-
* @param stateId - The
|
|
369
|
-
* @returns
|
|
370
|
-
* @throws {URLPatternUnavailableError} When URLPattern is absent and the route
|
|
422
|
+
* @param pathname - The real URL path, for example '/profile/john'
|
|
423
|
+
* @param stateId - The stateId of the match, for the lookup of the route pattern
|
|
424
|
+
* @returns The path parameters of the read, or an empty object when nothing matches
|
|
425
|
+
* @throws {URLPatternUnavailableError} When URLPattern is absent and the route holds a parameter
|
|
371
426
|
*/
|
|
372
427
|
extractParams(pathname, stateId) {
|
|
373
428
|
const pattern = this.routeMap.getPathByStateId(stateId);
|
|
@@ -376,22 +431,22 @@ export class RouterBridgeBase {
|
|
|
376
431
|
return extractRouteParams(pathname, pattern);
|
|
377
432
|
}
|
|
378
433
|
/**
|
|
379
|
-
*
|
|
380
|
-
*
|
|
381
|
-
*
|
|
382
|
-
*
|
|
383
|
-
*
|
|
384
|
-
* - unknown stateId with no route map
|
|
385
|
-
* - parameterized or wildcard pattern
|
|
386
|
-
* concrete
|
|
387
|
-
* -
|
|
388
|
-
*
|
|
389
|
-
*
|
|
390
|
-
*
|
|
391
|
-
* only
|
|
392
|
-
*
|
|
393
|
-
* @param route -
|
|
394
|
-
* @returns
|
|
434
|
+
* Resolves a value of an actor route to a concrete URL path for a navigation.
|
|
435
|
+
*
|
|
436
|
+
* A bridge that receives a raw `actor.currentRoute` value in `navigateRouter` calls
|
|
437
|
+
* this method to convert a stateId, with a `#` prefix or without one, into a path.
|
|
438
|
+
* The method returns `null` when a navigation is not possible:
|
|
439
|
+
* - an unknown stateId, with no entry in the route map
|
|
440
|
+
* - a parameterized pattern or a wildcard pattern, for example `/profile/:id` or
|
|
441
|
+
* `/docs/*`, because no concrete value is available for a real URL
|
|
442
|
+
* - a string that is no path and no known stateId
|
|
443
|
+
*
|
|
444
|
+
* A lookup of a stateId tries the form `"#stateId"` and the bare form `"stateId"`,
|
|
445
|
+
* through `lookupPathByStateId`. Therefore a route map of your own that holds one
|
|
446
|
+
* form only still resolves the path.
|
|
447
|
+
*
|
|
448
|
+
* @param route - The raw value of the actor route: a stateId, a stateId with a `#`, or a concrete path
|
|
449
|
+
* @returns The concrete URL path, or `null` when the bridge must skip the navigation
|
|
395
450
|
*/
|
|
396
451
|
resolveNavigationPath(route) {
|
|
397
452
|
const mapped = this.lookupPathByStateId(route);
|
|
@@ -401,17 +456,19 @@ export class RouterBridgeBase {
|
|
|
401
456
|
return path;
|
|
402
457
|
}
|
|
403
458
|
/**
|
|
404
|
-
*
|
|
459
|
+
* Returns the path of the route map for a value of an actor route, and it tries both
|
|
460
|
+
* forms of a stateId.
|
|
405
461
|
*
|
|
406
|
-
* The actor
|
|
407
|
-
* `"stateId"
|
|
408
|
-
* constructor accepts
|
|
409
|
-
* object
|
|
410
|
-
*
|
|
411
|
-
* form, then the
|
|
462
|
+
* The actor can emit `"#stateId"` while a route map of your own holds the bare form
|
|
463
|
+
* `"stateId"`, and also the opposite. `RouteMap` makes the canonical form
|
|
464
|
+
* internally, but the constructor accepts every structural
|
|
465
|
+
* `{ getStateIdByPath, getPathByStateId }` object: a plain test object and a map of
|
|
466
|
+
* one adapter are legal, and the documentation says so. Therefore this method tries
|
|
467
|
+
* both forms: the raw form first, then the form without the `#`, then the form with
|
|
468
|
+
* the `#`.
|
|
412
469
|
*
|
|
413
|
-
* @param route -
|
|
414
|
-
* @returns The
|
|
470
|
+
* @param route - The raw value of the actor route: a stateId, with a `#` prefix or without one
|
|
471
|
+
* @returns The path of the map, or `null` or `undefined` when no form is registered
|
|
415
472
|
*/
|
|
416
473
|
lookupPathByStateId(route) {
|
|
417
474
|
const direct = this.routeMap.getPathByStateId(route);
|
|
@@ -421,71 +478,136 @@ export class RouterBridgeBase {
|
|
|
421
478
|
return this.routeMap.getPathByStateId(alternate);
|
|
422
479
|
}
|
|
423
480
|
/**
|
|
424
|
-
*
|
|
481
|
+
* Reads the query parameters of a URL search string.
|
|
425
482
|
*
|
|
426
|
-
* @param search - URL search string
|
|
427
|
-
* @returns
|
|
483
|
+
* @param search - The URL search string, for example '?tab=security&page=1'
|
|
484
|
+
* @returns The query parameters of the read, or an empty object
|
|
428
485
|
*/
|
|
429
486
|
extractQuery(search) {
|
|
430
487
|
return extractQuery(search);
|
|
431
488
|
}
|
|
432
489
|
/**
|
|
433
|
-
*
|
|
490
|
+
* True when a location of the router is the location that this bridge pushed.
|
|
434
491
|
*
|
|
435
|
-
*
|
|
436
|
-
*
|
|
437
|
-
*
|
|
492
|
+
* `lastSyncedPath` holds the value of `actor.currentRoute`, and that value carries
|
|
493
|
+
* the query string of `context.query`, for example `"/dates?trip=one-way"`. A router
|
|
494
|
+
* callback reports the pathname and the search SEPARATELY, and `sanitizePathname`
|
|
495
|
+
* removes the query. A comparison of the pathname alone therefore misses for every
|
|
496
|
+
* route with a query, and the push of this bridge comes back as a `play.route` event
|
|
497
|
+
* of its own navigation. That echo is not only wasted work: it writes to the signal
|
|
498
|
+
* graph from inside the watcher callback of the actor route, and the watcher then
|
|
499
|
+
* arms itself again on a computed that is dirty already, which ends the direction
|
|
500
|
+
* from the actor to the router for the rest of the session.
|
|
438
501
|
*
|
|
439
|
-
*
|
|
440
|
-
*
|
|
441
|
-
*
|
|
442
|
-
* `syncActorFromRouter()` calls this internally, so bridges that delegate
|
|
443
|
-
* to it do not need to call `sanitizePath` themselves.
|
|
502
|
+
* The query is compared as a MAP, and not as a string: a framework can re-serialize
|
|
503
|
+
* a search string in another order, and `URLSearchParams` also accepts the form with
|
|
504
|
+
* a leading `?` and the form without one.
|
|
444
505
|
*
|
|
445
|
-
* @param
|
|
446
|
-
* @
|
|
506
|
+
* @param sanitized - The clean pathname of the router callback, with no query.
|
|
507
|
+
* @param search - The search string of the router callback, when it has one.
|
|
508
|
+
* @returns True when the location is the echo of the last push of this bridge.
|
|
509
|
+
*/
|
|
510
|
+
isEchoOfLastSync(sanitized, search) {
|
|
511
|
+
const last = this.lastSyncedPath;
|
|
512
|
+
if (last === null)
|
|
513
|
+
return false;
|
|
514
|
+
if (sanitized === last && (search ?? "") === "")
|
|
515
|
+
return true;
|
|
516
|
+
// Compare the query of BOTH sides. A pathname test alone reports an echo for a
|
|
517
|
+
// move that keeps the path and changes the query, which a filter link does, and
|
|
518
|
+
// which a `validateSearch` of a route does when it injects a default. The actor
|
|
519
|
+
// then never hears of the move, and the URL and the actor diverge.
|
|
520
|
+
const [lastPath, lastQuery] = splitLocation(last);
|
|
521
|
+
if (lastPath !== sanitized)
|
|
522
|
+
return false;
|
|
523
|
+
return sortedQuery(lastQuery) === sortedQuery(search ?? "");
|
|
524
|
+
}
|
|
525
|
+
/**
|
|
526
|
+
* Cleans a raw URL pathname of the router, and checks it.
|
|
527
|
+
*
|
|
528
|
+
* The method applies the limit of the path length, which is 2048 characters. It
|
|
529
|
+
* removes a query string and a fragment from the segment of the pathname, and it
|
|
530
|
+
* normalizes each sequence of slashes.
|
|
531
|
+
*
|
|
532
|
+
* An implementation that passes around `syncActorFromRouter()`, for example with a
|
|
533
|
+
* reactive watcher of its framework that receives a route object of a parse before,
|
|
534
|
+
* MUST call this method before it gives the path to a lookup in the route map.
|
|
535
|
+
* `syncActorFromRouter()` calls the method internally. Therefore a bridge that uses
|
|
536
|
+
* that method calls `sanitizePath` never itself.
|
|
537
|
+
*
|
|
538
|
+
* @param pathname - The raw URL pathname of the framework router.
|
|
539
|
+
* @returns The clean pathname, or `null` when the path is invalid or too long.
|
|
447
540
|
*/
|
|
448
541
|
sanitizePath(pathname) {
|
|
449
542
|
return sanitizePathname(pathname);
|
|
450
543
|
}
|
|
451
544
|
/**
|
|
452
|
-
*
|
|
453
|
-
*
|
|
454
|
-
*
|
|
455
|
-
* router.subscribe()
|
|
456
|
-
*
|
|
457
|
-
* current location synchronously
|
|
458
|
-
*
|
|
459
|
-
* actor to the correct state
|
|
460
|
-
*
|
|
461
|
-
*
|
|
462
|
-
*
|
|
463
|
-
* - `
|
|
464
|
-
* - `
|
|
465
|
-
*
|
|
466
|
-
*
|
|
467
|
-
*
|
|
545
|
+
* Returns the current pathname of the router at the moment of `connect()`.
|
|
546
|
+
*
|
|
547
|
+
* `connect()` calls this method one time, for the first synchronization from the URL
|
|
548
|
+
* to the actor. router.subscribe() fires on a *later* navigation event only. It does
|
|
549
|
+
* not repeat the location that the router loaded before. A subclass that can read the
|
|
550
|
+
* current location of its router synchronously, for example with
|
|
551
|
+
* `router.state.location.pathname`, overrides this method. A deep link and a direct
|
|
552
|
+
* URL then drive the actor to the correct state, and they leave it not at the default
|
|
553
|
+
* of its machine.
|
|
554
|
+
*
|
|
555
|
+
* The meaning of each return value:
|
|
556
|
+
* - `string` → the router has a current path, and the base `connect()` writes the actor state from the router
|
|
557
|
+
* - `null` → the router is active, but it has no current path yet, and the base `connect()` writes the router from the actor
|
|
558
|
+
* - `undefined` → the adapter does the first synchronization itself, and the base `connect()` does nothing
|
|
559
|
+
*
|
|
560
|
+
* The default value is `undefined`. Therefore a bridge without this hook keeps its
|
|
561
|
+
* earlier behavior.
|
|
468
562
|
*/
|
|
469
563
|
getInitialRouterPath() {
|
|
470
564
|
return undefined;
|
|
471
565
|
}
|
|
472
566
|
/**
|
|
473
|
-
*
|
|
567
|
+
* Returns the current search string of the router at the moment of `connect()`.
|
|
474
568
|
*
|
|
475
|
-
*
|
|
476
|
-
*
|
|
477
|
-
*
|
|
478
|
-
*
|
|
569
|
+
* This method is the pair of `getInitialRouterPath()`. `connect()` calls it one time,
|
|
570
|
+
* to give the query string of the first URL to `syncActorFromRouter()`. It returns
|
|
571
|
+
* `undefined` when the current URL of the router has no search string, and also when
|
|
572
|
+
* the subclass overrides the method not. `syncActorFromRouter` then makes an empty
|
|
573
|
+
* `query: {}` in the event.
|
|
479
574
|
*
|
|
480
|
-
*
|
|
481
|
-
*
|
|
575
|
+
* A subclass that overrides `getInitialRouterPath()` and has a query string
|
|
576
|
+
* overrides this method too.
|
|
482
577
|
*
|
|
483
|
-
* @returns URL search string
|
|
578
|
+
* @returns The URL search string, for example `"?tab=security"`, or `undefined` when it is not available.
|
|
484
579
|
*/
|
|
485
580
|
getInitialRouterSearch() {
|
|
486
581
|
return undefined;
|
|
487
582
|
}
|
|
488
583
|
}
|
|
584
|
+
/**
|
|
585
|
+
* The canonical form of a query string, for a comparison that ignores the order:
|
|
586
|
+
* each `key=value` pair, sorted, joined with `&`. An empty search gives `""`.
|
|
587
|
+
*
|
|
588
|
+
* `sort()` in place, and not `toSorted()`: the array is a fresh one of `map()`, so
|
|
589
|
+
* nothing else holds a reference to it and the mutation escapes nowhere. `toSorted()`
|
|
590
|
+
* is ES2023, and four example packages compile this source through the workspace
|
|
591
|
+
* aliases with `"lib": ["ES2022", …]`, which would break `tsc --build` of the repo.
|
|
592
|
+
*/
|
|
593
|
+
/** Splits a location into its path and its query, with no leading `?`. */
|
|
594
|
+
function splitLocation(location) {
|
|
595
|
+
const mark = location.indexOf("?");
|
|
596
|
+
if (mark === -1)
|
|
597
|
+
return [location, ""];
|
|
598
|
+
return [location.slice(0, mark), location.slice(mark + 1)];
|
|
599
|
+
}
|
|
600
|
+
/** Joins a pathname and a query into one location value. An empty query adds nothing. */
|
|
601
|
+
function withSearch(pathname, search) {
|
|
602
|
+
const query = (search ?? "").replace(/^\?/, "");
|
|
603
|
+
return query === "" ? pathname : `${pathname}?${query}`;
|
|
604
|
+
}
|
|
605
|
+
function sortedQuery(search) {
|
|
606
|
+
const pairs = [...new URLSearchParams(search).entries()].map(([key, value]) => `${key}=${value}`);
|
|
607
|
+
// oxlint-disable-next-line unicorn/no-array-sort
|
|
608
|
+
pairs.sort();
|
|
609
|
+
return pairs.join("&");
|
|
610
|
+
}
|
|
489
611
|
function noopCleanup() { }
|
|
490
612
|
function createRouteWatcher(signal, onRoute) {
|
|
491
613
|
let cleanup = noopCleanup;
|