lit-navigation-router 0.2.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/index.ts ADDED
@@ -0,0 +1,8 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2021 Google LLC
4
+ * SPDX-License-Identifier: BSD-3-Clause
5
+ */
6
+ export * from './routes.js';
7
+ export {Router, supportsNavigationApi} from './router.js';
8
+ export type {InterceptOptions} from './router.js';
package/src/router.ts ADDED
@@ -0,0 +1,205 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2021 Google LLC
4
+ * SPDX-License-Identifier: BSD-3-Clause
5
+ *
6
+ * Modifications Copyright 2026 VanLandingham Labs, same license.
7
+ * Rebuilt on the Navigation API; see NOTICE.md.
8
+ */
9
+
10
+ import {Routes} from './routes.js';
11
+
12
+ // We cache the origin since it can't change
13
+ const origin = location.origin || location.protocol + '//' + location.host;
14
+
15
+ /**
16
+ * The slice of `NavigateEvent` this router reads. Declared locally rather than
17
+ * typing the handler `any`: these properties *are* the correctness boundary, so
18
+ * a typo like `hashchange` for `hashChange` would silently disable a filter
19
+ * forever. Names verified against Chromium's `NavigateEvent.prototype`.
20
+ */
21
+ interface NavigateEventLike {
22
+ readonly canIntercept: boolean;
23
+ readonly hashChange: boolean;
24
+ readonly downloadRequest: string | null;
25
+ readonly formData: FormData | null;
26
+ readonly navigationType: 'push' | 'replace' | 'reload' | 'traverse';
27
+ readonly signal: AbortSignal;
28
+ readonly destination: {readonly url: string};
29
+ /** Not in every engine yet; used only as a best-effort `rel` check. */
30
+ readonly sourceElement?: Element | null;
31
+ intercept(options: InterceptOptions & {handler?: () => Promise<void>}): void;
32
+ }
33
+
34
+ /** The subset of `NavigationInterceptOptions` this router forwards. */
35
+ export interface InterceptOptions {
36
+ focusReset?: 'after-transition' | 'manual';
37
+ scroll?: 'after-transition' | 'manual';
38
+ }
39
+
40
+ interface NavigationLike {
41
+ addEventListener(
42
+ type: 'navigate',
43
+ listener: (e: NavigateEventLike) => void
44
+ ): void;
45
+ removeEventListener(
46
+ type: 'navigate',
47
+ listener: (e: NavigateEventLike) => void
48
+ ): void;
49
+ }
50
+
51
+ const getNavigation = (): NavigationLike | undefined =>
52
+ (window as unknown as {navigation?: NavigationLike}).navigation;
53
+
54
+ /**
55
+ * True when the Navigation API is available — Baseline Newly Available since
56
+ * January 2026 (Chrome/Edge, Safari 26.2, Firefox 147).
57
+ *
58
+ * This router **requires** it. Exported so an app can detect an unsupported
59
+ * engine at boot and say so, rather than leaving the user to notice that every
60
+ * link reloads the page.
61
+ */
62
+ export const supportsNavigationApi = (): boolean =>
63
+ typeof window !== 'undefined' &&
64
+ typeof getNavigation()?.addEventListener === 'function';
65
+
66
+ /**
67
+ * A root-level router that intercepts navigation via the Navigation API.
68
+ *
69
+ * This class extends Routes so that it can also have a route configuration.
70
+ *
71
+ * There should only be one Router instance on a page, since the Router
72
+ * installs a global listener. Nested routes should be configured with the
73
+ * `Routes` class.
74
+ *
75
+ * ## Why the Navigation API
76
+ *
77
+ * Upstream intercepted navigation with a global click listener plus `popstate`
78
+ * and committed with `history.pushState()`. That is structurally racy:
79
+ * `pushState` is synchronous and `popstate` fires *after* the URL has already
80
+ * moved, but `goto()` awaits `route.enter()` before swapping the outlet. The
81
+ * URL leads and the outlet lags, leaving two sources of truth — the outgoing
82
+ * route re-renders with stale params, and two quick navigations commit in
83
+ * whatever order their `enter()` hooks happen to resolve.
84
+ *
85
+ * `navigateEvent.intercept({handler})` collapses that. The browser commits the
86
+ * URL and holds the navigation un-finished while the handler runs, and it
87
+ * aborts `navigateEvent.signal` when a newer navigation supersedes this one —
88
+ * which `goto()` honours, so a superseded route can no longer win the outlet.
89
+ *
90
+ * ## No legacy fallback
91
+ *
92
+ * An earlier version of this fork kept upstream's click/popstate path for
93
+ * pre-2026 engines. It was removed deliberately. Ten review rounds found
94
+ * divergences between the two paths and **every one was in the click handler**,
95
+ * never in this one — which is structural, not luck: this handler reads a
96
+ * decision the browser has already made, while the click handler had to
97
+ * re-derive it, re-implementing the rules for choosing a navigable, the
98
+ * fragment-navigation predicate, and the modifier-key rules. Each round found
99
+ * another place where the re-implementation and the spec disagreed.
100
+ *
101
+ * On an engine without the API, links fall back to ordinary full page loads.
102
+ * For an app whose server serves the shell on every route that still works —
103
+ * it is slower, not broken — and `supportsNavigationApi()` lets you detect it.
104
+ * If real pre-2026 support is ever needed, use a Navigation API polyfill: one
105
+ * decision path, with compatibility isolated in a layer whose whole job is
106
+ * spec accuracy.
107
+ */
108
+ export class Router extends Routes {
109
+ /**
110
+ * Options forwarded to `navigateEvent.intercept()`. Leaving these unset
111
+ * gives the browser's default scroll and focus handling.
112
+ */
113
+ interceptOptions?: InterceptOptions;
114
+
115
+ private _listening = false;
116
+
117
+ override hostConnected() {
118
+ super.hostConnected();
119
+ // Gated on the exported predicate, not on `navigation !== undefined`:
120
+ // a stub or partial polyfill under that name would otherwise make this
121
+ // branch throw out of connectedCallback while `supportsNavigationApi()`
122
+ // told the app it was unsupported — and then even the initial render below
123
+ // would not run.
124
+ if (supportsNavigationApi()) {
125
+ getNavigation()!.addEventListener('navigate', this._onNavigate);
126
+ this._listening = true;
127
+ }
128
+ // Kick off routed rendering by going to the current URL. Done even without
129
+ // the API: a full page load still renders the right route, which is what
130
+ // makes the unsupported-engine degradation "slow" rather than "blank".
131
+ // Surfaced rather than left as a bare unhandled rejection, matching the
132
+ // convention in routes.ts: on an engine without the API this is the *only*
133
+ // rendering path, and a deep link with no matching route throws here.
134
+ void this.goto(window.location.pathname).catch((err) => {
135
+ queueMicrotask(() => {
136
+ throw err;
137
+ });
138
+ });
139
+ }
140
+
141
+ override hostDisconnected() {
142
+ super.hostDisconnected();
143
+ if (this._listening) {
144
+ getNavigation()?.removeEventListener('navigate', this._onNavigate);
145
+ this._listening = false;
146
+ }
147
+ }
148
+
149
+ /**
150
+ * Handles same-document navigation from every source at once: anchor clicks,
151
+ * `navigation.navigate()`, `history.pushState()`, and back/forward.
152
+ */
153
+ private _onNavigate = (e: NavigateEventLike) => {
154
+ // Not ours to handle: anything the browser says cannot be intercepted,
155
+ // fragment-only moves, downloads, and POST form submissions.
156
+ if (!e.canIntercept || e.hashChange || e.downloadRequest !== null) {
157
+ return;
158
+ }
159
+ if (e.formData) {
160
+ return;
161
+ }
162
+
163
+ // Reloads must stay reloads. `canIntercept` is true for them, so without
164
+ // this `location.reload()` silently degrades to re-running goto() on the
165
+ // same path — the document is never replaced, breaking the standard
166
+ // "new version available, reload" escape hatch. (It would also disagree
167
+ // with the browser's own refresh button, which is not interceptable.)
168
+ if (e.navigationType === 'reload') {
169
+ return;
170
+ }
171
+
172
+ // `rel="external"` is a convention this router honours — it is not defined
173
+ // by HTML or by the Navigation API, so the browser will not decline these
174
+ // for us. Best-effort: `sourceElement` is not in every engine, and is
175
+ // absent for programmatic navigation.
176
+ if (e.sourceElement?.getAttribute?.('rel') === 'external') {
177
+ return;
178
+ }
179
+
180
+ const url = new URL(e.destination.url);
181
+ if (url.origin !== origin) {
182
+ return;
183
+ }
184
+
185
+ // Only intercept what we can actually render. `canIntercept` is true for
186
+ // any same-origin URL, including cross-document ones — so without this a
187
+ // link to a server-rendered page, an export endpoint, or a GET form
188
+ // (whose `formData` is null) gets swallowed: the URL commits, goto()
189
+ // throws "No route found", and the address bar is left pointing somewhere
190
+ // the outlet never went. Declining lets the browser do the real
191
+ // navigation, which is the correct outcome.
192
+ if (!this.hasRouteFor(url.pathname)) {
193
+ return;
194
+ }
195
+
196
+ e.intercept({
197
+ ...this.interceptOptions,
198
+ handler: async () => {
199
+ // `e.signal` aborts if another navigation starts before this handler
200
+ // resolves; goto() checks it after `enter()` and stands down.
201
+ await this.goto(url.pathname, {signal: e.signal});
202
+ },
203
+ });
204
+ };
205
+ }
package/src/routes.ts ADDED
@@ -0,0 +1,471 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2021 Google LLC
4
+ * SPDX-License-Identifier: BSD-3-Clause
5
+ *
6
+ * Modifications Copyright 2026 VanLandingham Labs, same license. See NOTICE.md.
7
+ */
8
+
9
+ /// <reference types="urlpattern-polyfill" />
10
+
11
+ import type {ReactiveController, ReactiveControllerHost} from 'lit';
12
+
13
+ export interface BaseRouteConfig {
14
+ name?: string | undefined;
15
+ render?: (params: {[key: string]: string | undefined}) => unknown;
16
+ enter?: (params: {
17
+ [key: string]: string | undefined;
18
+ }) => Promise<boolean> | boolean;
19
+ }
20
+
21
+ /**
22
+ * A RouteConfig that matches against a `path` string. `path` must be a
23
+ * [`URLPattern` compatible pathname pattern](https://developer.mozilla.org/en-US/docs/Web/API/URLPattern/pathname).
24
+ */
25
+ export interface PathRouteConfig extends BaseRouteConfig {
26
+ path: string;
27
+ }
28
+
29
+ /**
30
+ * A RouteConfig that matches against a given [`URLPattern`](https://developer.mozilla.org/en-US/docs/Web/API/URLPattern)
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`.
36
+ */
37
+ export interface URLPatternRouteConfig extends BaseRouteConfig {
38
+ pattern: URLPatternLike;
39
+ }
40
+
41
+ /**
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.
50
+ */
51
+ export interface URLPatternLike {
52
+ test(input: {pathname: string}): boolean;
53
+ exec(input: {pathname: string}): {
54
+ pathname: {groups: {[key: string]: string | undefined}};
55
+ } | null;
56
+ }
57
+
58
+ /**
59
+ * A description of a route, which path or pattern to match against, and a
60
+ * render() callback used to render a match to the outlet.
61
+ */
62
+ export type RouteConfig = PathRouteConfig | URLPatternRouteConfig;
63
+
64
+ // A cache of URLPatterns created for PathRouteConfig.
65
+ // Rather than converting all given RoutConfigs to URLPatternRouteConfig, this
66
+ // lets us make `routes` mutable so users can add new PathRouteConfigs
67
+ // dynamically.
68
+ const patternCache = new WeakMap<PathRouteConfig, URLPatternLike>();
69
+
70
+ const isPatternConfig = (route: RouteConfig): route is URLPatternRouteConfig =>
71
+ (route as URLPatternRouteConfig).pattern !== undefined;
72
+
73
+ const getPattern = (route: RouteConfig): URLPatternLike => {
74
+ if (isPatternConfig(route)) {
75
+ return route.pattern;
76
+ }
77
+ let pattern = patternCache.get(route);
78
+ if (pattern === undefined) {
79
+ patternCache.set(route, (pattern = new URLPattern({pathname: route.path})));
80
+ }
81
+ return pattern;
82
+ };
83
+
84
+ /**
85
+ * A reactive controller that performs location-based routing using a
86
+ * configuration of URL patterns and associated render callbacks.
87
+ */
88
+ export class Routes implements ReactiveController {
89
+ private readonly _host: ReactiveControllerHost & HTMLElement;
90
+
91
+ /*
92
+ * The currently installed set of routes in precedence order.
93
+ *
94
+ * This array is mutable. To dynamically add a new route you can write:
95
+ *
96
+ * ```ts
97
+ * this._routes.routes.push({
98
+ * path: '/foo',
99
+ * render: () => html`<p>Foo</p>`,
100
+ * });
101
+ * ```
102
+ *
103
+ * Mutating this property does not trigger any route transitions. If the
104
+ * changes may result is a different route matching for the current path, you
105
+ * must instigate a route update with `goto()`.
106
+ */
107
+ routes: Array<RouteConfig> = [];
108
+
109
+ /**
110
+ * A default fallback route which will always be matched if none of the
111
+ * {@link routes} match. Implicitly matches to the path "/*".
112
+ */
113
+ fallback?: BaseRouteConfig;
114
+
115
+ /*
116
+ * The current set of child Routes controllers. These are connected via
117
+ * the routes-connected event.
118
+ */
119
+ private readonly _childRoutes: Array<Routes> = [];
120
+
121
+ private _parentRoutes: Routes | undefined;
122
+
123
+ /*
124
+ * State related to the current matching route.
125
+ *
126
+ * We keep this so that consuming code can access current parameters, and so
127
+ * that we can propagate tail matches to child routes if they are added after
128
+ * navigation / matching.
129
+ */
130
+ /** Monotonic goto counter; see the last-goto-wins note in goto(). */
131
+ private _gotoSeq = 0;
132
+
133
+ private _currentPathname: string | undefined;
134
+ private _currentRoute: RouteConfig | undefined;
135
+ private _currentParams: {
136
+ [key: string]: string | undefined;
137
+ } = {};
138
+
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
+ */
145
+ // TODO (justinfagnani): Do we need this now that we have a direct reference
146
+ // to the parent? We can call `this._parentRoutes.disconnect(this)`.
147
+ private _onDisconnect: (() => void) | undefined;
148
+
149
+ constructor(
150
+ host: ReactiveControllerHost & HTMLElement,
151
+ routes: Array<RouteConfig>,
152
+ options?: {fallback?: BaseRouteConfig}
153
+ ) {
154
+ (this._host = host).addController(this);
155
+ this.routes = [...routes];
156
+ this.fallback = options?.fallback;
157
+ }
158
+
159
+ /**
160
+ * Returns a URL string of the current route, including parent routes,
161
+ * optionally replacing the local path with `pathname`.
162
+ */
163
+ link(pathname?: string): string {
164
+ if (pathname?.startsWith('/')) {
165
+ return pathname;
166
+ }
167
+ if (pathname?.startsWith('.')) {
168
+ throw new Error('Not implemented');
169
+ }
170
+ pathname ??= this._currentPathname;
171
+ return (this._parentRoutes?.link() ?? '') + pathname;
172
+ }
173
+
174
+ /**
175
+ * Navigates this routes controller to `pathname`.
176
+ *
177
+ * This does not navigate parent routes, so it isn't (yet) a general page
178
+ * navigation API. It does navigate child routes if pathname matches a
179
+ * pattern with a tail wildcard pattern (`/*`).
180
+ *
181
+ * Pass `options.signal` to make the navigation abandonable. `enter()` is
182
+ * awaited, so a second `goto()` can start — and finish — while the first is
183
+ * still resolving its route; without a signal the slower one commits last
184
+ * and the outlet ends up on a route the URL has already left. `Router`
185
+ * threads `NavigateEvent.signal` through for exactly this reason.
186
+ */
187
+ async goto(pathname: string, options?: {signal?: AbortSignal}) {
188
+ // TODO (justinfagnani): handle absolute vs relative paths separately.
189
+
190
+ // TODO (justinfagnani): generalize this to handle query params and
191
+ // fragments. It currently only handles path names because it's easier to
192
+ // completely disregard the origin for now. The click handler only does
193
+ // an in-page navigation if the origin matches anyway.
194
+ const signal = options?.signal;
195
+ // Last-goto-wins, per controller. The navigation signal alone is not
196
+ // enough: a child controller mounts as a *result* of its parent's render,
197
+ // so its first goto() comes from `_onRoutesConnected` — after the parent's
198
+ // navigation has already finished, and therefore with a signal that will
199
+ // never abort. Without this counter a slow first child load commits over a
200
+ // newer one. This also keeps `Routes` correct when used on its own, with
201
+ // no `Router` and no Navigation API in the picture.
202
+ const seq = ++this._gotoSeq;
203
+ const superseded = () => signal?.aborted === true || seq !== this._gotoSeq;
204
+ let tailGroup: string | undefined;
205
+
206
+ if (this.routes.length === 0 && this.fallback === undefined) {
207
+ // If a routes controller has none of its own routes it acts like it has
208
+ // one route of `/*` so that it passes the whole pathname as a tail
209
+ // match.
210
+ tailGroup = pathname;
211
+ this._currentPathname = '';
212
+ // Simulate a tail group with the whole pathname
213
+ this._currentParams = {0: tailGroup};
214
+ } else {
215
+ const route = this._getRoute(pathname);
216
+ if (route === undefined) {
217
+ throw new Error(`No route found for ${pathname}`);
218
+ }
219
+ const pattern = getPattern(route);
220
+ const result = pattern.exec({pathname});
221
+ const params = result?.pathname.groups ?? {};
222
+ tailGroup = getTailGroup(params);
223
+ if (typeof route.enter === 'function') {
224
+ const success = await route.enter(params);
225
+ // If enter() returns false, cancel this navigation
226
+ if (success === false) {
227
+ return;
228
+ }
229
+ }
230
+ // A newer navigation superseded this one while `enter` was awaiting.
231
+ // Committing now would swap the outlet onto a route the URL has left.
232
+ if (superseded()) {
233
+ return;
234
+ }
235
+ // Only update route state if the enter handler completes successfully
236
+ this._currentRoute = route;
237
+ this._currentParams = params;
238
+ this._currentPathname =
239
+ tailGroup === undefined
240
+ ? pathname
241
+ : pathname.substring(0, pathname.length - tailGroup.length);
242
+ }
243
+
244
+ // Propagate the tail match to children — deliberately NOT awaited.
245
+ //
246
+ // Awaiting looks like it would make `navigation.finished` cover the whole
247
+ // tree, and an earlier revision of this fork did it. It is wrong twice
248
+ // over. At this point `requestUpdate()` has not run, so `_childRoutes`
249
+ // still holds the *outgoing* branch's controller: awaiting it gates the
250
+ // parent's outlet swap on an `enter()` for a tail that controller will
251
+ // never render (a hung one blocks the navigation forever), and if that
252
+ // child has no route for the new tail its `No route found` throw
253
+ // propagates out of here and `requestUpdate()` below never runs — URL
254
+ // committed, outlet stranded, i.e. this fork's own thesis bug one level
255
+ // down. Nested supersession is handled by the goto counter above, not by
256
+ // awaiting. Errors are swallowed rather than left as unhandled rejections.
257
+ if (tailGroup !== undefined) {
258
+ for (const childRoutes of this._childRoutes) {
259
+ // No signal, for the same reason as the late-mount path below: the
260
+ // parent commits before children run, so a child handed an aborted
261
+ // signal stands down with no newer goto() arriving to correct it,
262
+ // leaving the nested outlet stuck. A hash-only navigation aborts the
263
+ // outstanding one without producing a replacement, so this is
264
+ // reachable. Supersession is the counter's job.
265
+ //
266
+ // The expected failure here is a child with no route for the new tail
267
+ // — the outgoing branch, mid-swap. Filter that structurally rather
268
+ // than swallowing everything, so a genuine `enter()` rejection still
269
+ // surfaces the way it does upstream instead of vanishing.
270
+ if (!childRoutes.hasRouteFor(tailGroup)) {
271
+ // Skip the navigation but still supersede: `goto()` is where the
272
+ // counter is bumped, so returning early here would leave an
273
+ // in-flight child navigation current, free to commit over a URL that
274
+ // has moved on. Removing the abort signal above is only safe because
275
+ // the counter always runs — including here.
276
+ childRoutes._supersede();
277
+ continue;
278
+ }
279
+ void childRoutes.goto(tailGroup).catch((err) => {
280
+ queueMicrotask(() => {
281
+ throw err;
282
+ });
283
+ });
284
+ }
285
+ }
286
+ this._host.requestUpdate();
287
+ }
288
+
289
+ /**
290
+ * The result of calling the current route's render() callback.
291
+ */
292
+ outlet() {
293
+ return this._currentRoute?.render?.(this._currentParams);
294
+ }
295
+
296
+ /**
297
+ * The current parsed route parameters.
298
+ */
299
+ get params() {
300
+ return this._currentParams;
301
+ }
302
+
303
+ /**
304
+ * Invalidate any in-flight `goto()` on this controller without starting a
305
+ * new one. Same-class access, so `_gotoSeq` stays private to `Routes`.
306
+ */
307
+ private _supersede(seen: Set<Routes> = new Set()): void {
308
+ // Unreachable defence in depth. Upstream *can* produce a `_childRoutes`
309
+ // cycle — a host carrying two Routes controllers, disconnected and
310
+ // reconnected, ends up with each registered as the other's child — but
311
+ // `hostDisconnected` below removes the listener that causes it, and a test
312
+ // asserts the cycle cannot form. Kept because an unguarded recursive walk
313
+ // over a cycle is a stack overflow rather than a misrender.
314
+ if (seen.has(this)) {
315
+ return;
316
+ }
317
+ seen.add(this);
318
+ this._gotoSeq++;
319
+ // Recursive: on the navigating branch the child's own propagation loop
320
+ // reaches the grandchildren, but a skipped child never runs one — so
321
+ // without this an in-flight grandchild `enter()` stays current and commits
322
+ // over a URL that has moved on, the same defect one level deeper.
323
+ for (const child of this._childRoutes) {
324
+ child._supersede(seen);
325
+ }
326
+ }
327
+
328
+ /**
329
+ * True when this controller can render `pathname` — i.e. a route matches, or
330
+ * a fallback is configured.
331
+ *
332
+ * `Router` gates interception on this: intercepting a path we cannot render
333
+ * commits the URL and then throws out of `goto()`, leaving the address bar
334
+ * moved and the outlet stale. Letting the browser handle it instead means a
335
+ * server-rendered page, an export endpoint, or a GET form still works.
336
+ */
337
+ hasRouteFor(pathname: string): boolean {
338
+ // Mirrors goto()'s special case: a controller with no routes of its own
339
+ // behaves as if it had a single `/*` route.
340
+ if (this.routes.length === 0 && this.fallback === undefined) {
341
+ return true;
342
+ }
343
+ return this._getRoute(pathname) !== undefined;
344
+ }
345
+
346
+ /**
347
+ * Matches `url` against the installed routes and returns the first match.
348
+ */
349
+ private _getRoute(pathname: string): RouteConfig | undefined {
350
+ const matchedRoute = this.routes.find((r) =>
351
+ getPattern(r).test({pathname: pathname})
352
+ );
353
+ if (matchedRoute || this.fallback === undefined) {
354
+ return matchedRoute;
355
+ }
356
+ if (this.fallback) {
357
+ // The fallback route behaves like it has a "/*" path. This is hidden from
358
+ // the public API but is added here to return a valid RouteConfig.
359
+ return {...this.fallback, path: '/*'};
360
+ }
361
+ return undefined;
362
+ }
363
+
364
+ hostConnected() {
365
+ this._host.addEventListener(
366
+ RoutesConnectedEvent.eventName,
367
+ this._onRoutesConnected
368
+ );
369
+ const event = new RoutesConnectedEvent(this);
370
+ this._host.dispatchEvent(event);
371
+ this._onDisconnect = event.onDisconnect;
372
+ }
373
+
374
+ hostDisconnected() {
375
+ // Remove the listener hostConnected added. Without this a host that is
376
+ // disconnected and reconnected (a repeat() reorder, a tab swap) leaves the
377
+ // sibling controller's listener installed, so on the second connect it
378
+ // claims the re-dispatching controller as *its* child and the pair point
379
+ // at each other — a real `_childRoutes` cycle, which recursive walks turn
380
+ // into a stack overflow.
381
+ this._host.removeEventListener(
382
+ RoutesConnectedEvent.eventName,
383
+ this._onRoutesConnected
384
+ );
385
+ // When this child routes controller is disconnected because a parent
386
+ // outlet rendered a different template, disconnecting will ensure that
387
+ // this controller doesn't receive a tail match meant for another route.
388
+ this._onDisconnect?.();
389
+ this._parentRoutes = undefined;
390
+ }
391
+
392
+ private _onRoutesConnected = (e: RoutesConnectedEvent) => {
393
+ // Don't handle the event fired by this routes controller, which we get
394
+ // because we do this.dispatchEvent(...)
395
+ if (e.routes === this) {
396
+ return;
397
+ }
398
+
399
+ const childRoutes = e.routes;
400
+ this._childRoutes.push(childRoutes);
401
+ childRoutes._parentRoutes = this;
402
+
403
+ e.stopImmediatePropagation();
404
+ e.onDisconnect = () => {
405
+ // Remove route from this._childRoutes:
406
+ // `>>> 0` converts -1 to 2**32-1
407
+ this._childRoutes?.splice(
408
+ this._childRoutes.indexOf(childRoutes) >>> 0,
409
+ 1
410
+ );
411
+ };
412
+
413
+ const tailGroup = getTailGroup(this._currentParams);
414
+ // Same structural filter as the propagation path in goto(): a child that
415
+ // mounts under a tail it cannot render is the expected case (a deep link
416
+ // to `/x/unknown`), not an error. Without this the two call sites disagree
417
+ // — silent there, uncaught global throw here — for identical input.
418
+ if (tailGroup !== undefined && childRoutes.hasRouteFor(tailGroup)) {
419
+ // No signal here on purpose. The parent commits its own state before
420
+ // children run, so by the time a late child mounts the navigation may
421
+ // already have been aborted — handing it that signal makes it stand down
422
+ // with no newer goto() ever arriving to correct it, leaving the nested
423
+ // outlet blank permanently. The goto counter covers what matters
424
+ // (supersession by a newer goto).
425
+ void childRoutes.goto(tailGroup).catch((err) => {
426
+ queueMicrotask(() => {
427
+ throw err;
428
+ });
429
+ });
430
+ }
431
+ };
432
+ }
433
+
434
+ /**
435
+ * Returns the tail of a pathname groups object. This is the match from a
436
+ * wildcard at the end of a pathname pattern, like `/foo/*`
437
+ */
438
+ const getTailGroup = (groups: {[key: string]: string | undefined}) => {
439
+ let tailKey: string | undefined;
440
+ for (const key of Object.keys(groups)) {
441
+ if (/\d+/.test(key) && (tailKey === undefined || key > tailKey!)) {
442
+ tailKey = key;
443
+ }
444
+ }
445
+ return tailKey && groups[tailKey];
446
+ };
447
+
448
+ /**
449
+ * This event is fired from Routes controllers when their host is connected to
450
+ * announce the child route and potentially connect to a parent routes controller.
451
+ */
452
+ export class RoutesConnectedEvent extends Event {
453
+ static readonly eventName = 'lit-routes-connected';
454
+ readonly routes: Routes;
455
+ onDisconnect?: () => void;
456
+
457
+ constructor(routes: Routes) {
458
+ super(RoutesConnectedEvent.eventName, {
459
+ bubbles: true,
460
+ composed: true,
461
+ cancelable: false,
462
+ });
463
+ this.routes = routes;
464
+ }
465
+ }
466
+
467
+ declare global {
468
+ interface HTMLElementEventMap {
469
+ [RoutesConnectedEvent.eventName]: RoutesConnectedEvent;
470
+ }
471
+ }