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.
@@ -6,11 +6,8 @@
6
6
  * Modifications Copyright 2026 VanLandingham Labs, same license. See NOTICE.md.
7
7
  */
8
8
  /**
9
- * Splits `path` into the components a pattern is matched against.
10
- *
11
- * Deliberately not `new URL(path, origin)`: a nested controller's path is a
12
- * *tail* — a bare relative segment like `abc` — which `URL` would resolve
13
- * against the current directory and mangle.
9
+ * Not `new URL(path, origin)`: a nested controller's path is a tail, a bare
10
+ * relative segment like `abc`, which `URL` would resolve and mangle.
14
11
  */
15
12
  const parseLocation = (path) => {
16
13
  const hashIndex = path.indexOf('#');
@@ -25,10 +22,8 @@ const parseLocation = (path) => {
25
22
  };
26
23
  /** Re-attaches `search` and `hash` to a pathname. Inverse of `parseLocation`. */
27
24
  const formatLocation = (pathname, { search, hash }) => pathname + (search === '' ? '' : `?${search}`) + (hash === '' ? '' : `#${hash}`);
28
- // A cache of URLPatterns created for PathRouteConfig.
29
- // Rather than converting all given RoutConfigs to URLPatternRouteConfig, this
30
- // lets us make `routes` mutable so users can add new PathRouteConfigs
31
- // dynamically.
25
+ // Keyed by config object so `routes` can stay a plain mutable array that users
26
+ // push new PathRouteConfigs onto.
32
27
  const patternCache = new WeakMap();
33
28
  const isPatternConfig = (route) => route.pattern !== undefined;
34
29
  const getPattern = (route) => {
@@ -42,26 +37,16 @@ const getPattern = (route) => {
42
37
  return pattern;
43
38
  };
44
39
  /**
45
- * Matches a pathname pattern that ends in a wildcard, in the forms
46
- * `URLPattern.prototype.pathname` regenerates one: a bare `*` — which a
47
- * trailing `(.*)` also normalises to — or `{*}`, which the generator emits for
48
- * a wildcard after a modified group, e.g. `/docs{/}?*`. Either may be
49
- * optional (`*?`). Not a wildcard: an escaped `\*`, or a `*` that is the
50
- * modifier on a group (`(\d+)*`, `{/}*`) or on a named param (`:rest*`).
51
- * Those exclusions matter when an earlier positional group exists —
52
- * `/x/(\d+)/:rest*` — since that group would otherwise be taken for the tail.
40
+ * A trailing wildcard as `URLPattern.prototype.pathname` regenerates it: `*` or
41
+ * `{*}`, optionally `?`. Excludes an escaped `\*` and a `*` modifying a group
42
+ * or named param (`(\d+)*`, `{/}*`, `:rest*`), which would otherwise let an
43
+ * earlier group be taken for the tail. See #4.
53
44
  */
54
45
  const TRAILING_WILDCARD = /(?:(?<![\\)}]|:[\w$]+)\*|\{\*\})\??$/;
55
46
  /**
56
- * The tail of a match — what a trailing wildcard (`/foo/*`) captured — or
57
- * undefined when the pattern has none.
58
- *
59
- * Decided from the pattern, not from the groups object: an unnamed regex group
60
- * (`/post/(\d+)`) and a wildcard that is not last (`/foo/*` followed by
61
- * `/bar`) are keyed by index exactly as a tail is, and reading either as one
62
- * truncated `link()` and handed a child the wrong segment. When a trailing
63
- * wildcard is present it is the last group in the pattern, so its key is the
64
- * highest positional index.
47
+ * What a trailing wildcard captured, or undefined when there is none. Decided
48
+ * from the pattern, not the groups object: an unnamed group (`/post/(\d+)`) and
49
+ * a non-final wildcard are keyed by index exactly as a tail is. See #4.
65
50
  */
66
51
  const tailOf = (route, params) => {
67
52
  if (!TRAILING_WILDCARD.test(getPattern(route).pathname)) {
@@ -69,79 +54,201 @@ const tailOf = (route, params) => {
69
54
  }
70
55
  let tailIndex = -1;
71
56
  for (const key of Object.keys(params)) {
72
- // Numeric, not lexicographic: '9' sorts above '10' as a string, so a
73
- // pattern with eleven or more wildcards picked group 9 as its tail.
57
+ // Numeric, not lexicographic: '9' > '10' as strings.
74
58
  if (/^\d+$/.test(key) && Number(key) > tailIndex) {
75
59
  tailIndex = Number(key);
76
60
  }
77
61
  }
78
62
  return tailIndex < 0 ? undefined : params[String(tailIndex)];
79
63
  };
64
+ /**
65
+ * Parses the subset of `URLPattern` pathname syntax that can be run backwards:
66
+ * literals, `:name`, `*`, and `{...}` groups, each optionally `?`.
67
+ *
68
+ * A regex group cannot be reversed, and `+` or `*` repetition has no single
69
+ * answer, so both throw rather than guess. Named the route, not the pattern,
70
+ * because the caller passed a name and that is what they can act on.
71
+ */
72
+ const parsePattern = (pattern, name) => {
73
+ let i = 0;
74
+ let positional = 0;
75
+ const takeModifier = () => {
76
+ const mod = pattern[i];
77
+ if (mod === '?') {
78
+ i++;
79
+ return true;
80
+ }
81
+ if (mod === '+' || mod === '*') {
82
+ throw new Error(`Cannot build a link to '${name}': the repeating modifier '${mod}' in ` +
83
+ `'${pattern}' has no single reverse.`);
84
+ }
85
+ return false;
86
+ };
87
+ const parseNodes = (untilBrace) => {
88
+ const nodes = [];
89
+ let literal = '';
90
+ const flush = () => {
91
+ if (literal !== '') {
92
+ nodes.push({ kind: 'literal', text: literal });
93
+ literal = '';
94
+ }
95
+ };
96
+ while (i < pattern.length) {
97
+ const c = pattern[i];
98
+ if (untilBrace && c === '}') {
99
+ break;
100
+ }
101
+ if (c === '\\') {
102
+ literal += pattern[i + 1] ?? '';
103
+ i += 2;
104
+ continue;
105
+ }
106
+ if (c === '(') {
107
+ throw new Error(`Cannot build a link to '${name}': '${pattern}' contains a regular ` +
108
+ `expression group, which cannot be reversed. Use a named parameter.`);
109
+ }
110
+ if (c === '{') {
111
+ flush();
112
+ i++;
113
+ const inner = parseNodes(true);
114
+ if (pattern[i] !== '}') {
115
+ throw new Error(`Cannot build a link to '${name}': unbalanced '{' in '${pattern}'.`);
116
+ }
117
+ i++;
118
+ nodes.push({ kind: 'group', nodes: inner, optional: takeModifier() });
119
+ continue;
120
+ }
121
+ const named = c === ':' ? /^:([A-Za-z0-9_$]+)/.exec(pattern.slice(i)) : null;
122
+ if (named !== null) {
123
+ flush();
124
+ i += named[0].length;
125
+ nodes.push({ kind: 'param', name: named[1], optional: takeModifier() });
126
+ continue;
127
+ }
128
+ if (c === '*') {
129
+ flush();
130
+ i++;
131
+ nodes.push({
132
+ kind: 'wildcard',
133
+ index: positional++,
134
+ optional: takeModifier(),
135
+ });
136
+ continue;
137
+ }
138
+ literal += c;
139
+ i++;
140
+ }
141
+ flush();
142
+ return nodes;
143
+ };
144
+ return parseNodes(false);
145
+ };
146
+ /** The highest wildcard index in `nodes`, or -1 when there is none. */
147
+ const maxWildcard = (nodes) => nodes.reduce((max, node) => node.kind === 'wildcard'
148
+ ? Math.max(max, node.index)
149
+ : node.kind === 'group'
150
+ ? Math.max(max, maxWildcard(node.nodes))
151
+ : max, -1);
152
+ /**
153
+ * Builds a pathname from `nodes`, collecting the names of any parameters the
154
+ * caller did not supply.
155
+ *
156
+ * `tailIndex` is the wildcard that a trailing `/*` produced, or -1. That one
157
+ * may be left out: an empty tail is the index of a nested route space, which
158
+ * is what `linkTo('docs')` should mean. Any other wildcard is required, since
159
+ * dropping it would silently join the segments around it.
160
+ */
161
+ const fillNodes = (nodes, params, tailIndex, missing) => {
162
+ let out = '';
163
+ for (const node of nodes) {
164
+ switch (node.kind) {
165
+ case 'literal':
166
+ out += node.text;
167
+ break;
168
+ case 'param': {
169
+ const value = params[node.name];
170
+ if (value === undefined) {
171
+ if (!node.optional) {
172
+ missing.push(node.name);
173
+ }
174
+ }
175
+ else {
176
+ out += value;
177
+ }
178
+ break;
179
+ }
180
+ case 'wildcard': {
181
+ const value = params[String(node.index)];
182
+ if (value === undefined) {
183
+ if (!node.optional && node.index !== tailIndex) {
184
+ missing.push(String(node.index));
185
+ }
186
+ }
187
+ else {
188
+ out += value;
189
+ }
190
+ break;
191
+ }
192
+ case 'group': {
193
+ const before = missing.length;
194
+ const text = fillNodes(node.nodes, params, tailIndex, missing);
195
+ if (missing.length > before && node.optional) {
196
+ // The whole point of an optional group: drop it, and the parameters
197
+ // it wanted stop being missing.
198
+ missing.length = before;
199
+ }
200
+ else {
201
+ out += text;
202
+ }
203
+ break;
204
+ }
205
+ }
206
+ }
207
+ return out;
208
+ };
209
+ /** Runs a pathname pattern backwards. Throws naming every missing parameter. */
210
+ const fillPattern = (pattern, params, name) => {
211
+ const nodes = parsePattern(pattern, name);
212
+ const missing = [];
213
+ const tailIndex = TRAILING_WILDCARD.test(pattern) ? maxWildcard(nodes) : -1;
214
+ const text = fillNodes(nodes, params, tailIndex, missing);
215
+ if (missing.length > 0) {
216
+ throw new Error(`Cannot build a link to '${name}': missing parameter` +
217
+ `${missing.length > 1 ? 's' : ''} ` +
218
+ missing.map((m) => `'${m}'`).join(', ') +
219
+ ` for pattern '${pattern}'.`);
220
+ }
221
+ return text;
222
+ };
80
223
  /**
81
224
  * A reactive controller that performs location-based routing using a
82
225
  * configuration of URL patterns and associated render callbacks.
83
226
  */
84
227
  export class Routes {
85
228
  _host;
86
- /*
87
- * The currently installed set of routes in precedence order.
88
- *
89
- * This array is mutable. To dynamically add a new route you can write:
90
- *
91
- * ```ts
92
- * this._routes.routes.push({
93
- * path: '/foo',
94
- * render: () => html`<p>Foo</p>`,
95
- * });
96
- * ```
97
- *
98
- * Mutating this property does not trigger any route transitions. If the
99
- * changes may result is a different route matching for the current path, you
100
- * must instigate a route update with `goto()`.
229
+ /**
230
+ * The installed routes, in precedence order. Mutable, but mutating it starts
231
+ * no route transition; call `goto()` if a different route now matches.
101
232
  */
102
233
  routes = [];
103
234
  /**
104
- * A default fallback route which will always be matched if none of the
105
- * {@link routes} match. Behaves like a `/*` route: `params[0]` is the whole
106
- * pathname minus its leading slash, and is handed to child controllers as
107
- * their tail. A nested controller's own pathname is a tail, with no leading
108
- * slash; the fallback accepts that too.
235
+ * Matched when no route in {@link routes} does. Behaves like `/*`, so
236
+ * `params[0]` is the whole pathname minus any leading slash.
109
237
  */
110
238
  fallback;
111
- /*
112
- * The current set of child Routes controllers. These are connected via
113
- * the routes-connected event.
114
- */
115
239
  _childRoutes = [];
116
240
  _parentRoutes;
117
- /*
118
- * State related to the current matching route.
119
- *
120
- * We keep this so that consuming code can access current parameters, and so
121
- * that we can propagate tail matches to child routes if they are added after
122
- * navigation / matching.
123
- */
124
241
  /** Monotonic goto counter; see the last-goto-wins note in goto(). */
125
242
  _gotoSeq = 0;
126
243
  _currentPathname;
127
244
  _currentTail;
128
- /**
129
- * The search and hash of the current location.
130
- *
131
- * Ambient rather than tailed: only the pathname nests, so a child controller
132
- * is handed the parent's tail as its pathname but the *same* search and
133
- * hash. There is no meaningful way to split a fragment across a route tree.
134
- */
245
+ /** Ambient, not tailed: only the pathname nests. */
135
246
  _currentSearch = '';
136
247
  _currentHash = '';
137
248
  _currentRoute;
138
249
  _currentParams = {};
139
- /**
140
- * Callback to call when this controller is disconnected.
141
- *
142
- * It's critical to call this immediately in hostDisconnected so that this
143
- * controller instance doesn't receive a tail match meant for another route.
144
- */
250
+ /** Must run in hostDisconnected, or this controller can receive another
251
+ * route's tail match. */
145
252
  // TODO (justinfagnani): Do we need this now that we have a direct reference
146
253
  // to the parent? We can call `this._parentRoutes.disconnect(this)`.
147
254
  _onDisconnect;
@@ -165,38 +272,77 @@ export class Routes {
165
272
  return (this._parentRoutes?.link() ?? '') + pathname;
166
273
  }
167
274
  /**
168
- * Navigates this routes controller to `pathname`.
275
+ * A URL for the route named `name`, with `params` substituted into its
276
+ * pattern.
169
277
  *
170
- * This does not navigate parent routes, so it isn't (yet) a general page
171
- * navigation API. It does navigate child routes if pathname matches a
172
- * pattern with a tail wildcard pattern (`/*`).
278
+ * Lets a component say which route it means instead of where that route
279
+ * currently lives, so moving a route subtree does not silently break every
280
+ * hardcoded link inside it.
281
+ *
282
+ * Resolution covers the mounted controller tree: this controller, its
283
+ * ancestors, and any descendant that has rendered. A route in a branch that
284
+ * has not mounted yet is not addressable, because the mapping from a parent
285
+ * route to its child controller only exists once that parent has rendered.
286
+ * An unknown or ambiguous name throws rather than producing a wrong URL.
287
+ */
288
+ linkTo(name, params = {}) {
289
+ let root = this;
290
+ while (root._parentRoutes !== undefined) {
291
+ root = root._parentRoutes;
292
+ }
293
+ const found = [];
294
+ const visit = (routes, seen) => {
295
+ // Cycle guard, matching `_supersede`; see the note there.
296
+ if (seen.has(routes)) {
297
+ return;
298
+ }
299
+ seen.add(routes);
300
+ for (const route of routes.routes) {
301
+ if (route.name === name) {
302
+ found.push({ owner: routes, route });
303
+ }
304
+ }
305
+ for (const child of routes._childRoutes) {
306
+ visit(child, seen);
307
+ }
308
+ };
309
+ visit(root, new Set());
310
+ if (found.length === 0) {
311
+ throw new Error(`No route named '${name}' in the mounted route tree. Names are only ` +
312
+ `resolvable once the controller holding them has rendered.`);
313
+ }
314
+ if (found.length > 1) {
315
+ throw new Error(`More than one route named '${name}' in the mounted route tree.`);
316
+ }
317
+ const { owner, route } = found[0];
318
+ // The owner's own segment is being replaced by the generated one, so the
319
+ // prefix is everything above it.
320
+ const prefix = owner._parentRoutes?.link() ?? '';
321
+ return prefix + fillPattern(getPattern(route).pathname, params, name);
322
+ }
323
+ /**
324
+ * Navigates this controller to `path`, which may carry a search and hash.
325
+ * Navigates child routes but not parent ones, so it is not yet a general page
326
+ * navigation API.
173
327
  *
174
- * Pass `options.signal` to make the navigation abandonable. `enter()` is
175
- * awaited, so a second `goto()` can start — and finish — while the first is
176
- * still resolving its route; without a signal the slower one commits last
177
- * and the outlet ends up on a route the URL has already left. `Router`
178
- * threads `NavigateEvent.signal` through for exactly this reason.
328
+ * `options.signal` makes the navigation abandonable. `enter()` is awaited, so
329
+ * a second `goto()` can finish while the first is still resolving; without a
330
+ * signal the slower one commits last onto a route the URL has left.
179
331
  */
180
332
  async goto(path, options) {
181
333
  // TODO (justinfagnani): handle absolute vs relative paths separately.
182
334
  const location = parseLocation(path);
183
335
  const { pathname } = location;
184
- // Last-goto-wins, per controller. The navigation signal alone is not
185
- // enough: a child controller mounts as a *result* of its parent's render,
186
- // so its first goto() comes from `_onRoutesConnected` — after the parent's
187
- // navigation has already finished, and therefore with a signal that will
188
- // never abort. Without this counter a slow first child load commits over a
189
- // newer one. This also keeps `Routes` correct when used on its own, with
190
- // no `Router` and no Navigation API in the picture.
336
+ // Last-goto-wins. The signal alone is not enough: a child's first goto()
337
+ // comes from `_onRoutesConnected`, after the parent's navigation finished,
338
+ // so its signal never aborts. Also keeps `Routes` correct standalone.
191
339
  const seq = ++this._gotoSeq;
192
340
  let tail;
193
341
  if (this.routes.length === 0 && this.fallback === undefined) {
194
- // If a routes controller has none of its own routes it acts like it has
195
- // one route of `/*` so that it passes the whole pathname as a tail
196
- // match.
342
+ // No routes of its own acts as a single `/*`, passing the whole pathname
343
+ // on as a tail.
197
344
  tail = pathname;
198
345
  this._currentPathname = '';
199
- // Simulate a tail group with the whole pathname
200
346
  this._currentParams = { 0: tail };
201
347
  }
202
348
  else {
@@ -208,17 +354,14 @@ export class Routes {
208
354
  tail = match.tail;
209
355
  if (typeof route.enter === 'function') {
210
356
  const success = await route.enter(params);
211
- // If enter() returns false, cancel this navigation
212
357
  if (success === false) {
213
358
  return;
214
359
  }
215
360
  }
216
- // A newer navigation superseded this one while `enter` was awaiting.
217
- // Committing now would swap the outlet onto a route the URL has left.
361
+ // Superseded while `enter` awaited; committing would strand the outlet.
218
362
  if (options?.signal?.aborted === true || seq !== this._gotoSeq) {
219
363
  return;
220
364
  }
221
- // Only update route state if the enter handler completes successfully
222
365
  this._currentRoute = route;
223
366
  this._currentParams = params;
224
367
  this._currentPathname =
@@ -229,24 +372,12 @@ export class Routes {
229
372
  this._currentTail = tail;
230
373
  this._currentSearch = location.search;
231
374
  this._currentHash = location.hash;
232
- // Propagate the tail match to children — deliberately NOT awaited.
233
- //
234
- // Awaiting looks like it would make `navigation.finished` cover the whole
235
- // tree, and an earlier revision of this fork did it. It is wrong twice
236
- // over. At this point `requestUpdate()` has not run, so `_childRoutes`
237
- // still holds the *outgoing* branch's controller: awaiting it gates the
238
- // parent's outlet swap on an `enter()` for a tail that controller will
239
- // never render (a hung one blocks the navigation forever), and if that
240
- // child has no route for the new tail its `No route found` throw
241
- // propagates out of here and `requestUpdate()` below never runs — URL
242
- // committed, outlet stranded, i.e. this fork's own thesis bug one level
243
- // down. Nested supersession is handled by the goto counter above, not by
244
- // awaiting. `_routeChild` covers the per-child filtering and error policy.
375
+ // Not awaited. `requestUpdate()` has not run, so `_childRoutes` still holds
376
+ // the outgoing branch: awaiting gates the parent's swap on a controller
377
+ // that will never render, and its `No route found` would skip
378
+ // `requestUpdate()` below and strand the outlet on a committed URL.
245
379
  //
246
- // Runs whether or not there is a tail. A route without one has nothing for
247
- // the children to render, but they must still be superseded — otherwise a
248
- // child mid-`enter()` for the previous tail stays current and commits over
249
- // a URL that has moved on.
380
+ // Runs even with no tail, so children still get superseded. See #7.
250
381
  for (const childRoutes of this._childRoutes) {
251
382
  this._routeChild(childRoutes, tail);
252
383
  }
@@ -265,26 +396,12 @@ export class Routes {
265
396
  return this._currentParams;
266
397
  }
267
398
  /**
268
- * Hands a tail match to a child controller. Shared by the propagation loop in
269
- * `goto()` and the late-mount path in `_onRoutesConnected`, so that identical
270
- * input cannot be silent on one and an uncaught global throw on the other.
399
+ * Hands a tail match to a child. Shared by `goto()`'s propagation loop and the
400
+ * late-mount path, so identical input behaves identically on both.
271
401
  *
272
- * A child with no route for the new tail is the expected case, not an error —
273
- * the outgoing branch mid-swap, or a deep link to a path the child cannot
274
- * render. Filtered structurally rather than by swallowing every rejection, so
275
- * a genuine `enter()` rejection still surfaces the way it does upstream.
276
- * Skipping must still supersede: `goto()` is where the counter is bumped, so
277
- * returning without it would leave an in-flight child navigation current,
278
- * free to commit over a URL that has moved on. A parent route with no tail
279
- * at all is the same case: nothing to route, but still something to stand
280
- * down.
281
- *
282
- * No abort signal is threaded through, and the goto is deliberately not
283
- * awaited. The parent commits its own state before children run, so a child
284
- * handed an already-aborted signal stands down with no newer goto() arriving
285
- * to correct it, leaving the nested outlet stuck — reachable, because a
286
- * hash-only navigation aborts the outstanding one without producing a
287
- * replacement. Supersession is the counter's job.
402
+ * A child with no route for the tail is expected, not an error: the outgoing
403
+ * branch mid-swap, or a deep link it cannot render. Filtered structurally so
404
+ * a genuine `enter()` rejection still surfaces. Skipping must still supersede.
288
405
  */
289
406
  _routeChild(child, tail) {
290
407
  if (tail === undefined) {
@@ -305,42 +422,25 @@ export class Routes {
305
422
  });
306
423
  });
307
424
  }
308
- /**
309
- * Invalidate any in-flight `goto()` on this controller without starting a
310
- * new one. Same-class access, so `_gotoSeq` stays private to `Routes`.
311
- */
425
+ /** Invalidate any in-flight `goto()` here without starting a new one. */
312
426
  _supersede(seen = new Set()) {
313
- // Unreachable defence in depth. Upstream *can* produce a `_childRoutes`
314
- // cycle — a host carrying two Routes controllers, disconnected and
315
- // reconnected, ends up with each registered as the other's child — but
316
- // `hostDisconnected` below removes the listener that causes it, and a test
317
- // asserts the cycle cannot form. Kept because an unguarded recursive walk
318
- // over a cycle is a stack overflow rather than a misrender.
427
+ // Defence in depth: a `_childRoutes` cycle cannot form (hostDisconnected,
428
+ // plus a test), but an unguarded walk over one is a stack overflow.
319
429
  if (seen.has(this)) {
320
430
  return;
321
431
  }
322
432
  seen.add(this);
323
433
  this._gotoSeq++;
324
- // Recursive: on the navigating branch the child's own propagation loop
325
- // reaches the grandchildren, but a skipped child never runs one — so
326
- // without this an in-flight grandchild `enter()` stays current and commits
327
- // over a URL that has moved on, the same defect one level deeper.
434
+ // A skipped child never runs its own propagation loop, so recurse or an
435
+ // in-flight grandchild `enter()` stays current.
328
436
  for (const child of this._childRoutes) {
329
437
  child._supersede(seen);
330
438
  }
331
439
  }
332
440
  /**
333
- * True when this controller, or any controller below it, has a route that
334
- * constrains the hash.
335
- *
336
- * `Router` gates interception of fragment-only navigation on this. Left
337
- * ungated, a pathname-only app would have every in-page anchor swallowed and
338
- * re-rendered instead of scrolled; gated, such an app behaves exactly as it
339
- * did before hash routes existed.
340
- *
341
- * Walks children because a nested controller may route on the hash while the
342
- * top-level `Router` does not — and only the top-level one sees the
343
- * navigate event.
441
+ * True when this controller or any below it constrains the hash. `Router`
442
+ * gates fragment-only interception on this, so a pathname-only app keeps
443
+ * native scrolling. Walks children because only the root sees the event.
344
444
  */
345
445
  _constrainsHash(seen = new Set()) {
346
446
  // Cycle guard, matching `_supersede`; see the note there.
@@ -352,34 +452,24 @@ export class Routes {
352
452
  this._childRoutes.some((c) => c._constrainsHash(seen)));
353
453
  }
354
454
  /**
355
- * True when this controller can render `path` — i.e. a route matches, or
356
- * a fallback is configured.
357
- *
358
- * `Router` gates interception on this: intercepting a path we cannot render
359
- * commits the URL and then throws out of `goto()`, leaving the address bar
360
- * moved and the outlet stale. Letting the browser handle it instead means a
361
- * server-rendered page, an export endpoint, or a GET form still works.
455
+ * True when this controller can render `path`. `Router` gates interception on
456
+ * this: intercepting what we cannot render commits the URL and then throws,
457
+ * leaving the address bar moved and the outlet stale.
362
458
  */
363
459
  hasRouteFor(path) {
364
- // A fallback matches everything, and a controller with no routes of its own
365
- // behaves as if it had a single `/*` route (goto()'s special case). Either
366
- // way the answer is yes without running a single pattern — worth
367
- // short-circuiting, since `Router` asks this on every navigation.
460
+ // A fallback matches everything, and no routes behaves as one `/*`. Worth
461
+ // short-circuiting: `Router` asks this on every navigation.
368
462
  if (this.fallback !== undefined || this.routes.length === 0) {
369
463
  return true;
370
464
  }
371
- // `test()`, not `_match()`: this only needs the yes/no, and `exec()` pays
372
- // ~8x on a hit to build a groups object the caller would throw away.
465
+ // `test()`, not `_match()`: `exec()` pays ~8x on a hit to build groups the
466
+ // caller would throw away.
373
467
  const location = parseLocation(path);
374
468
  return this.routes.some((r) => getPattern(r).test(location));
375
469
  }
376
470
  /**
377
- * Matches `pathname` against the installed routes and returns the first match
378
- * with its parsed parameters, or the fallback's match if one is configured.
379
- *
380
- * One `exec()` per candidate rather than `test()` to select and `exec()` to
381
- * extract: that ran the winning pattern twice, and every caller that wants a
382
- * route wants its params too.
471
+ * The first route matching `location`, or the fallback's match. One `exec()`
472
+ * per candidate; selecting with `test()` first ran the winner twice.
383
473
  */
384
474
  _match(location) {
385
475
  for (const route of this.routes) {
@@ -388,10 +478,8 @@ export class Routes {
388
478
  const params = {
389
479
  ...result.pathname.groups,
390
480
  };
391
- // Named groups only. A positional group is keyed by index in every
392
- // component independently, so `/child/*` with a hash of `*` yields a
393
- // "0" in both — and `tailOf` picks the tail by highest numeric key.
394
- // Merging them would let a fragment masquerade as the tail.
481
+ // Named only. Every component numbers positional groups from zero, so
482
+ // a merged "0" could masquerade as the tail. See #13.
395
483
  for (const groups of [result.search.groups, result.hash.groups]) {
396
484
  for (const [key, value] of Object.entries(groups)) {
397
485
  if (!/^\d+$/.test(key)) {
@@ -405,12 +493,8 @@ export class Routes {
405
493
  if (this.fallback === undefined) {
406
494
  return undefined;
407
495
  }
408
- // The fallback route behaves like it has a "/*" path. This is hidden from
409
- // the public API; the `path` is there to return a valid RouteConfig. The
410
- // match itself is done by hand rather than with a real `/*` pattern: a
411
- // nested controller is handed its tail *without* a leading slash, which
412
- // `/*` does not match, so a nested fallback matched nothing — empty
413
- // params, no tail, and its own children never routed.
496
+ // Matched by hand, not with a real `/*` pattern: a nested controller gets
497
+ // its tail without a leading slash, which `/*` rejects. See #5.
414
498
  const { pathname } = location;
415
499
  const tail = pathname.startsWith('/') ? pathname.slice(1) : pathname;
416
500
  return { route: { ...this.fallback, path: '/*' }, params: { 0: tail }, tail };
@@ -422,22 +506,15 @@ export class Routes {
422
506
  this._onDisconnect = event.onDisconnect;
423
507
  }
424
508
  hostDisconnected() {
425
- // Remove the listener hostConnected added. Without this a host that is
426
- // disconnected and reconnected (a repeat() reorder, a tab swap) leaves the
427
- // sibling controller's listener installed, so on the second connect it
428
- // claims the re-dispatching controller as *its* child and the pair point
429
- // at each other — a real `_childRoutes` cycle, which recursive walks turn
430
- // into a stack overflow.
509
+ // Without this, a disconnected and reconnected host leaves a sibling's
510
+ // listener installed and the two claim each other as children: a
511
+ // `_childRoutes` cycle, which recursive walks turn into a stack overflow.
431
512
  this._host.removeEventListener(RoutesConnectedEvent.eventName, this._onRoutesConnected);
432
- // When this child routes controller is disconnected because a parent
433
- // outlet rendered a different template, disconnecting will ensure that
434
- // this controller doesn't receive a tail match meant for another route.
435
513
  this._onDisconnect?.();
436
514
  this._parentRoutes = undefined;
437
515
  }
438
516
  _onRoutesConnected = (e) => {
439
- // Don't handle the event fired by this routes controller, which we get
440
- // because we do this.dispatchEvent(...)
517
+ // Ignore our own event, which we receive because we dispatch on the host.
441
518
  if (e.routes === this) {
442
519
  return;
443
520
  }
@@ -451,17 +528,11 @@ export class Routes {
451
528
  this._childRoutes.splice(index, 1);
452
529
  }
453
530
  };
454
- // A child that mounts under an existing tail match has to be caught up to
455
- // it — it missed the propagation loop in goto() that ran before it existed.
456
- // With no tail there is nothing to catch up to, and `_routeChild` then only
457
- // supersedes, a no-op on a freshly mounted child.
531
+ // Catch up a child that mounted after goto()'s propagation loop ran.
458
532
  this._routeChild(childRoutes, this._currentTail);
459
533
  };
460
534
  }
461
- /**
462
- * This event is fired from Routes controllers when their host is connected to
463
- * announce the child route and potentially connect to a parent routes controller.
464
- */
535
+ /** Announces a Routes controller to its parent when the host connects. */
465
536
  export class RoutesConnectedEvent extends Event {
466
537
  static eventName = 'lit-routes-connected';
467
538
  routes;