lit-navigation-router 0.5.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 +33 -7
- package/README.md +64 -30
- package/development/router.d.ts +17 -50
- package/development/router.d.ts.map +1 -1
- package/development/router.js +34 -97
- package/development/router.js.map +1 -1
- package/development/routes.d.ts +57 -113
- package/development/routes.d.ts.map +1 -1
- package/development/routes.js +276 -205
- package/development/routes.js.map +1 -1
- package/package.json +6 -6
- package/src/router.ts +37 -101
- package/src/routes.ts +334 -240
package/development/routes.js
CHANGED
|
@@ -6,11 +6,8 @@
|
|
|
6
6
|
* Modifications Copyright 2026 VanLandingham Labs, same license. See NOTICE.md.
|
|
7
7
|
*/
|
|
8
8
|
/**
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
* Deliberately not `new URL(path, origin)`: a nested controller's path is a
|
|
12
|
-
* *tail* — a bare relative segment like `abc` — which `URL` would resolve
|
|
13
|
-
* against the current directory and mangle.
|
|
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.
|
|
14
11
|
*/
|
|
15
12
|
const parseLocation = (path) => {
|
|
16
13
|
const hashIndex = path.indexOf('#');
|
|
@@ -25,10 +22,8 @@ const parseLocation = (path) => {
|
|
|
25
22
|
};
|
|
26
23
|
/** Re-attaches `search` and `hash` to a pathname. Inverse of `parseLocation`. */
|
|
27
24
|
const formatLocation = (pathname, { search, hash }) => pathname + (search === '' ? '' : `?${search}`) + (hash === '' ? '' : `#${hash}`);
|
|
28
|
-
//
|
|
29
|
-
//
|
|
30
|
-
// lets us make `routes` mutable so users can add new PathRouteConfigs
|
|
31
|
-
// dynamically.
|
|
25
|
+
// Keyed by config object so `routes` can stay a plain mutable array that users
|
|
26
|
+
// push new PathRouteConfigs onto.
|
|
32
27
|
const patternCache = new WeakMap();
|
|
33
28
|
const isPatternConfig = (route) => route.pattern !== undefined;
|
|
34
29
|
const getPattern = (route) => {
|
|
@@ -42,26 +37,16 @@ const getPattern = (route) => {
|
|
|
42
37
|
return pattern;
|
|
43
38
|
};
|
|
44
39
|
/**
|
|
45
|
-
*
|
|
46
|
-
* `
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
* optional (`*?`). Not a wildcard: an escaped `\*`, or a `*` that is the
|
|
50
|
-
* modifier on a group (`(\d+)*`, `{/}*`) or on a named param (`:rest*`).
|
|
51
|
-
* Those exclusions matter when an earlier positional group exists —
|
|
52
|
-
* `/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.
|
|
53
44
|
*/
|
|
54
45
|
const TRAILING_WILDCARD = /(?:(?<![\\)}]|:[\w$]+)\*|\{\*\})\??$/;
|
|
55
46
|
/**
|
|
56
|
-
*
|
|
57
|
-
*
|
|
58
|
-
*
|
|
59
|
-
* Decided from the pattern, not from the groups object: an unnamed regex group
|
|
60
|
-
* (`/post/(\d+)`) and a wildcard that is not last (`/foo/*` followed by
|
|
61
|
-
* `/bar`) are keyed by index exactly as a tail is, and reading either as one
|
|
62
|
-
* truncated `link()` and handed a child the wrong segment. When a trailing
|
|
63
|
-
* wildcard is present it is the last group in the pattern, so its key is the
|
|
64
|
-
* 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.
|
|
65
50
|
*/
|
|
66
51
|
const tailOf = (route, params) => {
|
|
67
52
|
if (!TRAILING_WILDCARD.test(getPattern(route).pathname)) {
|
|
@@ -69,79 +54,201 @@ const tailOf = (route, params) => {
|
|
|
69
54
|
}
|
|
70
55
|
let tailIndex = -1;
|
|
71
56
|
for (const key of Object.keys(params)) {
|
|
72
|
-
// Numeric, not lexicographic: '9'
|
|
73
|
-
// pattern with eleven or more wildcards picked group 9 as its tail.
|
|
57
|
+
// Numeric, not lexicographic: '9' > '10' as strings.
|
|
74
58
|
if (/^\d+$/.test(key) && Number(key) > tailIndex) {
|
|
75
59
|
tailIndex = Number(key);
|
|
76
60
|
}
|
|
77
61
|
}
|
|
78
62
|
return tailIndex < 0 ? undefined : params[String(tailIndex)];
|
|
79
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
|
+
};
|
|
80
223
|
/**
|
|
81
224
|
* A reactive controller that performs location-based routing using a
|
|
82
225
|
* configuration of URL patterns and associated render callbacks.
|
|
83
226
|
*/
|
|
84
227
|
export class Routes {
|
|
85
228
|
_host;
|
|
86
|
-
|
|
87
|
-
* The
|
|
88
|
-
*
|
|
89
|
-
* This array is mutable. To dynamically add a new route you can write:
|
|
90
|
-
*
|
|
91
|
-
* ```ts
|
|
92
|
-
* this._routes.routes.push({
|
|
93
|
-
* path: '/foo',
|
|
94
|
-
* render: () => html`<p>Foo</p>`,
|
|
95
|
-
* });
|
|
96
|
-
* ```
|
|
97
|
-
*
|
|
98
|
-
* Mutating this property does not trigger any route transitions. If the
|
|
99
|
-
* changes may result is a different route matching for the current path, you
|
|
100
|
-
* 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.
|
|
101
232
|
*/
|
|
102
233
|
routes = [];
|
|
103
234
|
/**
|
|
104
|
-
*
|
|
105
|
-
*
|
|
106
|
-
* pathname minus its leading slash, and is handed to child controllers as
|
|
107
|
-
* their tail. A nested controller's own pathname is a tail, with no leading
|
|
108
|
-
* 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.
|
|
109
237
|
*/
|
|
110
238
|
fallback;
|
|
111
|
-
/*
|
|
112
|
-
* The current set of child Routes controllers. These are connected via
|
|
113
|
-
* the routes-connected event.
|
|
114
|
-
*/
|
|
115
239
|
_childRoutes = [];
|
|
116
240
|
_parentRoutes;
|
|
117
|
-
/*
|
|
118
|
-
* State related to the current matching route.
|
|
119
|
-
*
|
|
120
|
-
* We keep this so that consuming code can access current parameters, and so
|
|
121
|
-
* that we can propagate tail matches to child routes if they are added after
|
|
122
|
-
* navigation / matching.
|
|
123
|
-
*/
|
|
124
241
|
/** Monotonic goto counter; see the last-goto-wins note in goto(). */
|
|
125
242
|
_gotoSeq = 0;
|
|
126
243
|
_currentPathname;
|
|
127
244
|
_currentTail;
|
|
128
|
-
/**
|
|
129
|
-
* The search and hash of the current location.
|
|
130
|
-
*
|
|
131
|
-
* Ambient rather than tailed: only the pathname nests, so a child controller
|
|
132
|
-
* is handed the parent's tail as its pathname but the *same* search and
|
|
133
|
-
* hash. There is no meaningful way to split a fragment across a route tree.
|
|
134
|
-
*/
|
|
245
|
+
/** Ambient, not tailed: only the pathname nests. */
|
|
135
246
|
_currentSearch = '';
|
|
136
247
|
_currentHash = '';
|
|
137
248
|
_currentRoute;
|
|
138
249
|
_currentParams = {};
|
|
139
|
-
/**
|
|
140
|
-
*
|
|
141
|
-
*
|
|
142
|
-
* It's critical to call this immediately in hostDisconnected so that this
|
|
143
|
-
* controller instance doesn't receive a tail match meant for another route.
|
|
144
|
-
*/
|
|
250
|
+
/** Must run in hostDisconnected, or this controller can receive another
|
|
251
|
+
* route's tail match. */
|
|
145
252
|
// TODO (justinfagnani): Do we need this now that we have a direct reference
|
|
146
253
|
// to the parent? We can call `this._parentRoutes.disconnect(this)`.
|
|
147
254
|
_onDisconnect;
|
|
@@ -165,38 +272,77 @@ export class Routes {
|
|
|
165
272
|
return (this._parentRoutes?.link() ?? '') + pathname;
|
|
166
273
|
}
|
|
167
274
|
/**
|
|
168
|
-
*
|
|
275
|
+
* A URL for the route named `name`, with `params` substituted into its
|
|
276
|
+
* pattern.
|
|
169
277
|
*
|
|
170
|
-
*
|
|
171
|
-
*
|
|
172
|
-
*
|
|
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.
|
|
281
|
+
*
|
|
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.
|
|
287
|
+
*/
|
|
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.
|
|
173
327
|
*
|
|
174
|
-
*
|
|
175
|
-
*
|
|
176
|
-
*
|
|
177
|
-
* and the outlet ends up on a route the URL has already left. `Router`
|
|
178
|
-
* threads `NavigateEvent.signal` through for exactly this reason.
|
|
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.
|
|
179
331
|
*/
|
|
180
332
|
async goto(path, options) {
|
|
181
333
|
// TODO (justinfagnani): handle absolute vs relative paths separately.
|
|
182
334
|
const location = parseLocation(path);
|
|
183
335
|
const { pathname } = location;
|
|
184
|
-
// Last-goto-wins
|
|
185
|
-
//
|
|
186
|
-
// so its
|
|
187
|
-
// navigation has already finished, and therefore with a signal that will
|
|
188
|
-
// never abort. Without this counter a slow first child load commits over a
|
|
189
|
-
// newer one. This also keeps `Routes` correct when used on its own, with
|
|
190
|
-
// no `Router` and no Navigation API in the picture.
|
|
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.
|
|
191
339
|
const seq = ++this._gotoSeq;
|
|
192
340
|
let tail;
|
|
193
341
|
if (this.routes.length === 0 && this.fallback === undefined) {
|
|
194
|
-
//
|
|
195
|
-
//
|
|
196
|
-
// match.
|
|
342
|
+
// No routes of its own acts as a single `/*`, passing the whole pathname
|
|
343
|
+
// on as a tail.
|
|
197
344
|
tail = pathname;
|
|
198
345
|
this._currentPathname = '';
|
|
199
|
-
// Simulate a tail group with the whole pathname
|
|
200
346
|
this._currentParams = { 0: tail };
|
|
201
347
|
}
|
|
202
348
|
else {
|
|
@@ -208,17 +354,14 @@ export class Routes {
|
|
|
208
354
|
tail = match.tail;
|
|
209
355
|
if (typeof route.enter === 'function') {
|
|
210
356
|
const success = await route.enter(params);
|
|
211
|
-
// If enter() returns false, cancel this navigation
|
|
212
357
|
if (success === false) {
|
|
213
358
|
return;
|
|
214
359
|
}
|
|
215
360
|
}
|
|
216
|
-
//
|
|
217
|
-
// Committing now would swap the outlet onto a route the URL has left.
|
|
361
|
+
// Superseded while `enter` awaited; committing would strand the outlet.
|
|
218
362
|
if (options?.signal?.aborted === true || seq !== this._gotoSeq) {
|
|
219
363
|
return;
|
|
220
364
|
}
|
|
221
|
-
// Only update route state if the enter handler completes successfully
|
|
222
365
|
this._currentRoute = route;
|
|
223
366
|
this._currentParams = params;
|
|
224
367
|
this._currentPathname =
|
|
@@ -229,24 +372,12 @@ export class Routes {
|
|
|
229
372
|
this._currentTail = tail;
|
|
230
373
|
this._currentSearch = location.search;
|
|
231
374
|
this._currentHash = location.hash;
|
|
232
|
-
//
|
|
233
|
-
//
|
|
234
|
-
//
|
|
235
|
-
//
|
|
236
|
-
// over. At this point `requestUpdate()` has not run, so `_childRoutes`
|
|
237
|
-
// still holds the *outgoing* branch's controller: awaiting it gates the
|
|
238
|
-
// parent's outlet swap on an `enter()` for a tail that controller will
|
|
239
|
-
// never render (a hung one blocks the navigation forever), and if that
|
|
240
|
-
// child has no route for the new tail its `No route found` throw
|
|
241
|
-
// propagates out of here and `requestUpdate()` below never runs — URL
|
|
242
|
-
// committed, outlet stranded, i.e. this fork's own thesis bug one level
|
|
243
|
-
// down. Nested supersession is handled by the goto counter above, not by
|
|
244
|
-
// awaiting. `_routeChild` covers the per-child filtering and error policy.
|
|
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.
|
|
245
379
|
//
|
|
246
|
-
// Runs
|
|
247
|
-
// the children to render, but they must still be superseded — otherwise a
|
|
248
|
-
// child mid-`enter()` for the previous tail stays current and commits over
|
|
249
|
-
// a URL that has moved on.
|
|
380
|
+
// Runs even with no tail, so children still get superseded. See #7.
|
|
250
381
|
for (const childRoutes of this._childRoutes) {
|
|
251
382
|
this._routeChild(childRoutes, tail);
|
|
252
383
|
}
|
|
@@ -265,26 +396,12 @@ export class Routes {
|
|
|
265
396
|
return this._currentParams;
|
|
266
397
|
}
|
|
267
398
|
/**
|
|
268
|
-
* Hands a tail match to a child
|
|
269
|
-
*
|
|
270
|
-
* input cannot be silent on one and an uncaught global throw on the other.
|
|
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.
|
|
271
401
|
*
|
|
272
|
-
* A child with no route for the
|
|
273
|
-
*
|
|
274
|
-
*
|
|
275
|
-
* a genuine `enter()` rejection still surfaces the way it does upstream.
|
|
276
|
-
* Skipping must still supersede: `goto()` is where the counter is bumped, so
|
|
277
|
-
* returning without it would leave an in-flight child navigation current,
|
|
278
|
-
* free to commit over a URL that has moved on. A parent route with no tail
|
|
279
|
-
* at all is the same case: nothing to route, but still something to stand
|
|
280
|
-
* down.
|
|
281
|
-
*
|
|
282
|
-
* No abort signal is threaded through, and the goto is deliberately not
|
|
283
|
-
* awaited. The parent commits its own state before children run, so a child
|
|
284
|
-
* handed an already-aborted signal stands down with no newer goto() arriving
|
|
285
|
-
* to correct it, leaving the nested outlet stuck — reachable, because a
|
|
286
|
-
* hash-only navigation aborts the outstanding one without producing a
|
|
287
|
-
* 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.
|
|
288
405
|
*/
|
|
289
406
|
_routeChild(child, tail) {
|
|
290
407
|
if (tail === undefined) {
|
|
@@ -305,42 +422,25 @@ export class Routes {
|
|
|
305
422
|
});
|
|
306
423
|
});
|
|
307
424
|
}
|
|
308
|
-
/**
|
|
309
|
-
* Invalidate any in-flight `goto()` on this controller without starting a
|
|
310
|
-
* new one. Same-class access, so `_gotoSeq` stays private to `Routes`.
|
|
311
|
-
*/
|
|
425
|
+
/** Invalidate any in-flight `goto()` here without starting a new one. */
|
|
312
426
|
_supersede(seen = new Set()) {
|
|
313
|
-
//
|
|
314
|
-
//
|
|
315
|
-
// reconnected, ends up with each registered as the other's child — but
|
|
316
|
-
// `hostDisconnected` below removes the listener that causes it, and a test
|
|
317
|
-
// asserts the cycle cannot form. Kept because an unguarded recursive walk
|
|
318
|
-
// 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.
|
|
319
429
|
if (seen.has(this)) {
|
|
320
430
|
return;
|
|
321
431
|
}
|
|
322
432
|
seen.add(this);
|
|
323
433
|
this._gotoSeq++;
|
|
324
|
-
//
|
|
325
|
-
//
|
|
326
|
-
// without this an in-flight grandchild `enter()` stays current and commits
|
|
327
|
-
// 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.
|
|
328
436
|
for (const child of this._childRoutes) {
|
|
329
437
|
child._supersede(seen);
|
|
330
438
|
}
|
|
331
439
|
}
|
|
332
440
|
/**
|
|
333
|
-
* True when this controller
|
|
334
|
-
*
|
|
335
|
-
*
|
|
336
|
-
* `Router` gates interception of fragment-only navigation on this. Left
|
|
337
|
-
* ungated, a pathname-only app would have every in-page anchor swallowed and
|
|
338
|
-
* re-rendered instead of scrolled; gated, such an app behaves exactly as it
|
|
339
|
-
* did before hash routes existed.
|
|
340
|
-
*
|
|
341
|
-
* Walks children because a nested controller may route on the hash while the
|
|
342
|
-
* top-level `Router` does not — and only the top-level one sees the
|
|
343
|
-
* navigate event.
|
|
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.
|
|
344
444
|
*/
|
|
345
445
|
_constrainsHash(seen = new Set()) {
|
|
346
446
|
// Cycle guard, matching `_supersede`; see the note there.
|
|
@@ -352,34 +452,24 @@ export class Routes {
|
|
|
352
452
|
this._childRoutes.some((c) => c._constrainsHash(seen)));
|
|
353
453
|
}
|
|
354
454
|
/**
|
|
355
|
-
* True when this controller can render `path`
|
|
356
|
-
*
|
|
357
|
-
*
|
|
358
|
-
* `Router` gates interception on this: intercepting a path we cannot render
|
|
359
|
-
* commits the URL and then throws out of `goto()`, leaving the address bar
|
|
360
|
-
* moved and the outlet stale. Letting the browser handle it instead means a
|
|
361
|
-
* server-rendered page, an export endpoint, or a GET form still works.
|
|
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.
|
|
362
458
|
*/
|
|
363
459
|
hasRouteFor(path) {
|
|
364
|
-
// A fallback matches everything, and
|
|
365
|
-
//
|
|
366
|
-
// way the answer is yes without running a single pattern — worth
|
|
367
|
-
// short-circuiting, since `Router` asks this on every navigation.
|
|
460
|
+
// A fallback matches everything, and no routes behaves as one `/*`. Worth
|
|
461
|
+
// short-circuiting: `Router` asks this on every navigation.
|
|
368
462
|
if (this.fallback !== undefined || this.routes.length === 0) {
|
|
369
463
|
return true;
|
|
370
464
|
}
|
|
371
|
-
// `test()`, not `_match()`:
|
|
372
|
-
//
|
|
465
|
+
// `test()`, not `_match()`: `exec()` pays ~8x on a hit to build groups the
|
|
466
|
+
// caller would throw away.
|
|
373
467
|
const location = parseLocation(path);
|
|
374
468
|
return this.routes.some((r) => getPattern(r).test(location));
|
|
375
469
|
}
|
|
376
470
|
/**
|
|
377
|
-
*
|
|
378
|
-
*
|
|
379
|
-
*
|
|
380
|
-
* One `exec()` per candidate rather than `test()` to select and `exec()` to
|
|
381
|
-
* extract: that ran the winning pattern twice, and every caller that wants a
|
|
382
|
-
* 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.
|
|
383
473
|
*/
|
|
384
474
|
_match(location) {
|
|
385
475
|
for (const route of this.routes) {
|
|
@@ -388,10 +478,8 @@ export class Routes {
|
|
|
388
478
|
const params = {
|
|
389
479
|
...result.pathname.groups,
|
|
390
480
|
};
|
|
391
|
-
// Named
|
|
392
|
-
//
|
|
393
|
-
// "0" in both — and `tailOf` picks the tail by highest numeric key.
|
|
394
|
-
// Merging them would let a fragment masquerade as the tail.
|
|
481
|
+
// Named only. Every component numbers positional groups from zero, so
|
|
482
|
+
// a merged "0" could masquerade as the tail. See #13.
|
|
395
483
|
for (const groups of [result.search.groups, result.hash.groups]) {
|
|
396
484
|
for (const [key, value] of Object.entries(groups)) {
|
|
397
485
|
if (!/^\d+$/.test(key)) {
|
|
@@ -405,12 +493,8 @@ export class Routes {
|
|
|
405
493
|
if (this.fallback === undefined) {
|
|
406
494
|
return undefined;
|
|
407
495
|
}
|
|
408
|
-
//
|
|
409
|
-
//
|
|
410
|
-
// match itself is done by hand rather than with a real `/*` pattern: a
|
|
411
|
-
// nested controller is handed its tail *without* a leading slash, which
|
|
412
|
-
// `/*` does not match, so a nested fallback matched nothing — empty
|
|
413
|
-
// 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.
|
|
414
498
|
const { pathname } = location;
|
|
415
499
|
const tail = pathname.startsWith('/') ? pathname.slice(1) : pathname;
|
|
416
500
|
return { route: { ...this.fallback, path: '/*' }, params: { 0: tail }, tail };
|
|
@@ -422,22 +506,15 @@ export class Routes {
|
|
|
422
506
|
this._onDisconnect = event.onDisconnect;
|
|
423
507
|
}
|
|
424
508
|
hostDisconnected() {
|
|
425
|
-
//
|
|
426
|
-
//
|
|
427
|
-
//
|
|
428
|
-
// claims the re-dispatching controller as *its* child and the pair point
|
|
429
|
-
// at each other — a real `_childRoutes` cycle, which recursive walks turn
|
|
430
|
-
// 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.
|
|
431
512
|
this._host.removeEventListener(RoutesConnectedEvent.eventName, this._onRoutesConnected);
|
|
432
|
-
// When this child routes controller is disconnected because a parent
|
|
433
|
-
// outlet rendered a different template, disconnecting will ensure that
|
|
434
|
-
// this controller doesn't receive a tail match meant for another route.
|
|
435
513
|
this._onDisconnect?.();
|
|
436
514
|
this._parentRoutes = undefined;
|
|
437
515
|
}
|
|
438
516
|
_onRoutesConnected = (e) => {
|
|
439
|
-
//
|
|
440
|
-
// because we do this.dispatchEvent(...)
|
|
517
|
+
// Ignore our own event, which we receive because we dispatch on the host.
|
|
441
518
|
if (e.routes === this) {
|
|
442
519
|
return;
|
|
443
520
|
}
|
|
@@ -451,17 +528,11 @@ export class Routes {
|
|
|
451
528
|
this._childRoutes.splice(index, 1);
|
|
452
529
|
}
|
|
453
530
|
};
|
|
454
|
-
//
|
|
455
|
-
// it — it missed the propagation loop in goto() that ran before it existed.
|
|
456
|
-
// With no tail there is nothing to catch up to, and `_routeChild` then only
|
|
457
|
-
// supersedes, a no-op on a freshly mounted child.
|
|
531
|
+
// Catch up a child that mounted after goto()'s propagation loop ran.
|
|
458
532
|
this._routeChild(childRoutes, this._currentTail);
|
|
459
533
|
};
|
|
460
534
|
}
|
|
461
|
-
/**
|
|
462
|
-
* This event is fired from Routes controllers when their host is connected to
|
|
463
|
-
* announce the child route and potentially connect to a parent routes controller.
|
|
464
|
-
*/
|
|
535
|
+
/** Announces a Routes controller to its parent when the host connects. */
|
|
465
536
|
export class RoutesConnectedEvent extends Event {
|
|
466
537
|
static eventName = 'lit-routes-connected';
|
|
467
538
|
routes;
|