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/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
- * While `URLPattern` can match against protocols, hostnames, and ports,
33
- * routes will only be checked for matches if they're part of the current
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
- * Declared structurally rather than referencing the global so the emitted
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, split out of a path
75
- * string by `parseLocation`.
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
- * Splits `path` into the components a pattern is matched against.
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
- // A cache of URLPatterns created for PathRouteConfig.
121
- // Rather than converting all given RoutConfigs to URLPatternRouteConfig, this
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
- * Matches a pathname pattern that ends in a wildcard, in the forms
142
- * `URLPattern.prototype.pathname` regenerates one: a bare `*` — which a
143
- * trailing `(.*)` also normalises to — or `{*}`, which the generator emits for
144
- * a wildcard after a modified group, e.g. `/docs{/}?*`. Either may be
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
- * The tail of a match — what a trailing wildcard (`/foo/*`) captured — or
154
- * undefined when the pattern has none.
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' sorts above '10' as a string, so a
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 currently installed set of routes in precedence order.
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
- * A default fallback route which will always be matched if none of the
208
- * {@link routes} match. Behaves like a `/*` route: `params[0]` is the whole
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
- * Callback to call when this controller is disconnected.
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
- * Navigates this routes controller to `pathname`.
404
+ * A URL for the route named `name`, with `params` substituted into its
405
+ * pattern.
286
406
  *
287
- * This does not navigate parent routes, so it isn't (yet) a general page
288
- * navigation API. It does navigate child routes if pathname matches a
289
- * pattern with a tail wildcard pattern (`/*`).
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
- * Pass `options.signal` to make the navigation abandonable. `enter()` is
292
- * awaited, so a second `goto()` can start — and finish — while the first is
293
- * still resolving its route; without a signal the slower one commits last
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, per controller. The navigation signal alone is not
304
- // enough: a child controller mounts as a *result* of its parent's render,
305
- // so its first goto() comes from `_onRoutesConnected` — after the parent's
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
- // If a routes controller has none of its own routes it acts like it has
315
- // one route of `/*` so that it passes the whole pathname as a tail
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
- // A newer navigation superseded this one while `enter` was awaiting.
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
- // Propagate the tail match to children — deliberately NOT awaited.
353
- //
354
- // Awaiting looks like it would make `navigation.finished` cover the whole
355
- // tree, and an earlier revision of this fork did it. It is wrong twice
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 whether or not there is a tail. A route without one has nothing for
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 controller. Shared by the propagation loop in
392
- * `goto()` and the late-mount path in `_onRoutesConnected`, so that identical
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 new tail is the expected case, not an error —
396
- * the outgoing branch mid-swap, or a deep link to a path the child cannot
397
- * render. Filtered structurally rather than by swallowing every rejection, so
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
- // Unreachable defence in depth. Upstream *can* produce a `_childRoutes`
438
- // cycle — a host carrying two Routes controllers, disconnected and
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
- // Recursive: on the navigating branch the child's own propagation loop
449
- // reaches the grandchildren, but a skipped child never runs one — so
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, or any controller below it, has a route that
459
- * constrains the hash.
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` — i.e. a route matches, or
484
- * a fallback is configured.
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 a controller with no routes of its own
493
- // behaves as if it had a single `/*` route (goto()'s special case). Either
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()`: this only needs the yes/no, and `exec()` pays
500
- // ~8x on a hit to build a groups object the caller would throw away.
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
- * Matches `pathname` against the installed routes and returns the first match
507
- * with its parsed parameters, or the fallback's match if one is configured.
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 groups only. A positional group is keyed by index in every
527
- // component independently, so `/child/*` with a hash of `*` yields a
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
- // The fallback route behaves like it has a "/*" path. This is hidden from
544
- // the public API; the `path` is there to return a valid RouteConfig. The
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
- // Remove the listener hostConnected added. Without this a host that is
566
- // disconnected and reconnected (a repeat() reorder, a tab swap) leaves the
567
- // sibling controller's listener installed, so on the second connect it
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
- // Don't handle the event fired by this routes controller, which we get
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
- // A child that mounts under an existing tail match has to be caught up to
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;