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/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
- * 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 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
- * 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
- test(input: {pathname: string}): boolean;
59
- exec(input: {pathname: string}): {
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
- * A description of a route, which path or pattern to match against, and a
66
- * render() callback used to render a match to the outlet.
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
- // A cache of URLPatterns created for PathRouteConfig.
71
- // Rather than converting all given RoutConfigs to URLPatternRouteConfig, this
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
- * Matches a pathname pattern that ends in a wildcard, in the forms
92
- * `URLPattern.prototype.pathname` regenerates one: a bare `*` — which a
93
- * trailing `(.*)` also normalises to — or `{*}`, which the generator emits for
94
- * a wildcard after a modified group, e.g. `/docs{/}?*`. Either may be
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
- * The tail of a match — what a trailing wildcard (`/foo/*`) captured — or
104
- * undefined when the pattern has none.
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' sorts above '10' as a string, so a
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 currently installed set of routes in precedence order.
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
- * A default fallback route which will always be matched if none of the
158
- * {@link routes} match. Behaves like a `/*` route: `params[0]` is the whole
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
- * Callback to call when this controller is disconnected.
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
- * Navigates this routes controller to `pathname`.
404
+ * A URL for the route named `name`, with `params` substituted into its
405
+ * pattern.
227
406
  *
228
- * This does not navigate parent routes, so it isn't (yet) a general page
229
- * navigation API. It does navigate child routes if pathname matches a
230
- * 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.
231
467
  *
232
- * Pass `options.signal` to make the navigation abandonable. `enter()` is
233
- * awaited, so a second `goto()` can start — and finish — while the first is
234
- * still resolving its route; without a signal the slower one commits last
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(pathname: string, options?: {signal?: AbortSignal}) {
472
+ async goto(path: string, options?: {signal?: AbortSignal}) {
239
473
  // TODO (justinfagnani): handle absolute vs relative paths separately.
240
474
 
241
- // TODO (justinfagnani): generalize this to handle query params and
242
- // fragments. It currently only handles path names because it's easier to
243
- // completely disregard the origin for now. The click handler only does
244
- // an in-page navigation if the origin matches anyway.
245
- // Last-goto-wins, per controller. The navigation signal alone is not
246
- // enough: a child controller mounts as a *result* of its parent's render,
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
- // If a routes controller has none of its own routes it acts like it has
257
- // one route of `/*` so that it passes the whole pathname as a tail
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(pathname);
491
+ const match = this._match(location);
265
492
  if (match === undefined) {
266
- throw new Error(`No route found for ${pathname}`);
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
- // A newer navigation superseded this one while `enter` was awaiting.
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
- // Propagate the tail match to children — deliberately NOT awaited.
293
- //
294
- // Awaiting looks like it would make `navigation.finished` cover the whole
295
- // tree, and an earlier revision of this fork did it. It is wrong twice
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 whether or not there is a tail. A route without one has nothing for
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 controller. Shared by the propagation loop in
332
- * `goto()` and the late-mount path in `_onRoutesConnected`, so that identical
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
- * No abort signal is threaded through, and the goto is deliberately not
346
- * awaited. The parent commits its own state before children run, so a child
347
- * handed an already-aborted signal stands down with no newer goto() arriving
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 || !child.hasRouteFor(tail)) {
553
+ if (tail === undefined) {
354
554
  child._supersede();
355
555
  return;
356
556
  }
357
- void child.goto(tail).catch((err) => {
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
- // Unreachable defence in depth. Upstream *can* produce a `_childRoutes`
370
- // cycle — a host carrying two Routes controllers, disconnected and
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
- // Recursive: on the navigating branch the child's own propagation loop
381
- // reaches the grandchildren, but a skipped child never runs one — so
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 can render `pathname` — i.e. a route matches, or
391
- * a fallback is configured.
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
- hasRouteFor(pathname: string): boolean {
399
- // A fallback matches everything, and a controller with no routes of its own
400
- // behaves as if it had a single `/*` route (goto()'s special case). Either
401
- // way the answer is yes without running a single pattern — worth
402
- // short-circuiting, since `Router` asks this on every navigation.
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()`: this only needs the yes/no, and `exec()` pays
407
- // ~8x on a hit to build a groups object the caller would throw away.
408
- return this.routes.some((r) => getPattern(r).test({pathname}));
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
- * Matches `pathname` against the installed routes and returns the first match
413
- * with its parsed parameters, or the fallback's match if one is configured.
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(pathname: string):
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({pathname});
634
+ const result = getPattern(route).exec(location);
428
635
  if (result !== null) {
429
- const params = result.pathname.groups;
430
- return {route, params, tail: tailOf(route, params)};
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
- // The fallback route behaves like it has a "/*" path. This is hidden from
437
- // the public API; the `path` is there to return a valid RouteConfig. The
438
- // match itself is done by hand rather than with a real `/*` pattern: a
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
- // Remove the listener hostConnected added. Without this a host that is
458
- // disconnected and reconnected (a repeat() reorder, a tab swap) leaves the
459
- // sibling controller's listener installed, so on the second connect it
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
- // Don't handle the event fired by this routes controller, which we get
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
- // A child that mounts under an existing tail match has to be caught up to
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;