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