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/src/routes.ts
CHANGED
|
@@ -27,40 +27,24 @@ 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 the pattern is limited to checking `pathname`, `search`
|
|
35
|
-
* and `hash`.
|
|
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
|
-
* The hash pattern string. `'*'` means the route places no constraint on the
|
|
60
|
-
* fragment, which is what `URLPattern` fills in for any component the caller
|
|
61
|
-
* omits. `Router` reads this to decide whether a fragment-only navigation is
|
|
62
|
-
* its business or the browser's.
|
|
63
|
-
*/
|
|
47
|
+
/** `'*'` means unconstrained. `Router` gates hash interception on this. */
|
|
64
48
|
readonly hash: string;
|
|
65
49
|
test(input: RouteLocation): boolean;
|
|
66
50
|
exec(input: RouteLocation): {
|
|
@@ -71,13 +55,9 @@ export interface URLPatternLike {
|
|
|
71
55
|
}
|
|
72
56
|
|
|
73
57
|
/**
|
|
74
|
-
* The parts of a location this router matches against
|
|
75
|
-
*
|
|
76
|
-
*
|
|
77
|
-
* `search` and `hash` carry no leading `?` or `#`. `URLPattern` canonicalises
|
|
78
|
-
* either form away on an init input, but only for a real `URLPattern` — the
|
|
79
|
-
* structural `URLPatternLike` above admits other implementations, so the
|
|
80
|
-
* delimiters are stripped here rather than left to the pattern.
|
|
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.
|
|
81
61
|
*/
|
|
82
62
|
export interface RouteLocation {
|
|
83
63
|
pathname: string;
|
|
@@ -86,11 +66,8 @@ export interface RouteLocation {
|
|
|
86
66
|
}
|
|
87
67
|
|
|
88
68
|
/**
|
|
89
|
-
*
|
|
90
|
-
*
|
|
91
|
-
* Deliberately not `new URL(path, origin)`: a nested controller's path is a
|
|
92
|
-
* *tail* — a bare relative segment like `abc` — which `URL` would resolve
|
|
93
|
-
* against the current directory and mangle.
|
|
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.
|
|
94
71
|
*/
|
|
95
72
|
const parseLocation = (path: string): RouteLocation => {
|
|
96
73
|
const hashIndex = path.indexOf('#');
|
|
@@ -111,16 +88,10 @@ const formatLocation = (
|
|
|
111
88
|
): string =>
|
|
112
89
|
pathname + (search === '' ? '' : `?${search}`) + (hash === '' ? '' : `#${hash}`);
|
|
113
90
|
|
|
114
|
-
/**
|
|
115
|
-
* A description of a route, which path or pattern to match against, and a
|
|
116
|
-
* render() callback used to render a match to the outlet.
|
|
117
|
-
*/
|
|
118
91
|
export type RouteConfig = PathRouteConfig | URLPatternRouteConfig;
|
|
119
92
|
|
|
120
|
-
//
|
|
121
|
-
//
|
|
122
|
-
// lets us make `routes` mutable so users can add new PathRouteConfigs
|
|
123
|
-
// dynamically.
|
|
93
|
+
// Keyed by config object so `routes` can stay a plain mutable array that users
|
|
94
|
+
// push new PathRouteConfigs onto.
|
|
124
95
|
const patternCache = new WeakMap<PathRouteConfig, URLPatternLike>();
|
|
125
96
|
|
|
126
97
|
const isPatternConfig = (route: RouteConfig): route is URLPatternRouteConfig =>
|
|
@@ -138,27 +109,17 @@ const getPattern = (route: RouteConfig): URLPatternLike => {
|
|
|
138
109
|
};
|
|
139
110
|
|
|
140
111
|
/**
|
|
141
|
-
*
|
|
142
|
-
* `
|
|
143
|
-
*
|
|
144
|
-
*
|
|
145
|
-
* optional (`*?`). Not a wildcard: an escaped `\*`, or a `*` that is the
|
|
146
|
-
* modifier on a group (`(\d+)*`, `{/}*`) or on a named param (`:rest*`).
|
|
147
|
-
* Those exclusions matter when an earlier positional group exists —
|
|
148
|
-
* `/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.
|
|
149
116
|
*/
|
|
150
117
|
const TRAILING_WILDCARD = /(?:(?<![\\)}]|:[\w$]+)\*|\{\*\})\??$/;
|
|
151
118
|
|
|
152
119
|
/**
|
|
153
|
-
*
|
|
154
|
-
*
|
|
155
|
-
*
|
|
156
|
-
* Decided from the pattern, not from the groups object: an unnamed regex group
|
|
157
|
-
* (`/post/(\d+)`) and a wildcard that is not last (`/foo/*` followed by
|
|
158
|
-
* `/bar`) are keyed by index exactly as a tail is, and reading either as one
|
|
159
|
-
* truncated `link()` and handed a child the wrong segment. When a trailing
|
|
160
|
-
* wildcard is present it is the last group in the pattern, so its key is the
|
|
161
|
-
* 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.
|
|
162
123
|
*/
|
|
163
124
|
const tailOf = (
|
|
164
125
|
route: RouteConfig,
|
|
@@ -169,8 +130,7 @@ const tailOf = (
|
|
|
169
130
|
}
|
|
170
131
|
let tailIndex = -1;
|
|
171
132
|
for (const key of Object.keys(params)) {
|
|
172
|
-
// Numeric, not lexicographic: '9'
|
|
173
|
-
// pattern with eleven or more wildcards picked group 9 as its tail.
|
|
133
|
+
// Numeric, not lexicographic: '9' > '10' as strings.
|
|
174
134
|
if (/^\d+$/.test(key) && Number(key) > tailIndex) {
|
|
175
135
|
tailIndex = Number(key);
|
|
176
136
|
}
|
|
@@ -178,6 +138,201 @@ const tailOf = (
|
|
|
178
138
|
return tailIndex < 0 ? undefined : params[String(tailIndex)];
|
|
179
139
|
};
|
|
180
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
|
+
|
|
181
336
|
/**
|
|
182
337
|
* A reactive controller that performs location-based routing using a
|
|
183
338
|
* configuration of URL patterns and associated render callbacks.
|
|
@@ -185,60 +340,28 @@ const tailOf = (
|
|
|
185
340
|
export class Routes implements ReactiveController {
|
|
186
341
|
private readonly _host: ReactiveControllerHost & HTMLElement;
|
|
187
342
|
|
|
188
|
-
|
|
189
|
-
* The
|
|
190
|
-
*
|
|
191
|
-
* This array is mutable. To dynamically add a new route you can write:
|
|
192
|
-
*
|
|
193
|
-
* ```ts
|
|
194
|
-
* this._routes.routes.push({
|
|
195
|
-
* path: '/foo',
|
|
196
|
-
* render: () => html`<p>Foo</p>`,
|
|
197
|
-
* });
|
|
198
|
-
* ```
|
|
199
|
-
*
|
|
200
|
-
* Mutating this property does not trigger any route transitions. If the
|
|
201
|
-
* changes may result is a different route matching for the current path, you
|
|
202
|
-
* 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.
|
|
203
346
|
*/
|
|
204
347
|
routes: Array<RouteConfig> = [];
|
|
205
348
|
|
|
206
349
|
/**
|
|
207
|
-
*
|
|
208
|
-
*
|
|
209
|
-
* pathname minus its leading slash, and is handed to child controllers as
|
|
210
|
-
* their tail. A nested controller's own pathname is a tail, with no leading
|
|
211
|
-
* 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.
|
|
212
352
|
*/
|
|
213
353
|
fallback?: BaseRouteConfig;
|
|
214
354
|
|
|
215
|
-
/*
|
|
216
|
-
* The current set of child Routes controllers. These are connected via
|
|
217
|
-
* the routes-connected event.
|
|
218
|
-
*/
|
|
219
355
|
private readonly _childRoutes: Array<Routes> = [];
|
|
220
356
|
|
|
221
357
|
private _parentRoutes: Routes | undefined;
|
|
222
358
|
|
|
223
|
-
/*
|
|
224
|
-
* State related to the current matching route.
|
|
225
|
-
*
|
|
226
|
-
* We keep this so that consuming code can access current parameters, and so
|
|
227
|
-
* that we can propagate tail matches to child routes if they are added after
|
|
228
|
-
* navigation / matching.
|
|
229
|
-
*/
|
|
230
359
|
/** Monotonic goto counter; see the last-goto-wins note in goto(). */
|
|
231
360
|
private _gotoSeq = 0;
|
|
232
361
|
|
|
233
362
|
private _currentPathname: string | undefined;
|
|
234
363
|
private _currentTail: string | undefined;
|
|
235
|
-
/**
|
|
236
|
-
* The search and hash of the current location.
|
|
237
|
-
*
|
|
238
|
-
* Ambient rather than tailed: only the pathname nests, so a child controller
|
|
239
|
-
* is handed the parent's tail as its pathname but the *same* search and
|
|
240
|
-
* hash. There is no meaningful way to split a fragment across a route tree.
|
|
241
|
-
*/
|
|
364
|
+
/** Ambient, not tailed: only the pathname nests. */
|
|
242
365
|
private _currentSearch = '';
|
|
243
366
|
private _currentHash = '';
|
|
244
367
|
private _currentRoute: RouteConfig | undefined;
|
|
@@ -246,12 +369,8 @@ export class Routes implements ReactiveController {
|
|
|
246
369
|
[key: string]: string | undefined;
|
|
247
370
|
} = {};
|
|
248
371
|
|
|
249
|
-
/**
|
|
250
|
-
*
|
|
251
|
-
*
|
|
252
|
-
* It's critical to call this immediately in hostDisconnected so that this
|
|
253
|
-
* controller instance doesn't receive a tail match meant for another route.
|
|
254
|
-
*/
|
|
372
|
+
/** Must run in hostDisconnected, or this controller can receive another
|
|
373
|
+
* route's tail match. */
|
|
255
374
|
// TODO (justinfagnani): Do we need this now that we have a direct reference
|
|
256
375
|
// to the parent? We can call `this._parentRoutes.disconnect(this)`.
|
|
257
376
|
private _onDisconnect: (() => void) | undefined;
|
|
@@ -282,17 +401,73 @@ export class Routes implements ReactiveController {
|
|
|
282
401
|
}
|
|
283
402
|
|
|
284
403
|
/**
|
|
285
|
-
*
|
|
404
|
+
* A URL for the route named `name`, with `params` substituted into its
|
|
405
|
+
* pattern.
|
|
286
406
|
*
|
|
287
|
-
*
|
|
288
|
-
*
|
|
289
|
-
*
|
|
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.
|
|
290
467
|
*
|
|
291
|
-
*
|
|
292
|
-
*
|
|
293
|
-
*
|
|
294
|
-
* and the outlet ends up on a route the URL has already left. `Router`
|
|
295
|
-
* 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.
|
|
296
471
|
*/
|
|
297
472
|
async goto(path: string, options?: {signal?: AbortSignal}) {
|
|
298
473
|
// TODO (justinfagnani): handle absolute vs relative paths separately.
|
|
@@ -300,23 +475,17 @@ export class Routes implements ReactiveController {
|
|
|
300
475
|
const location = parseLocation(path);
|
|
301
476
|
const {pathname} = location;
|
|
302
477
|
|
|
303
|
-
// Last-goto-wins
|
|
304
|
-
//
|
|
305
|
-
// so its
|
|
306
|
-
// navigation has already finished, and therefore with a signal that will
|
|
307
|
-
// never abort. Without this counter a slow first child load commits over a
|
|
308
|
-
// newer one. This also keeps `Routes` correct when used on its own, with
|
|
309
|
-
// no `Router` and no Navigation API in the picture.
|
|
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.
|
|
310
481
|
const seq = ++this._gotoSeq;
|
|
311
482
|
let tail: string | undefined;
|
|
312
483
|
|
|
313
484
|
if (this.routes.length === 0 && this.fallback === undefined) {
|
|
314
|
-
//
|
|
315
|
-
//
|
|
316
|
-
// match.
|
|
485
|
+
// No routes of its own acts as a single `/*`, passing the whole pathname
|
|
486
|
+
// on as a tail.
|
|
317
487
|
tail = pathname;
|
|
318
488
|
this._currentPathname = '';
|
|
319
|
-
// Simulate a tail group with the whole pathname
|
|
320
489
|
this._currentParams = {0: tail};
|
|
321
490
|
} else {
|
|
322
491
|
const match = this._match(location);
|
|
@@ -327,17 +496,14 @@ export class Routes implements ReactiveController {
|
|
|
327
496
|
tail = match.tail;
|
|
328
497
|
if (typeof route.enter === 'function') {
|
|
329
498
|
const success = await route.enter(params);
|
|
330
|
-
// If enter() returns false, cancel this navigation
|
|
331
499
|
if (success === false) {
|
|
332
500
|
return;
|
|
333
501
|
}
|
|
334
502
|
}
|
|
335
|
-
//
|
|
336
|
-
// Committing now would swap the outlet onto a route the URL has left.
|
|
503
|
+
// Superseded while `enter` awaited; committing would strand the outlet.
|
|
337
504
|
if (options?.signal?.aborted === true || seq !== this._gotoSeq) {
|
|
338
505
|
return;
|
|
339
506
|
}
|
|
340
|
-
// Only update route state if the enter handler completes successfully
|
|
341
507
|
this._currentRoute = route;
|
|
342
508
|
this._currentParams = params;
|
|
343
509
|
this._currentPathname =
|
|
@@ -349,24 +515,12 @@ export class Routes implements ReactiveController {
|
|
|
349
515
|
this._currentSearch = location.search;
|
|
350
516
|
this._currentHash = location.hash;
|
|
351
517
|
|
|
352
|
-
//
|
|
353
|
-
//
|
|
354
|
-
//
|
|
355
|
-
//
|
|
356
|
-
// over. At this point `requestUpdate()` has not run, so `_childRoutes`
|
|
357
|
-
// still holds the *outgoing* branch's controller: awaiting it gates the
|
|
358
|
-
// parent's outlet swap on an `enter()` for a tail that controller will
|
|
359
|
-
// never render (a hung one blocks the navigation forever), and if that
|
|
360
|
-
// child has no route for the new tail its `No route found` throw
|
|
361
|
-
// propagates out of here and `requestUpdate()` below never runs — URL
|
|
362
|
-
// committed, outlet stranded, i.e. this fork's own thesis bug one level
|
|
363
|
-
// down. Nested supersession is handled by the goto counter above, not by
|
|
364
|
-
// 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.
|
|
365
522
|
//
|
|
366
|
-
// Runs
|
|
367
|
-
// the children to render, but they must still be superseded — otherwise a
|
|
368
|
-
// child mid-`enter()` for the previous tail stays current and commits over
|
|
369
|
-
// a URL that has moved on.
|
|
523
|
+
// Runs even with no tail, so children still get superseded. See #7.
|
|
370
524
|
for (const childRoutes of this._childRoutes) {
|
|
371
525
|
this._routeChild(childRoutes, tail);
|
|
372
526
|
}
|
|
@@ -388,26 +542,12 @@ export class Routes implements ReactiveController {
|
|
|
388
542
|
}
|
|
389
543
|
|
|
390
544
|
/**
|
|
391
|
-
* Hands a tail match to a child
|
|
392
|
-
*
|
|
393
|
-
* input cannot be silent on one and an uncaught global throw on the other.
|
|
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.
|
|
394
547
|
*
|
|
395
|
-
* A child with no route for the
|
|
396
|
-
*
|
|
397
|
-
*
|
|
398
|
-
* a genuine `enter()` rejection still surfaces the way it does upstream.
|
|
399
|
-
* Skipping must still supersede: `goto()` is where the counter is bumped, so
|
|
400
|
-
* returning without it would leave an in-flight child navigation current,
|
|
401
|
-
* free to commit over a URL that has moved on. A parent route with no tail
|
|
402
|
-
* at all is the same case: nothing to route, but still something to stand
|
|
403
|
-
* down.
|
|
404
|
-
*
|
|
405
|
-
* No abort signal is threaded through, and the goto is deliberately not
|
|
406
|
-
* awaited. The parent commits its own state before children run, so a child
|
|
407
|
-
* handed an already-aborted signal stands down with no newer goto() arriving
|
|
408
|
-
* to correct it, leaving the nested outlet stuck — reachable, because a
|
|
409
|
-
* hash-only navigation aborts the outstanding one without producing a
|
|
410
|
-
* 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.
|
|
411
551
|
*/
|
|
412
552
|
private _routeChild(child: Routes, tail: string | undefined) {
|
|
413
553
|
if (tail === undefined) {
|
|
@@ -429,43 +569,26 @@ export class Routes implements ReactiveController {
|
|
|
429
569
|
});
|
|
430
570
|
}
|
|
431
571
|
|
|
432
|
-
/**
|
|
433
|
-
* Invalidate any in-flight `goto()` on this controller without starting a
|
|
434
|
-
* new one. Same-class access, so `_gotoSeq` stays private to `Routes`.
|
|
435
|
-
*/
|
|
572
|
+
/** Invalidate any in-flight `goto()` here without starting a new one. */
|
|
436
573
|
private _supersede(seen: Set<Routes> = new Set()): void {
|
|
437
|
-
//
|
|
438
|
-
//
|
|
439
|
-
// reconnected, ends up with each registered as the other's child — but
|
|
440
|
-
// `hostDisconnected` below removes the listener that causes it, and a test
|
|
441
|
-
// asserts the cycle cannot form. Kept because an unguarded recursive walk
|
|
442
|
-
// 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.
|
|
443
576
|
if (seen.has(this)) {
|
|
444
577
|
return;
|
|
445
578
|
}
|
|
446
579
|
seen.add(this);
|
|
447
580
|
this._gotoSeq++;
|
|
448
|
-
//
|
|
449
|
-
//
|
|
450
|
-
// without this an in-flight grandchild `enter()` stays current and commits
|
|
451
|
-
// 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.
|
|
452
583
|
for (const child of this._childRoutes) {
|
|
453
584
|
child._supersede(seen);
|
|
454
585
|
}
|
|
455
586
|
}
|
|
456
587
|
|
|
457
588
|
/**
|
|
458
|
-
* True when this controller
|
|
459
|
-
*
|
|
460
|
-
*
|
|
461
|
-
* `Router` gates interception of fragment-only navigation on this. Left
|
|
462
|
-
* ungated, a pathname-only app would have every in-page anchor swallowed and
|
|
463
|
-
* re-rendered instead of scrolled; gated, such an app behaves exactly as it
|
|
464
|
-
* did before hash routes existed.
|
|
465
|
-
*
|
|
466
|
-
* Walks children because a nested controller may route on the hash while the
|
|
467
|
-
* top-level `Router` does not — and only the top-level one sees the
|
|
468
|
-
* navigate event.
|
|
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.
|
|
469
592
|
*/
|
|
470
593
|
protected _constrainsHash(seen: Set<Routes> = new Set()): boolean {
|
|
471
594
|
// Cycle guard, matching `_supersede`; see the note there.
|
|
@@ -480,35 +603,25 @@ export class Routes implements ReactiveController {
|
|
|
480
603
|
}
|
|
481
604
|
|
|
482
605
|
/**
|
|
483
|
-
* True when this controller can render `path`
|
|
484
|
-
*
|
|
485
|
-
*
|
|
486
|
-
* `Router` gates interception on this: intercepting a path we cannot render
|
|
487
|
-
* commits the URL and then throws out of `goto()`, leaving the address bar
|
|
488
|
-
* moved and the outlet stale. Letting the browser handle it instead means a
|
|
489
|
-
* server-rendered page, an export endpoint, or a GET form still works.
|
|
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.
|
|
490
609
|
*/
|
|
491
610
|
hasRouteFor(path: string): boolean {
|
|
492
|
-
// A fallback matches everything, and
|
|
493
|
-
//
|
|
494
|
-
// way the answer is yes without running a single pattern — worth
|
|
495
|
-
// short-circuiting, since `Router` asks this on every navigation.
|
|
611
|
+
// A fallback matches everything, and no routes behaves as one `/*`. Worth
|
|
612
|
+
// short-circuiting: `Router` asks this on every navigation.
|
|
496
613
|
if (this.fallback !== undefined || this.routes.length === 0) {
|
|
497
614
|
return true;
|
|
498
615
|
}
|
|
499
|
-
// `test()`, not `_match()`:
|
|
500
|
-
//
|
|
616
|
+
// `test()`, not `_match()`: `exec()` pays ~8x on a hit to build groups the
|
|
617
|
+
// caller would throw away.
|
|
501
618
|
const location = parseLocation(path);
|
|
502
619
|
return this.routes.some((r) => getPattern(r).test(location));
|
|
503
620
|
}
|
|
504
621
|
|
|
505
622
|
/**
|
|
506
|
-
*
|
|
507
|
-
*
|
|
508
|
-
*
|
|
509
|
-
* One `exec()` per candidate rather than `test()` to select and `exec()` to
|
|
510
|
-
* extract: that ran the winning pattern twice, and every caller that wants a
|
|
511
|
-
* 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.
|
|
512
625
|
*/
|
|
513
626
|
private _match(location: RouteLocation):
|
|
514
627
|
| {
|
|
@@ -523,10 +636,8 @@ export class Routes implements ReactiveController {
|
|
|
523
636
|
const params: {[key: string]: string | undefined} = {
|
|
524
637
|
...result.pathname.groups,
|
|
525
638
|
};
|
|
526
|
-
// Named
|
|
527
|
-
//
|
|
528
|
-
// "0" in both — and `tailOf` picks the tail by highest numeric key.
|
|
529
|
-
// Merging them would let a fragment masquerade as the tail.
|
|
639
|
+
// Named only. Every component numbers positional groups from zero, so
|
|
640
|
+
// a merged "0" could masquerade as the tail. See #13.
|
|
530
641
|
for (const groups of [result.search.groups, result.hash.groups]) {
|
|
531
642
|
for (const [key, value] of Object.entries(groups)) {
|
|
532
643
|
if (!/^\d+$/.test(key)) {
|
|
@@ -540,12 +651,8 @@ export class Routes implements ReactiveController {
|
|
|
540
651
|
if (this.fallback === undefined) {
|
|
541
652
|
return undefined;
|
|
542
653
|
}
|
|
543
|
-
//
|
|
544
|
-
//
|
|
545
|
-
// match itself is done by hand rather than with a real `/*` pattern: a
|
|
546
|
-
// nested controller is handed its tail *without* a leading slash, which
|
|
547
|
-
// `/*` does not match, so a nested fallback matched nothing — empty
|
|
548
|
-
// 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.
|
|
549
656
|
const {pathname} = location;
|
|
550
657
|
const tail = pathname.startsWith('/') ? pathname.slice(1) : pathname;
|
|
551
658
|
return {route: {...this.fallback, path: '/*'}, params: {0: tail}, tail};
|
|
@@ -562,26 +669,19 @@ export class Routes implements ReactiveController {
|
|
|
562
669
|
}
|
|
563
670
|
|
|
564
671
|
hostDisconnected() {
|
|
565
|
-
//
|
|
566
|
-
//
|
|
567
|
-
//
|
|
568
|
-
// claims the re-dispatching controller as *its* child and the pair point
|
|
569
|
-
// at each other — a real `_childRoutes` cycle, which recursive walks turn
|
|
570
|
-
// 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.
|
|
571
675
|
this._host.removeEventListener(
|
|
572
676
|
RoutesConnectedEvent.eventName,
|
|
573
677
|
this._onRoutesConnected
|
|
574
678
|
);
|
|
575
|
-
// When this child routes controller is disconnected because a parent
|
|
576
|
-
// outlet rendered a different template, disconnecting will ensure that
|
|
577
|
-
// this controller doesn't receive a tail match meant for another route.
|
|
578
679
|
this._onDisconnect?.();
|
|
579
680
|
this._parentRoutes = undefined;
|
|
580
681
|
}
|
|
581
682
|
|
|
582
683
|
private _onRoutesConnected = (e: RoutesConnectedEvent) => {
|
|
583
|
-
//
|
|
584
|
-
// because we do this.dispatchEvent(...)
|
|
684
|
+
// Ignore our own event, which we receive because we dispatch on the host.
|
|
585
685
|
if (e.routes === this) {
|
|
586
686
|
return;
|
|
587
687
|
}
|
|
@@ -598,18 +698,12 @@ export class Routes implements ReactiveController {
|
|
|
598
698
|
}
|
|
599
699
|
};
|
|
600
700
|
|
|
601
|
-
//
|
|
602
|
-
// it — it missed the propagation loop in goto() that ran before it existed.
|
|
603
|
-
// With no tail there is nothing to catch up to, and `_routeChild` then only
|
|
604
|
-
// supersedes, a no-op on a freshly mounted child.
|
|
701
|
+
// Catch up a child that mounted after goto()'s propagation loop ran.
|
|
605
702
|
this._routeChild(childRoutes, this._currentTail);
|
|
606
703
|
};
|
|
607
704
|
}
|
|
608
705
|
|
|
609
|
-
/**
|
|
610
|
-
* This event is fired from Routes controllers when their host is connected to
|
|
611
|
-
* announce the child route and potentially connect to a parent routes controller.
|
|
612
|
-
*/
|
|
706
|
+
/** Announces a Routes controller to its parent when the host connects. */
|
|
613
707
|
export class RoutesConnectedEvent extends Event {
|
|
614
708
|
static readonly eventName = 'lit-routes-connected';
|
|
615
709
|
readonly routes: Routes;
|