lit-navigation-router 0.4.0 → 0.6.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/CHANGELOG.md +67 -2
- package/README.md +87 -25
- package/development/router.d.ts +17 -50
- package/development/router.d.ts.map +1 -1
- package/development/router.js +43 -93
- package/development/router.js.map +1 -1
- package/development/routes.d.ts +81 -87
- package/development/routes.d.ts.map +1 -1
- package/development/routes.js +338 -193
- package/development/routes.js.map +1 -1
- package/package.json +6 -6
- package/src/router.ts +49 -99
- package/src/routes.ts +417 -215
package/development/routes.js
CHANGED
|
@@ -5,10 +5,25 @@
|
|
|
5
5
|
*
|
|
6
6
|
* Modifications Copyright 2026 VanLandingham Labs, same license. See NOTICE.md.
|
|
7
7
|
*/
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
8
|
+
/**
|
|
9
|
+
* Not `new URL(path, origin)`: a nested controller's path is a tail, a bare
|
|
10
|
+
* relative segment like `abc`, which `URL` would resolve and mangle.
|
|
11
|
+
*/
|
|
12
|
+
const parseLocation = (path) => {
|
|
13
|
+
const hashIndex = path.indexOf('#');
|
|
14
|
+
const hash = hashIndex === -1 ? '' : path.slice(hashIndex + 1);
|
|
15
|
+
const beforeHash = hashIndex === -1 ? path : path.slice(0, hashIndex);
|
|
16
|
+
const searchIndex = beforeHash.indexOf('?');
|
|
17
|
+
return {
|
|
18
|
+
pathname: searchIndex === -1 ? beforeHash : beforeHash.slice(0, searchIndex),
|
|
19
|
+
search: searchIndex === -1 ? '' : beforeHash.slice(searchIndex + 1),
|
|
20
|
+
hash,
|
|
21
|
+
};
|
|
22
|
+
};
|
|
23
|
+
/** Re-attaches `search` and `hash` to a pathname. Inverse of `parseLocation`. */
|
|
24
|
+
const formatLocation = (pathname, { search, hash }) => pathname + (search === '' ? '' : `?${search}`) + (hash === '' ? '' : `#${hash}`);
|
|
25
|
+
// Keyed by config object so `routes` can stay a plain mutable array that users
|
|
26
|
+
// push new PathRouteConfigs onto.
|
|
12
27
|
const patternCache = new WeakMap();
|
|
13
28
|
const isPatternConfig = (route) => route.pattern !== undefined;
|
|
14
29
|
const getPattern = (route) => {
|
|
@@ -22,26 +37,16 @@ const getPattern = (route) => {
|
|
|
22
37
|
return pattern;
|
|
23
38
|
};
|
|
24
39
|
/**
|
|
25
|
-
*
|
|
26
|
-
* `
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
* optional (`*?`). Not a wildcard: an escaped `\*`, or a `*` that is the
|
|
30
|
-
* modifier on a group (`(\d+)*`, `{/}*`) or on a named param (`:rest*`).
|
|
31
|
-
* Those exclusions matter when an earlier positional group exists —
|
|
32
|
-
* `/x/(\d+)/:rest*` — since that group would otherwise be taken for the tail.
|
|
40
|
+
* A trailing wildcard as `URLPattern.prototype.pathname` regenerates it: `*` or
|
|
41
|
+
* `{*}`, optionally `?`. Excludes an escaped `\*` and a `*` modifying a group
|
|
42
|
+
* or named param (`(\d+)*`, `{/}*`, `:rest*`), which would otherwise let an
|
|
43
|
+
* earlier group be taken for the tail. See #4.
|
|
33
44
|
*/
|
|
34
45
|
const TRAILING_WILDCARD = /(?:(?<![\\)}]|:[\w$]+)\*|\{\*\})\??$/;
|
|
35
46
|
/**
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
* Decided from the pattern, not from the groups object: an unnamed regex group
|
|
40
|
-
* (`/post/(\d+)`) and a wildcard that is not last (`/foo/*` followed by
|
|
41
|
-
* `/bar`) are keyed by index exactly as a tail is, and reading either as one
|
|
42
|
-
* truncated `link()` and handed a child the wrong segment. When a trailing
|
|
43
|
-
* wildcard is present it is the last group in the pattern, so its key is the
|
|
44
|
-
* highest positional index.
|
|
47
|
+
* What a trailing wildcard captured, or undefined when there is none. Decided
|
|
48
|
+
* from the pattern, not the groups object: an unnamed group (`/post/(\d+)`) and
|
|
49
|
+
* a non-final wildcard are keyed by index exactly as a tail is. See #4.
|
|
45
50
|
*/
|
|
46
51
|
const tailOf = (route, params) => {
|
|
47
52
|
if (!TRAILING_WILDCARD.test(getPattern(route).pathname)) {
|
|
@@ -49,70 +54,201 @@ const tailOf = (route, params) => {
|
|
|
49
54
|
}
|
|
50
55
|
let tailIndex = -1;
|
|
51
56
|
for (const key of Object.keys(params)) {
|
|
52
|
-
// Numeric, not lexicographic: '9'
|
|
53
|
-
// pattern with eleven or more wildcards picked group 9 as its tail.
|
|
57
|
+
// Numeric, not lexicographic: '9' > '10' as strings.
|
|
54
58
|
if (/^\d+$/.test(key) && Number(key) > tailIndex) {
|
|
55
59
|
tailIndex = Number(key);
|
|
56
60
|
}
|
|
57
61
|
}
|
|
58
62
|
return tailIndex < 0 ? undefined : params[String(tailIndex)];
|
|
59
63
|
};
|
|
64
|
+
/**
|
|
65
|
+
* Parses the subset of `URLPattern` pathname syntax that can be run backwards:
|
|
66
|
+
* literals, `:name`, `*`, and `{...}` groups, each optionally `?`.
|
|
67
|
+
*
|
|
68
|
+
* A regex group cannot be reversed, and `+` or `*` repetition has no single
|
|
69
|
+
* answer, so both throw rather than guess. Named the route, not the pattern,
|
|
70
|
+
* because the caller passed a name and that is what they can act on.
|
|
71
|
+
*/
|
|
72
|
+
const parsePattern = (pattern, name) => {
|
|
73
|
+
let i = 0;
|
|
74
|
+
let positional = 0;
|
|
75
|
+
const takeModifier = () => {
|
|
76
|
+
const mod = pattern[i];
|
|
77
|
+
if (mod === '?') {
|
|
78
|
+
i++;
|
|
79
|
+
return true;
|
|
80
|
+
}
|
|
81
|
+
if (mod === '+' || mod === '*') {
|
|
82
|
+
throw new Error(`Cannot build a link to '${name}': the repeating modifier '${mod}' in ` +
|
|
83
|
+
`'${pattern}' has no single reverse.`);
|
|
84
|
+
}
|
|
85
|
+
return false;
|
|
86
|
+
};
|
|
87
|
+
const parseNodes = (untilBrace) => {
|
|
88
|
+
const nodes = [];
|
|
89
|
+
let literal = '';
|
|
90
|
+
const flush = () => {
|
|
91
|
+
if (literal !== '') {
|
|
92
|
+
nodes.push({ kind: 'literal', text: literal });
|
|
93
|
+
literal = '';
|
|
94
|
+
}
|
|
95
|
+
};
|
|
96
|
+
while (i < pattern.length) {
|
|
97
|
+
const c = pattern[i];
|
|
98
|
+
if (untilBrace && c === '}') {
|
|
99
|
+
break;
|
|
100
|
+
}
|
|
101
|
+
if (c === '\\') {
|
|
102
|
+
literal += pattern[i + 1] ?? '';
|
|
103
|
+
i += 2;
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
if (c === '(') {
|
|
107
|
+
throw new Error(`Cannot build a link to '${name}': '${pattern}' contains a regular ` +
|
|
108
|
+
`expression group, which cannot be reversed. Use a named parameter.`);
|
|
109
|
+
}
|
|
110
|
+
if (c === '{') {
|
|
111
|
+
flush();
|
|
112
|
+
i++;
|
|
113
|
+
const inner = parseNodes(true);
|
|
114
|
+
if (pattern[i] !== '}') {
|
|
115
|
+
throw new Error(`Cannot build a link to '${name}': unbalanced '{' in '${pattern}'.`);
|
|
116
|
+
}
|
|
117
|
+
i++;
|
|
118
|
+
nodes.push({ kind: 'group', nodes: inner, optional: takeModifier() });
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
const named = c === ':' ? /^:([A-Za-z0-9_$]+)/.exec(pattern.slice(i)) : null;
|
|
122
|
+
if (named !== null) {
|
|
123
|
+
flush();
|
|
124
|
+
i += named[0].length;
|
|
125
|
+
nodes.push({ kind: 'param', name: named[1], optional: takeModifier() });
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
if (c === '*') {
|
|
129
|
+
flush();
|
|
130
|
+
i++;
|
|
131
|
+
nodes.push({
|
|
132
|
+
kind: 'wildcard',
|
|
133
|
+
index: positional++,
|
|
134
|
+
optional: takeModifier(),
|
|
135
|
+
});
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
literal += c;
|
|
139
|
+
i++;
|
|
140
|
+
}
|
|
141
|
+
flush();
|
|
142
|
+
return nodes;
|
|
143
|
+
};
|
|
144
|
+
return parseNodes(false);
|
|
145
|
+
};
|
|
146
|
+
/** The highest wildcard index in `nodes`, or -1 when there is none. */
|
|
147
|
+
const maxWildcard = (nodes) => nodes.reduce((max, node) => node.kind === 'wildcard'
|
|
148
|
+
? Math.max(max, node.index)
|
|
149
|
+
: node.kind === 'group'
|
|
150
|
+
? Math.max(max, maxWildcard(node.nodes))
|
|
151
|
+
: max, -1);
|
|
152
|
+
/**
|
|
153
|
+
* Builds a pathname from `nodes`, collecting the names of any parameters the
|
|
154
|
+
* caller did not supply.
|
|
155
|
+
*
|
|
156
|
+
* `tailIndex` is the wildcard that a trailing `/*` produced, or -1. That one
|
|
157
|
+
* may be left out: an empty tail is the index of a nested route space, which
|
|
158
|
+
* is what `linkTo('docs')` should mean. Any other wildcard is required, since
|
|
159
|
+
* dropping it would silently join the segments around it.
|
|
160
|
+
*/
|
|
161
|
+
const fillNodes = (nodes, params, tailIndex, missing) => {
|
|
162
|
+
let out = '';
|
|
163
|
+
for (const node of nodes) {
|
|
164
|
+
switch (node.kind) {
|
|
165
|
+
case 'literal':
|
|
166
|
+
out += node.text;
|
|
167
|
+
break;
|
|
168
|
+
case 'param': {
|
|
169
|
+
const value = params[node.name];
|
|
170
|
+
if (value === undefined) {
|
|
171
|
+
if (!node.optional) {
|
|
172
|
+
missing.push(node.name);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
else {
|
|
176
|
+
out += value;
|
|
177
|
+
}
|
|
178
|
+
break;
|
|
179
|
+
}
|
|
180
|
+
case 'wildcard': {
|
|
181
|
+
const value = params[String(node.index)];
|
|
182
|
+
if (value === undefined) {
|
|
183
|
+
if (!node.optional && node.index !== tailIndex) {
|
|
184
|
+
missing.push(String(node.index));
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
else {
|
|
188
|
+
out += value;
|
|
189
|
+
}
|
|
190
|
+
break;
|
|
191
|
+
}
|
|
192
|
+
case 'group': {
|
|
193
|
+
const before = missing.length;
|
|
194
|
+
const text = fillNodes(node.nodes, params, tailIndex, missing);
|
|
195
|
+
if (missing.length > before && node.optional) {
|
|
196
|
+
// The whole point of an optional group: drop it, and the parameters
|
|
197
|
+
// it wanted stop being missing.
|
|
198
|
+
missing.length = before;
|
|
199
|
+
}
|
|
200
|
+
else {
|
|
201
|
+
out += text;
|
|
202
|
+
}
|
|
203
|
+
break;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
return out;
|
|
208
|
+
};
|
|
209
|
+
/** Runs a pathname pattern backwards. Throws naming every missing parameter. */
|
|
210
|
+
const fillPattern = (pattern, params, name) => {
|
|
211
|
+
const nodes = parsePattern(pattern, name);
|
|
212
|
+
const missing = [];
|
|
213
|
+
const tailIndex = TRAILING_WILDCARD.test(pattern) ? maxWildcard(nodes) : -1;
|
|
214
|
+
const text = fillNodes(nodes, params, tailIndex, missing);
|
|
215
|
+
if (missing.length > 0) {
|
|
216
|
+
throw new Error(`Cannot build a link to '${name}': missing parameter` +
|
|
217
|
+
`${missing.length > 1 ? 's' : ''} ` +
|
|
218
|
+
missing.map((m) => `'${m}'`).join(', ') +
|
|
219
|
+
` for pattern '${pattern}'.`);
|
|
220
|
+
}
|
|
221
|
+
return text;
|
|
222
|
+
};
|
|
60
223
|
/**
|
|
61
224
|
* A reactive controller that performs location-based routing using a
|
|
62
225
|
* configuration of URL patterns and associated render callbacks.
|
|
63
226
|
*/
|
|
64
227
|
export class Routes {
|
|
65
228
|
_host;
|
|
66
|
-
|
|
67
|
-
* The
|
|
68
|
-
*
|
|
69
|
-
* This array is mutable. To dynamically add a new route you can write:
|
|
70
|
-
*
|
|
71
|
-
* ```ts
|
|
72
|
-
* this._routes.routes.push({
|
|
73
|
-
* path: '/foo',
|
|
74
|
-
* render: () => html`<p>Foo</p>`,
|
|
75
|
-
* });
|
|
76
|
-
* ```
|
|
77
|
-
*
|
|
78
|
-
* Mutating this property does not trigger any route transitions. If the
|
|
79
|
-
* changes may result is a different route matching for the current path, you
|
|
80
|
-
* must instigate a route update with `goto()`.
|
|
229
|
+
/**
|
|
230
|
+
* The installed routes, in precedence order. Mutable, but mutating it starts
|
|
231
|
+
* no route transition; call `goto()` if a different route now matches.
|
|
81
232
|
*/
|
|
82
233
|
routes = [];
|
|
83
234
|
/**
|
|
84
|
-
*
|
|
85
|
-
*
|
|
86
|
-
* pathname minus its leading slash, and is handed to child controllers as
|
|
87
|
-
* their tail. A nested controller's own pathname is a tail, with no leading
|
|
88
|
-
* slash; the fallback accepts that too.
|
|
235
|
+
* Matched when no route in {@link routes} does. Behaves like `/*`, so
|
|
236
|
+
* `params[0]` is the whole pathname minus any leading slash.
|
|
89
237
|
*/
|
|
90
238
|
fallback;
|
|
91
|
-
/*
|
|
92
|
-
* The current set of child Routes controllers. These are connected via
|
|
93
|
-
* the routes-connected event.
|
|
94
|
-
*/
|
|
95
239
|
_childRoutes = [];
|
|
96
240
|
_parentRoutes;
|
|
97
|
-
/*
|
|
98
|
-
* State related to the current matching route.
|
|
99
|
-
*
|
|
100
|
-
* We keep this so that consuming code can access current parameters, and so
|
|
101
|
-
* that we can propagate tail matches to child routes if they are added after
|
|
102
|
-
* navigation / matching.
|
|
103
|
-
*/
|
|
104
241
|
/** Monotonic goto counter; see the last-goto-wins note in goto(). */
|
|
105
242
|
_gotoSeq = 0;
|
|
106
243
|
_currentPathname;
|
|
107
244
|
_currentTail;
|
|
245
|
+
/** Ambient, not tailed: only the pathname nests. */
|
|
246
|
+
_currentSearch = '';
|
|
247
|
+
_currentHash = '';
|
|
108
248
|
_currentRoute;
|
|
109
249
|
_currentParams = {};
|
|
110
|
-
/**
|
|
111
|
-
*
|
|
112
|
-
*
|
|
113
|
-
* It's critical to call this immediately in hostDisconnected so that this
|
|
114
|
-
* controller instance doesn't receive a tail match meant for another route.
|
|
115
|
-
*/
|
|
250
|
+
/** Must run in hostDisconnected, or this controller can receive another
|
|
251
|
+
* route's tail match. */
|
|
116
252
|
// TODO (justinfagnani): Do we need this now that we have a direct reference
|
|
117
253
|
// to the parent? We can call `this._parentRoutes.disconnect(this)`.
|
|
118
254
|
_onDisconnect;
|
|
@@ -136,62 +272,96 @@ export class Routes {
|
|
|
136
272
|
return (this._parentRoutes?.link() ?? '') + pathname;
|
|
137
273
|
}
|
|
138
274
|
/**
|
|
139
|
-
*
|
|
275
|
+
* A URL for the route named `name`, with `params` substituted into its
|
|
276
|
+
* pattern.
|
|
140
277
|
*
|
|
141
|
-
*
|
|
142
|
-
*
|
|
143
|
-
*
|
|
278
|
+
* Lets a component say which route it means instead of where that route
|
|
279
|
+
* currently lives, so moving a route subtree does not silently break every
|
|
280
|
+
* hardcoded link inside it.
|
|
144
281
|
*
|
|
145
|
-
*
|
|
146
|
-
*
|
|
147
|
-
*
|
|
148
|
-
*
|
|
149
|
-
*
|
|
282
|
+
* Resolution covers the mounted controller tree: this controller, its
|
|
283
|
+
* ancestors, and any descendant that has rendered. A route in a branch that
|
|
284
|
+
* has not mounted yet is not addressable, because the mapping from a parent
|
|
285
|
+
* route to its child controller only exists once that parent has rendered.
|
|
286
|
+
* An unknown or ambiguous name throws rather than producing a wrong URL.
|
|
150
287
|
*/
|
|
151
|
-
|
|
288
|
+
linkTo(name, params = {}) {
|
|
289
|
+
let root = this;
|
|
290
|
+
while (root._parentRoutes !== undefined) {
|
|
291
|
+
root = root._parentRoutes;
|
|
292
|
+
}
|
|
293
|
+
const found = [];
|
|
294
|
+
const visit = (routes, seen) => {
|
|
295
|
+
// Cycle guard, matching `_supersede`; see the note there.
|
|
296
|
+
if (seen.has(routes)) {
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
seen.add(routes);
|
|
300
|
+
for (const route of routes.routes) {
|
|
301
|
+
if (route.name === name) {
|
|
302
|
+
found.push({ owner: routes, route });
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
for (const child of routes._childRoutes) {
|
|
306
|
+
visit(child, seen);
|
|
307
|
+
}
|
|
308
|
+
};
|
|
309
|
+
visit(root, new Set());
|
|
310
|
+
if (found.length === 0) {
|
|
311
|
+
throw new Error(`No route named '${name}' in the mounted route tree. Names are only ` +
|
|
312
|
+
`resolvable once the controller holding them has rendered.`);
|
|
313
|
+
}
|
|
314
|
+
if (found.length > 1) {
|
|
315
|
+
throw new Error(`More than one route named '${name}' in the mounted route tree.`);
|
|
316
|
+
}
|
|
317
|
+
const { owner, route } = found[0];
|
|
318
|
+
// The owner's own segment is being replaced by the generated one, so the
|
|
319
|
+
// prefix is everything above it.
|
|
320
|
+
const prefix = owner._parentRoutes?.link() ?? '';
|
|
321
|
+
return prefix + fillPattern(getPattern(route).pathname, params, name);
|
|
322
|
+
}
|
|
323
|
+
/**
|
|
324
|
+
* Navigates this controller to `path`, which may carry a search and hash.
|
|
325
|
+
* Navigates child routes but not parent ones, so it is not yet a general page
|
|
326
|
+
* navigation API.
|
|
327
|
+
*
|
|
328
|
+
* `options.signal` makes the navigation abandonable. `enter()` is awaited, so
|
|
329
|
+
* a second `goto()` can finish while the first is still resolving; without a
|
|
330
|
+
* signal the slower one commits last onto a route the URL has left.
|
|
331
|
+
*/
|
|
332
|
+
async goto(path, options) {
|
|
152
333
|
// TODO (justinfagnani): handle absolute vs relative paths separately.
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
//
|
|
156
|
-
//
|
|
157
|
-
//
|
|
158
|
-
// enough: a child controller mounts as a *result* of its parent's render,
|
|
159
|
-
// so its first goto() comes from `_onRoutesConnected` — after the parent's
|
|
160
|
-
// navigation has already finished, and therefore with a signal that will
|
|
161
|
-
// never abort. Without this counter a slow first child load commits over a
|
|
162
|
-
// newer one. This also keeps `Routes` correct when used on its own, with
|
|
163
|
-
// no `Router` and no Navigation API in the picture.
|
|
334
|
+
const location = parseLocation(path);
|
|
335
|
+
const { pathname } = location;
|
|
336
|
+
// Last-goto-wins. The signal alone is not enough: a child's first goto()
|
|
337
|
+
// comes from `_onRoutesConnected`, after the parent's navigation finished,
|
|
338
|
+
// so its signal never aborts. Also keeps `Routes` correct standalone.
|
|
164
339
|
const seq = ++this._gotoSeq;
|
|
165
340
|
let tail;
|
|
166
341
|
if (this.routes.length === 0 && this.fallback === undefined) {
|
|
167
|
-
//
|
|
168
|
-
//
|
|
169
|
-
// match.
|
|
342
|
+
// No routes of its own acts as a single `/*`, passing the whole pathname
|
|
343
|
+
// on as a tail.
|
|
170
344
|
tail = pathname;
|
|
171
345
|
this._currentPathname = '';
|
|
172
|
-
// Simulate a tail group with the whole pathname
|
|
173
346
|
this._currentParams = { 0: tail };
|
|
174
347
|
}
|
|
175
348
|
else {
|
|
176
|
-
const match = this._match(
|
|
349
|
+
const match = this._match(location);
|
|
177
350
|
if (match === undefined) {
|
|
178
|
-
throw new Error(`No route found for ${
|
|
351
|
+
throw new Error(`No route found for ${path}`);
|
|
179
352
|
}
|
|
180
353
|
const { route, params } = match;
|
|
181
354
|
tail = match.tail;
|
|
182
355
|
if (typeof route.enter === 'function') {
|
|
183
356
|
const success = await route.enter(params);
|
|
184
|
-
// If enter() returns false, cancel this navigation
|
|
185
357
|
if (success === false) {
|
|
186
358
|
return;
|
|
187
359
|
}
|
|
188
360
|
}
|
|
189
|
-
//
|
|
190
|
-
// Committing now would swap the outlet onto a route the URL has left.
|
|
361
|
+
// Superseded while `enter` awaited; committing would strand the outlet.
|
|
191
362
|
if (options?.signal?.aborted === true || seq !== this._gotoSeq) {
|
|
192
363
|
return;
|
|
193
364
|
}
|
|
194
|
-
// Only update route state if the enter handler completes successfully
|
|
195
365
|
this._currentRoute = route;
|
|
196
366
|
this._currentParams = params;
|
|
197
367
|
this._currentPathname =
|
|
@@ -200,24 +370,14 @@ export class Routes {
|
|
|
200
370
|
: pathname.substring(0, pathname.length - tail.length);
|
|
201
371
|
}
|
|
202
372
|
this._currentTail = tail;
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
//
|
|
206
|
-
//
|
|
207
|
-
//
|
|
208
|
-
//
|
|
209
|
-
// parent's outlet swap on an `enter()` for a tail that controller will
|
|
210
|
-
// never render (a hung one blocks the navigation forever), and if that
|
|
211
|
-
// child has no route for the new tail its `No route found` throw
|
|
212
|
-
// propagates out of here and `requestUpdate()` below never runs — URL
|
|
213
|
-
// committed, outlet stranded, i.e. this fork's own thesis bug one level
|
|
214
|
-
// down. Nested supersession is handled by the goto counter above, not by
|
|
215
|
-
// awaiting. `_routeChild` covers the per-child filtering and error policy.
|
|
373
|
+
this._currentSearch = location.search;
|
|
374
|
+
this._currentHash = location.hash;
|
|
375
|
+
// Not awaited. `requestUpdate()` has not run, so `_childRoutes` still holds
|
|
376
|
+
// the outgoing branch: awaiting gates the parent's swap on a controller
|
|
377
|
+
// that will never render, and its `No route found` would skip
|
|
378
|
+
// `requestUpdate()` below and strand the outlet on a committed URL.
|
|
216
379
|
//
|
|
217
|
-
// Runs
|
|
218
|
-
// the children to render, but they must still be superseded — otherwise a
|
|
219
|
-
// child mid-`enter()` for the previous tail stays current and commits over
|
|
220
|
-
// a URL that has moved on.
|
|
380
|
+
// Runs even with no tail, so children still get superseded. See #7.
|
|
221
381
|
for (const childRoutes of this._childRoutes) {
|
|
222
382
|
this._routeChild(childRoutes, tail);
|
|
223
383
|
}
|
|
@@ -236,108 +396,106 @@ export class Routes {
|
|
|
236
396
|
return this._currentParams;
|
|
237
397
|
}
|
|
238
398
|
/**
|
|
239
|
-
* Hands a tail match to a child
|
|
240
|
-
*
|
|
241
|
-
* input cannot be silent on one and an uncaught global throw on the other.
|
|
242
|
-
*
|
|
243
|
-
* A child with no route for the new tail is the expected case, not an error —
|
|
244
|
-
* the outgoing branch mid-swap, or a deep link to a path the child cannot
|
|
245
|
-
* render. Filtered structurally rather than by swallowing every rejection, so
|
|
246
|
-
* a genuine `enter()` rejection still surfaces the way it does upstream.
|
|
247
|
-
* Skipping must still supersede: `goto()` is where the counter is bumped, so
|
|
248
|
-
* returning without it would leave an in-flight child navigation current,
|
|
249
|
-
* free to commit over a URL that has moved on. A parent route with no tail
|
|
250
|
-
* at all is the same case: nothing to route, but still something to stand
|
|
251
|
-
* down.
|
|
399
|
+
* Hands a tail match to a child. Shared by `goto()`'s propagation loop and the
|
|
400
|
+
* late-mount path, so identical input behaves identically on both.
|
|
252
401
|
*
|
|
253
|
-
*
|
|
254
|
-
*
|
|
255
|
-
*
|
|
256
|
-
* to correct it, leaving the nested outlet stuck — reachable, because a
|
|
257
|
-
* hash-only navigation aborts the outstanding one without producing a
|
|
258
|
-
* replacement. Supersession is the counter's job.
|
|
402
|
+
* A child with no route for the tail is expected, not an error: the outgoing
|
|
403
|
+
* branch mid-swap, or a deep link it cannot render. Filtered structurally so
|
|
404
|
+
* a genuine `enter()` rejection still surfaces. Skipping must still supersede.
|
|
259
405
|
*/
|
|
260
406
|
_routeChild(child, tail) {
|
|
261
|
-
if (tail === undefined
|
|
407
|
+
if (tail === undefined) {
|
|
408
|
+
child._supersede();
|
|
409
|
+
return;
|
|
410
|
+
}
|
|
411
|
+
const childPath = formatLocation(tail, {
|
|
412
|
+
search: this._currentSearch,
|
|
413
|
+
hash: this._currentHash,
|
|
414
|
+
});
|
|
415
|
+
if (!child.hasRouteFor(childPath)) {
|
|
262
416
|
child._supersede();
|
|
263
417
|
return;
|
|
264
418
|
}
|
|
265
|
-
void child.goto(
|
|
419
|
+
void child.goto(childPath).catch((err) => {
|
|
266
420
|
queueMicrotask(() => {
|
|
267
421
|
throw err;
|
|
268
422
|
});
|
|
269
423
|
});
|
|
270
424
|
}
|
|
271
|
-
/**
|
|
272
|
-
* Invalidate any in-flight `goto()` on this controller without starting a
|
|
273
|
-
* new one. Same-class access, so `_gotoSeq` stays private to `Routes`.
|
|
274
|
-
*/
|
|
425
|
+
/** Invalidate any in-flight `goto()` here without starting a new one. */
|
|
275
426
|
_supersede(seen = new Set()) {
|
|
276
|
-
//
|
|
277
|
-
//
|
|
278
|
-
// reconnected, ends up with each registered as the other's child — but
|
|
279
|
-
// `hostDisconnected` below removes the listener that causes it, and a test
|
|
280
|
-
// asserts the cycle cannot form. Kept because an unguarded recursive walk
|
|
281
|
-
// over a cycle is a stack overflow rather than a misrender.
|
|
427
|
+
// Defence in depth: a `_childRoutes` cycle cannot form (hostDisconnected,
|
|
428
|
+
// plus a test), but an unguarded walk over one is a stack overflow.
|
|
282
429
|
if (seen.has(this)) {
|
|
283
430
|
return;
|
|
284
431
|
}
|
|
285
432
|
seen.add(this);
|
|
286
433
|
this._gotoSeq++;
|
|
287
|
-
//
|
|
288
|
-
//
|
|
289
|
-
// without this an in-flight grandchild `enter()` stays current and commits
|
|
290
|
-
// over a URL that has moved on, the same defect one level deeper.
|
|
434
|
+
// A skipped child never runs its own propagation loop, so recurse or an
|
|
435
|
+
// in-flight grandchild `enter()` stays current.
|
|
291
436
|
for (const child of this._childRoutes) {
|
|
292
437
|
child._supersede(seen);
|
|
293
438
|
}
|
|
294
439
|
}
|
|
295
440
|
/**
|
|
296
|
-
* True when this controller
|
|
297
|
-
* a
|
|
298
|
-
*
|
|
299
|
-
* `Router` gates interception on this: intercepting a path we cannot render
|
|
300
|
-
* commits the URL and then throws out of `goto()`, leaving the address bar
|
|
301
|
-
* moved and the outlet stale. Letting the browser handle it instead means a
|
|
302
|
-
* server-rendered page, an export endpoint, or a GET form still works.
|
|
441
|
+
* True when this controller or any below it constrains the hash. `Router`
|
|
442
|
+
* gates fragment-only interception on this, so a pathname-only app keeps
|
|
443
|
+
* native scrolling. Walks children because only the root sees the event.
|
|
303
444
|
*/
|
|
304
|
-
|
|
305
|
-
//
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
445
|
+
_constrainsHash(seen = new Set()) {
|
|
446
|
+
// Cycle guard, matching `_supersede`; see the note there.
|
|
447
|
+
if (seen.has(this)) {
|
|
448
|
+
return false;
|
|
449
|
+
}
|
|
450
|
+
seen.add(this);
|
|
451
|
+
return (this.routes.some((r) => getPattern(r).hash !== '*') ||
|
|
452
|
+
this._childRoutes.some((c) => c._constrainsHash(seen)));
|
|
453
|
+
}
|
|
454
|
+
/**
|
|
455
|
+
* True when this controller can render `path`. `Router` gates interception on
|
|
456
|
+
* this: intercepting what we cannot render commits the URL and then throws,
|
|
457
|
+
* leaving the address bar moved and the outlet stale.
|
|
458
|
+
*/
|
|
459
|
+
hasRouteFor(path) {
|
|
460
|
+
// A fallback matches everything, and no routes behaves as one `/*`. Worth
|
|
461
|
+
// short-circuiting: `Router` asks this on every navigation.
|
|
309
462
|
if (this.fallback !== undefined || this.routes.length === 0) {
|
|
310
463
|
return true;
|
|
311
464
|
}
|
|
312
|
-
// `test()`, not `_match()`:
|
|
313
|
-
//
|
|
314
|
-
|
|
465
|
+
// `test()`, not `_match()`: `exec()` pays ~8x on a hit to build groups the
|
|
466
|
+
// caller would throw away.
|
|
467
|
+
const location = parseLocation(path);
|
|
468
|
+
return this.routes.some((r) => getPattern(r).test(location));
|
|
315
469
|
}
|
|
316
470
|
/**
|
|
317
|
-
*
|
|
318
|
-
*
|
|
319
|
-
*
|
|
320
|
-
* One `exec()` per candidate rather than `test()` to select and `exec()` to
|
|
321
|
-
* extract: that ran the winning pattern twice, and every caller that wants a
|
|
322
|
-
* route wants its params too.
|
|
471
|
+
* The first route matching `location`, or the fallback's match. One `exec()`
|
|
472
|
+
* per candidate; selecting with `test()` first ran the winner twice.
|
|
323
473
|
*/
|
|
324
|
-
_match(
|
|
474
|
+
_match(location) {
|
|
325
475
|
for (const route of this.routes) {
|
|
326
|
-
const result = getPattern(route).exec(
|
|
476
|
+
const result = getPattern(route).exec(location);
|
|
327
477
|
if (result !== null) {
|
|
328
|
-
const params =
|
|
329
|
-
|
|
478
|
+
const params = {
|
|
479
|
+
...result.pathname.groups,
|
|
480
|
+
};
|
|
481
|
+
// Named only. Every component numbers positional groups from zero, so
|
|
482
|
+
// a merged "0" could masquerade as the tail. See #13.
|
|
483
|
+
for (const groups of [result.search.groups, result.hash.groups]) {
|
|
484
|
+
for (const [key, value] of Object.entries(groups)) {
|
|
485
|
+
if (!/^\d+$/.test(key)) {
|
|
486
|
+
params[key] = value;
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
return { route, params, tail: tailOf(route, result.pathname.groups) };
|
|
330
491
|
}
|
|
331
492
|
}
|
|
332
493
|
if (this.fallback === undefined) {
|
|
333
494
|
return undefined;
|
|
334
495
|
}
|
|
335
|
-
//
|
|
336
|
-
//
|
|
337
|
-
|
|
338
|
-
// nested controller is handed its tail *without* a leading slash, which
|
|
339
|
-
// `/*` does not match, so a nested fallback matched nothing — empty
|
|
340
|
-
// params, no tail, and its own children never routed.
|
|
496
|
+
// Matched by hand, not with a real `/*` pattern: a nested controller gets
|
|
497
|
+
// its tail without a leading slash, which `/*` rejects. See #5.
|
|
498
|
+
const { pathname } = location;
|
|
341
499
|
const tail = pathname.startsWith('/') ? pathname.slice(1) : pathname;
|
|
342
500
|
return { route: { ...this.fallback, path: '/*' }, params: { 0: tail }, tail };
|
|
343
501
|
}
|
|
@@ -348,22 +506,15 @@ export class Routes {
|
|
|
348
506
|
this._onDisconnect = event.onDisconnect;
|
|
349
507
|
}
|
|
350
508
|
hostDisconnected() {
|
|
351
|
-
//
|
|
352
|
-
//
|
|
353
|
-
//
|
|
354
|
-
// claims the re-dispatching controller as *its* child and the pair point
|
|
355
|
-
// at each other — a real `_childRoutes` cycle, which recursive walks turn
|
|
356
|
-
// into a stack overflow.
|
|
509
|
+
// Without this, a disconnected and reconnected host leaves a sibling's
|
|
510
|
+
// listener installed and the two claim each other as children: a
|
|
511
|
+
// `_childRoutes` cycle, which recursive walks turn into a stack overflow.
|
|
357
512
|
this._host.removeEventListener(RoutesConnectedEvent.eventName, this._onRoutesConnected);
|
|
358
|
-
// When this child routes controller is disconnected because a parent
|
|
359
|
-
// outlet rendered a different template, disconnecting will ensure that
|
|
360
|
-
// this controller doesn't receive a tail match meant for another route.
|
|
361
513
|
this._onDisconnect?.();
|
|
362
514
|
this._parentRoutes = undefined;
|
|
363
515
|
}
|
|
364
516
|
_onRoutesConnected = (e) => {
|
|
365
|
-
//
|
|
366
|
-
// because we do this.dispatchEvent(...)
|
|
517
|
+
// Ignore our own event, which we receive because we dispatch on the host.
|
|
367
518
|
if (e.routes === this) {
|
|
368
519
|
return;
|
|
369
520
|
}
|
|
@@ -377,17 +528,11 @@ export class Routes {
|
|
|
377
528
|
this._childRoutes.splice(index, 1);
|
|
378
529
|
}
|
|
379
530
|
};
|
|
380
|
-
//
|
|
381
|
-
// it — it missed the propagation loop in goto() that ran before it existed.
|
|
382
|
-
// With no tail there is nothing to catch up to, and `_routeChild` then only
|
|
383
|
-
// supersedes, a no-op on a freshly mounted child.
|
|
531
|
+
// Catch up a child that mounted after goto()'s propagation loop ran.
|
|
384
532
|
this._routeChild(childRoutes, this._currentTail);
|
|
385
533
|
};
|
|
386
534
|
}
|
|
387
|
-
/**
|
|
388
|
-
* This event is fired from Routes controllers when their host is connected to
|
|
389
|
-
* announce the child route and potentially connect to a parent routes controller.
|
|
390
|
-
*/
|
|
535
|
+
/** Announces a Routes controller to its parent when the host connects. */
|
|
391
536
|
export class RoutesConnectedEvent extends Event {
|
|
392
537
|
static eventName = 'lit-routes-connected';
|
|
393
538
|
routes;
|