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