lit-navigation-router 0.2.0 → 0.4.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/CHANGELOG.md ADDED
@@ -0,0 +1,98 @@
1
+ # Changelog
2
+
3
+ ## 0.4.0
4
+
5
+ ### Fixed
6
+
7
+ - **A route's tail is identified from its pattern, not guessed from the
8
+ match's groups object**
9
+ ([#4](https://github.com/VanLandinghamLabs/lit-router/issues/4)).
10
+ `URLPattern` keys every unnamed group by position, so an unnamed regex group
11
+ (`/post/(\d+)`) and a wildcard that is not last (`/foo/*/bar`) looked exactly
12
+ like a trailing `/*`. Both were handed to child controllers as a tail and
13
+ stripped from `link()`, which returned `/post/` for `/post/123` and a
14
+ truncated `/foo/zz/b` for `/foo/zz/bar`. Only a pattern that ends in a
15
+ wildcard now has a tail.
16
+ - **A nested `fallback` passes its tail on to its own children**
17
+ ([#5](https://github.com/VanLandinghamLabs/lit-router/issues/5)). The
18
+ fallback matched with a literal `/*` pattern, but a nested controller is
19
+ handed its tail without a leading slash, which `/*` rejects: the fallback
20
+ rendered with empty params and grandchildren were never routed or
21
+ superseded. It now behaves like `/*` at the root and `*` when nested, with
22
+ `params[0]` the whole tail in both cases.
23
+ - **Children are superseded when the parent moves to a route with no tail**
24
+ ([#7](https://github.com/VanLandinghamLabs/lit-router/issues/7)). The
25
+ propagation loop ran only when the new route had a tail, so a child
26
+ mid-`enter()` for the previous tail was never stood down and could commit
27
+ over a URL that had already moved on.
28
+
29
+ ### Changed
30
+
31
+ - `URLPatternLike` requires `pathname`: the pattern string, which a real
32
+ `URLPattern` exposes and which is how the tail is now identified. An object
33
+ offering only `test()`/`exec()` no longer type-checks as a route pattern.
34
+ - A trailing `*` counts as a tail only when it is a wildcard: bare `*`, `{*}`,
35
+ `(.*)` (which `URLPattern` normalises to `*`), optionally followed by `?`.
36
+ A `*` that is the modifier on a group or a named param (`(\d+)*`, `{/}*`,
37
+ `:rest*`), or an escaped `\*`, is not. Previously any pattern whose match
38
+ produced a positional group was treated as having a tail.
39
+
40
+ ### Documentation
41
+
42
+ - A nested index route is spelled `{path: ''}`
43
+ ([#6](https://github.com/VanLandinghamLabs/lit-router/issues/6)). The tail
44
+ handed to a child has no leading slash, so the index of a nested route space
45
+ is the empty string; `{path: '/'}` matches nothing there. This already
46
+ worked and is now documented and pinned by a test.
47
+
48
+ ## 0.3.0
49
+
50
+ ### Fixed
51
+
52
+ - **The wildcard tail handed to child controllers was selected incorrectly.**
53
+ `getTailGroup()` picked the winning positional group with an unanchored
54
+ `/\d+/` test and a string comparison, which went wrong two ways:
55
+
56
+ - A *named* group whose name merely contains a digit was accepted as a
57
+ candidate, and since a letter sorts above a digit it then won. A route like
58
+ `/user/:id2/*` on `/user/5/docs/a` handed the child `'5'` instead of
59
+ `'docs/a'`, and the child rendered nothing.
60
+ - `'9' > '10'` as strings, so a pattern with eleven or more wildcards took
61
+ the second-to-last group as its tail.
62
+
63
+ **This changes behaviour.** If a route combines a wildcard with a param name
64
+ containing a digit, the child controller now receives a different path — the
65
+ correct one. Anything relying on the old selection was relying on the child
66
+ being given the wrong segment.
67
+
68
+ - **An unset `formData` or `downloadRequest` on a `NavigateEvent` no longer
69
+ makes the router decline every navigation.** Both are spec'd as
70
+ nullable-but-present, so the previous strict `!== null` was correct against a
71
+ real Navigation API but wrong under a polyfill that leaves either unset.
72
+
73
+ ### Changed
74
+
75
+ - Merged the two child-routing paths (`goto()`'s propagation loop and the
76
+ late-mount path in `_onRoutesConnected`) into a single `_routeChild()`, so
77
+ they cannot diverge. A child skipped on the late-mount path is now also
78
+ superseded.
79
+ - `hasRouteFor()` short-circuits when a fallback is configured instead of
80
+ running every pattern, and the fallback no longer rebuilds a `URLPattern` on
81
+ every navigation.
82
+ - `location.origin` is read per navigation rather than at module scope, so
83
+ importing the package no longer touches `location` — importing it where there
84
+ is no DOM previously threw, contradicting `sideEffects: false`.
85
+
86
+ ### Known issues
87
+
88
+ `getTailGroup()` still cannot distinguish the trailing wildcard from an unnamed
89
+ regex group or a wildcard that is not last. See
90
+ [#4](https://github.com/VanLandinghamLabs/lit-router/issues/4),
91
+ [#5](https://github.com/VanLandinghamLabs/lit-router/issues/5),
92
+ [#6](https://github.com/VanLandinghamLabs/lit-router/issues/6), and
93
+ [#7](https://github.com/VanLandinghamLabs/lit-router/issues/7).
94
+
95
+ ## 0.2.0
96
+
97
+ Initial release of the fork. A router for Lit built on the Navigation API,
98
+ forked from `@lit-labs/router`.
package/NOTICE.md CHANGED
@@ -105,6 +105,20 @@ it is now answered:
105
105
  Nested supersession is the counter's job, not the await's.
106
106
  - New `hasRouteFor(pathname)`, used by `Router` to decline what it cannot
107
107
  render.
108
+ - **The tail is identified from the pattern, not from the match.** Upstream
109
+ takes the highest positional group of any match as the tail, but `URLPattern`
110
+ keys an unnamed regex group (`/post/(\d+)`) and a non-final wildcard
111
+ (`/foo/*/bar`) by position too, so both were handed to children and stripped
112
+ from `link()`. `tailOf()` reads the compiled pattern's `pathname` and only
113
+ looks for a tail when it ends in a wildcard; `URLPatternLike.pathname` is
114
+ therefore required.
115
+ - The fallback matches by hand rather than with a `/*` pattern. A nested
116
+ controller is handed its tail without a leading slash, which `/*` rejects, so
117
+ upstream's nested fallback saw empty params and never routed its own
118
+ children.
119
+ - Children are handed to `_routeChild` on every parent navigation, tail or
120
+ not: a route with no tail still supersedes them, so a child mid-`enter()` for
121
+ the previous tail cannot commit over a URL that has moved on.
108
122
 
109
123
  ### Known limits
110
124
 
@@ -120,9 +134,10 @@ it is now answered:
120
134
  - The `seen` set in `_supersede()` is defence in depth and unpinned **by
121
135
  construction**: since `hostDisconnected` removes its listener, no test can
122
136
  build a `_childRoutes` cycle any more.
123
- - A child skipped because it cannot render the new tail keeps its previously
124
- *committed* outlet it is superseded (no in-flight navigation can commit)
125
- but not cleared, so it goes on rendering the route the URL has left. Upstream
137
+ - A child skipped because it cannot render the new tail or because the
138
+ parent's new route has no tail at all keeps its previously *committed*
139
+ outlet: it is superseded (no in-flight navigation can commit) but not
140
+ cleared, so it goes on rendering the route the URL has left. Upstream
126
141
  had the same end state by a different route (`No route found` threw before
127
142
  any state changed). Clearing it needs `_currentRoute` reset plus a host
128
143
  update, which is a behaviour change rather than a bug fix.
@@ -140,7 +155,8 @@ Runner so it stands alone. Test changes:
140
155
  - Two `(r: RouteConfig)` annotations added in `router_test.ts` (upstream relied
141
156
  on monorepo-wide inference). **Otherwise upstream's 6 tests are unmodified and
142
157
  pass**, which is the main evidence that the rewrite preserves behaviour.
143
- - 14 new tests in `src/test/navigation_test.ts` cover the Navigation API path.
158
+ - 26 new tests in `src/test/navigation_test.ts` cover the Navigation API path
159
+ and the nested-routing fixes above.
144
160
  The first asserts the suite is actually running against `window.navigation`
145
161
  rather than silently falling back — without it the rest would pass against the
146
162
  legacy path and prove nothing. It is kept as a guard for the day this
@@ -148,7 +164,7 @@ Runner so it stands alone. Test changes:
148
164
 
149
165
  ## Verified
150
166
 
151
- `npm test` → 20 passed (6 upstream + 14 new), Chromium via Playwright.
167
+ `npm test` → 32 passed (6 upstream + 26 new), Chromium via Playwright.
152
168
 
153
169
  Run `npm run clean` before a mutation check: the build is `composite`/
154
170
  `incremental`, and a stale `development/` can contain a hunk's comment without
package/README.md CHANGED
@@ -57,6 +57,34 @@ this._router.interceptOptions = {scroll: 'manual', focusReset: 'manual'};
57
57
  await routes.goto('/item/1', {signal});
58
58
  ```
59
59
 
60
+ ## Nested routes and tails
61
+
62
+ A route whose pattern ends in a wildcard — `/docs/*` — hands what the wildcard
63
+ matched (the *tail*) to any `Routes` controller mounted by its `render()`. The
64
+ tail has no leading slash, and child routes are written the same way:
65
+
66
+ ```ts
67
+ // Parent
68
+ {path: '/docs/*', render: () => html`<my-docs></my-docs>`}
69
+
70
+ // Child, inside <my-docs>
71
+ private _routes = new Routes(this, [
72
+ {path: '', render: () => html`<h2>Docs</h2>`}, // /docs/
73
+ {path: ':page', render: ({page}) => html`<doc-page .page=${page}></doc-page>`}, // /docs/intro
74
+ ]);
75
+ ```
76
+
77
+ The index of a nested route space is the empty tail, spelled `{path: ''}`.
78
+ `{path: '/'}` matches nothing there: it would need a tail of `/`, i.e. a URL of
79
+ `/docs//`.
80
+
81
+ Only a trailing wildcard produces a tail. An unnamed regex group
82
+ (`/post/(\d+)`) or a wildcard followed by more pattern (`/a/*/b`) is a
83
+ parameter of that route, available as `params[0]`; it is neither passed to
84
+ children nor stripped from `link()`. A `fallback` behaves like a `/*` route and
85
+ passes the whole pathname on as the tail. Nested, it accepts the slash-less
86
+ tail it is handed and passes that on.
87
+
60
88
  ## Browser support — please read
61
89
 
62
90
  This router **requires the Navigation API**. There is no legacy fallback.
@@ -1 +1 @@
1
- {"version":3,"file":"router.d.ts","sourceRoot":"","sources":["../src/router.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAEH,OAAO,EAAC,MAAM,EAAC,MAAM,aAAa,CAAC;AAwBnC,uEAAuE;AACvE,MAAM,WAAW,gBAAgB;IAC/B,UAAU,CAAC,EAAE,kBAAkB,GAAG,QAAQ,CAAC;IAC3C,MAAM,CAAC,EAAE,kBAAkB,GAAG,QAAQ,CAAC;CACxC;AAgBD;;;;;;;GAOG;AACH,eAAO,MAAM,qBAAqB,QAAO,OAEgB,CAAC;AAE1D;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAyCG;AACH,qBAAa,MAAO,SAAQ,MAAM;IAChC;;;OAGG;IACH,gBAAgB,CAAC,EAAE,gBAAgB,CAAC;IAEpC,OAAO,CAAC,UAAU,CAAS;IAElB,aAAa;IAwBb,gBAAgB;IAQzB;;;OAGG;IACH,OAAO,CAAC,WAAW,CAmDjB;CACH"}
1
+ {"version":3,"file":"router.d.ts","sourceRoot":"","sources":["../src/router.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAEH,OAAO,EAAC,MAAM,EAAC,MAAM,aAAa,CAAC;AAqBnC,uEAAuE;AACvE,MAAM,WAAW,gBAAgB;IAC/B,UAAU,CAAC,EAAE,kBAAkB,GAAG,QAAQ,CAAC;IAC3C,MAAM,CAAC,EAAE,kBAAkB,GAAG,QAAQ,CAAC;CACxC;AAgBD;;;;;;;GAOG;AACH,eAAO,MAAM,qBAAqB,QAAO,OAEgB,CAAC;AAE1D;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAyCG;AACH,qBAAa,MAAO,SAAQ,MAAM;IAChC;;;OAGG;IACH,gBAAgB,CAAC,EAAE,gBAAgB,CAAC;IAEpC,OAAO,CAAC,UAAU,CAAS;IAElB,aAAa;IAwBb,gBAAgB;IAQzB;;;OAGG;IACH,OAAO,CAAC,WAAW,CA6DjB;CACH"}
@@ -7,8 +7,6 @@
7
7
  * Rebuilt on the Navigation API; see NOTICE.md.
8
8
  */
9
9
  import { Routes } from './routes.js';
10
- // We cache the origin since it can't change
11
- const origin = location.origin || location.protocol + '//' + location.host;
12
10
  const getNavigation = () => window.navigation;
13
11
  /**
14
12
  * True when the Navigation API is available — Baseline Newly Available since
@@ -106,10 +104,14 @@ export class Router extends Routes {
106
104
  _onNavigate = (e) => {
107
105
  // Not ours to handle: anything the browser says cannot be intercepted,
108
106
  // fragment-only moves, downloads, and POST form submissions.
109
- if (!e.canIntercept || e.hashChange || e.downloadRequest !== null) {
110
- return;
111
- }
112
- if (e.formData) {
107
+ //
108
+ // `!= null`, not `!== null`: the spec types both as nullable-but-present,
109
+ // but a polyfill that leaves either unset would make a strict check true
110
+ // for every ordinary link and silently decline the whole app.
111
+ if (!e.canIntercept ||
112
+ e.hashChange ||
113
+ e.downloadRequest != null ||
114
+ e.formData != null) {
113
115
  return;
114
116
  }
115
117
  // Reloads must stay reloads. `canIntercept` is true for them, so without
@@ -127,8 +129,12 @@ export class Router extends Routes {
127
129
  if (e.sourceElement?.getAttribute?.('rel') === 'external') {
128
130
  return;
129
131
  }
132
+ // Read per navigation rather than cached at module scope: the value cannot
133
+ // change, but reading it on import makes merely importing this module throw
134
+ // where there is no `location` (SSR, a bundler evaluating for tree-shaking
135
+ // under the package's `sideEffects: false` claim).
130
136
  const url = new URL(e.destination.url);
131
- if (url.origin !== origin) {
137
+ if (url.origin !== window.location.origin) {
132
138
  return;
133
139
  }
134
140
  // Only intercept what we can actually render. `canIntercept` is true for
@@ -1 +1 @@
1
- {"version":3,"file":"router.js","sourceRoot":"","sources":["../src/router.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * @license\n * Copyright 2021 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *\n * Modifications Copyright 2026 VanLandingham Labs, same license.\n * Rebuilt on the Navigation API; see NOTICE.md.\n */\n\nimport {Routes} from './routes.js';\n\n// We cache the origin since it can't change\nconst origin = location.origin || location.protocol + '//' + location.host;\n\n/**\n * The slice of `NavigateEvent` this router reads. Declared locally rather than\n * typing the handler `any`: these properties *are* the correctness boundary, so\n * a typo like `hashchange` for `hashChange` would silently disable a filter\n * forever. Names verified against Chromium's `NavigateEvent.prototype`.\n */\ninterface NavigateEventLike {\n readonly canIntercept: boolean;\n readonly hashChange: boolean;\n readonly downloadRequest: string | null;\n readonly formData: FormData | null;\n readonly navigationType: 'push' | 'replace' | 'reload' | 'traverse';\n readonly signal: AbortSignal;\n readonly destination: {readonly url: string};\n /** Not in every engine yet; used only as a best-effort `rel` check. */\n readonly sourceElement?: Element | null;\n intercept(options: InterceptOptions & {handler?: () => Promise<void>}): void;\n}\n\n/** The subset of `NavigationInterceptOptions` this router forwards. */\nexport interface InterceptOptions {\n focusReset?: 'after-transition' | 'manual';\n scroll?: 'after-transition' | 'manual';\n}\n\ninterface NavigationLike {\n addEventListener(\n type: 'navigate',\n listener: (e: NavigateEventLike) => void\n ): void;\n removeEventListener(\n type: 'navigate',\n listener: (e: NavigateEventLike) => void\n ): void;\n}\n\nconst getNavigation = (): NavigationLike | undefined =>\n (window as unknown as {navigation?: NavigationLike}).navigation;\n\n/**\n * True when the Navigation API is available — Baseline Newly Available since\n * January 2026 (Chrome/Edge, Safari 26.2, Firefox 147).\n *\n * This router **requires** it. Exported so an app can detect an unsupported\n * engine at boot and say so, rather than leaving the user to notice that every\n * link reloads the page.\n */\nexport const supportsNavigationApi = (): boolean =>\n typeof window !== 'undefined' &&\n typeof getNavigation()?.addEventListener === 'function';\n\n/**\n * A root-level router that intercepts navigation via the Navigation API.\n *\n * This class extends Routes so that it can also have a route configuration.\n *\n * There should only be one Router instance on a page, since the Router\n * installs a global listener. Nested routes should be configured with the\n * `Routes` class.\n *\n * ## Why the Navigation API\n *\n * Upstream intercepted navigation with a global click listener plus `popstate`\n * and committed with `history.pushState()`. That is structurally racy:\n * `pushState` is synchronous and `popstate` fires *after* the URL has already\n * moved, but `goto()` awaits `route.enter()` before swapping the outlet. The\n * URL leads and the outlet lags, leaving two sources of truth — the outgoing\n * route re-renders with stale params, and two quick navigations commit in\n * whatever order their `enter()` hooks happen to resolve.\n *\n * `navigateEvent.intercept({handler})` collapses that. The browser commits the\n * URL and holds the navigation un-finished while the handler runs, and it\n * aborts `navigateEvent.signal` when a newer navigation supersedes this one —\n * which `goto()` honours, so a superseded route can no longer win the outlet.\n *\n * ## No legacy fallback\n *\n * An earlier version of this fork kept upstream's click/popstate path for\n * pre-2026 engines. It was removed deliberately. Ten review rounds found\n * divergences between the two paths and **every one was in the click handler**,\n * never in this one — which is structural, not luck: this handler reads a\n * decision the browser has already made, while the click handler had to\n * re-derive it, re-implementing the rules for choosing a navigable, the\n * fragment-navigation predicate, and the modifier-key rules. Each round found\n * another place where the re-implementation and the spec disagreed.\n *\n * On an engine without the API, links fall back to ordinary full page loads.\n * For an app whose server serves the shell on every route that still works —\n * it is slower, not broken — and `supportsNavigationApi()` lets you detect it.\n * If real pre-2026 support is ever needed, use a Navigation API polyfill: one\n * decision path, with compatibility isolated in a layer whose whole job is\n * spec accuracy.\n */\nexport class Router extends Routes {\n /**\n * Options forwarded to `navigateEvent.intercept()`. Leaving these unset\n * gives the browser's default scroll and focus handling.\n */\n interceptOptions?: InterceptOptions;\n\n private _listening = false;\n\n override hostConnected() {\n super.hostConnected();\n // Gated on the exported predicate, not on `navigation !== undefined`:\n // a stub or partial polyfill under that name would otherwise make this\n // branch throw out of connectedCallback while `supportsNavigationApi()`\n // told the app it was unsupported — and then even the initial render below\n // would not run.\n if (supportsNavigationApi()) {\n getNavigation()!.addEventListener('navigate', this._onNavigate);\n this._listening = true;\n }\n // Kick off routed rendering by going to the current URL. Done even without\n // the API: a full page load still renders the right route, which is what\n // makes the unsupported-engine degradation \"slow\" rather than \"blank\".\n // Surfaced rather than left as a bare unhandled rejection, matching the\n // convention in routes.ts: on an engine without the API this is the *only*\n // rendering path, and a deep link with no matching route throws here.\n void this.goto(window.location.pathname).catch((err) => {\n queueMicrotask(() => {\n throw err;\n });\n });\n }\n\n override hostDisconnected() {\n super.hostDisconnected();\n if (this._listening) {\n getNavigation()?.removeEventListener('navigate', this._onNavigate);\n this._listening = false;\n }\n }\n\n /**\n * Handles same-document navigation from every source at once: anchor clicks,\n * `navigation.navigate()`, `history.pushState()`, and back/forward.\n */\n private _onNavigate = (e: NavigateEventLike) => {\n // Not ours to handle: anything the browser says cannot be intercepted,\n // fragment-only moves, downloads, and POST form submissions.\n if (!e.canIntercept || e.hashChange || e.downloadRequest !== null) {\n return;\n }\n if (e.formData) {\n return;\n }\n\n // Reloads must stay reloads. `canIntercept` is true for them, so without\n // this `location.reload()` silently degrades to re-running goto() on the\n // same path — the document is never replaced, breaking the standard\n // \"new version available, reload\" escape hatch. (It would also disagree\n // with the browser's own refresh button, which is not interceptable.)\n if (e.navigationType === 'reload') {\n return;\n }\n\n // `rel=\"external\"` is a convention this router honours — it is not defined\n // by HTML or by the Navigation API, so the browser will not decline these\n // for us. Best-effort: `sourceElement` is not in every engine, and is\n // absent for programmatic navigation.\n if (e.sourceElement?.getAttribute?.('rel') === 'external') {\n return;\n }\n\n const url = new URL(e.destination.url);\n if (url.origin !== origin) {\n return;\n }\n\n // Only intercept what we can actually render. `canIntercept` is true for\n // any same-origin URL, including cross-document ones — so without this a\n // link to a server-rendered page, an export endpoint, or a GET form\n // (whose `formData` is null) gets swallowed: the URL commits, goto()\n // throws \"No route found\", and the address bar is left pointing somewhere\n // the outlet never went. Declining lets the browser do the real\n // navigation, which is the correct outcome.\n if (!this.hasRouteFor(url.pathname)) {\n return;\n }\n\n e.intercept({\n ...this.interceptOptions,\n handler: async () => {\n // `e.signal` aborts if another navigation starts before this handler\n // resolves; goto() checks it after `enter()` and stands down.\n await this.goto(url.pathname, {signal: e.signal});\n },\n });\n };\n}\n"]}
1
+ {"version":3,"file":"router.js","sourceRoot":"","sources":["../src/router.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * @license\n * Copyright 2021 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *\n * Modifications Copyright 2026 VanLandingham Labs, same license.\n * Rebuilt on the Navigation API; see NOTICE.md.\n */\n\nimport {Routes} from './routes.js';\n\n/**\n * The slice of `NavigateEvent` this router reads. Declared locally rather than\n * typing the handler `any`: these properties *are* the correctness boundary, so\n * a typo like `hashchange` for `hashChange` would silently disable a filter\n * forever. Names verified against Chromium's `NavigateEvent.prototype`.\n */\ninterface NavigateEventLike {\n readonly canIntercept: boolean;\n readonly hashChange: boolean;\n readonly downloadRequest: string | null;\n readonly formData: FormData | null;\n readonly navigationType: 'push' | 'replace' | 'reload' | 'traverse';\n readonly signal: AbortSignal;\n readonly destination: {readonly url: string};\n /** Not in every engine yet; used only as a best-effort `rel` check. */\n readonly sourceElement?: Element | null;\n intercept(options: InterceptOptions & {handler?: () => Promise<void>}): void;\n}\n\n/** The subset of `NavigationInterceptOptions` this router forwards. */\nexport interface InterceptOptions {\n focusReset?: 'after-transition' | 'manual';\n scroll?: 'after-transition' | 'manual';\n}\n\ninterface NavigationLike {\n addEventListener(\n type: 'navigate',\n listener: (e: NavigateEventLike) => void\n ): void;\n removeEventListener(\n type: 'navigate',\n listener: (e: NavigateEventLike) => void\n ): void;\n}\n\nconst getNavigation = (): NavigationLike | undefined =>\n (window as unknown as {navigation?: NavigationLike}).navigation;\n\n/**\n * True when the Navigation API is available — Baseline Newly Available since\n * January 2026 (Chrome/Edge, Safari 26.2, Firefox 147).\n *\n * This router **requires** it. Exported so an app can detect an unsupported\n * engine at boot and say so, rather than leaving the user to notice that every\n * link reloads the page.\n */\nexport const supportsNavigationApi = (): boolean =>\n typeof window !== 'undefined' &&\n typeof getNavigation()?.addEventListener === 'function';\n\n/**\n * A root-level router that intercepts navigation via the Navigation API.\n *\n * This class extends Routes so that it can also have a route configuration.\n *\n * There should only be one Router instance on a page, since the Router\n * installs a global listener. Nested routes should be configured with the\n * `Routes` class.\n *\n * ## Why the Navigation API\n *\n * Upstream intercepted navigation with a global click listener plus `popstate`\n * and committed with `history.pushState()`. That is structurally racy:\n * `pushState` is synchronous and `popstate` fires *after* the URL has already\n * moved, but `goto()` awaits `route.enter()` before swapping the outlet. The\n * URL leads and the outlet lags, leaving two sources of truth — the outgoing\n * route re-renders with stale params, and two quick navigations commit in\n * whatever order their `enter()` hooks happen to resolve.\n *\n * `navigateEvent.intercept({handler})` collapses that. The browser commits the\n * URL and holds the navigation un-finished while the handler runs, and it\n * aborts `navigateEvent.signal` when a newer navigation supersedes this one —\n * which `goto()` honours, so a superseded route can no longer win the outlet.\n *\n * ## No legacy fallback\n *\n * An earlier version of this fork kept upstream's click/popstate path for\n * pre-2026 engines. It was removed deliberately. Ten review rounds found\n * divergences between the two paths and **every one was in the click handler**,\n * never in this one — which is structural, not luck: this handler reads a\n * decision the browser has already made, while the click handler had to\n * re-derive it, re-implementing the rules for choosing a navigable, the\n * fragment-navigation predicate, and the modifier-key rules. Each round found\n * another place where the re-implementation and the spec disagreed.\n *\n * On an engine without the API, links fall back to ordinary full page loads.\n * For an app whose server serves the shell on every route that still works —\n * it is slower, not broken — and `supportsNavigationApi()` lets you detect it.\n * If real pre-2026 support is ever needed, use a Navigation API polyfill: one\n * decision path, with compatibility isolated in a layer whose whole job is\n * spec accuracy.\n */\nexport class Router extends Routes {\n /**\n * Options forwarded to `navigateEvent.intercept()`. Leaving these unset\n * gives the browser's default scroll and focus handling.\n */\n interceptOptions?: InterceptOptions;\n\n private _listening = false;\n\n override hostConnected() {\n super.hostConnected();\n // Gated on the exported predicate, not on `navigation !== undefined`:\n // a stub or partial polyfill under that name would otherwise make this\n // branch throw out of connectedCallback while `supportsNavigationApi()`\n // told the app it was unsupported — and then even the initial render below\n // would not run.\n if (supportsNavigationApi()) {\n getNavigation()!.addEventListener('navigate', this._onNavigate);\n this._listening = true;\n }\n // Kick off routed rendering by going to the current URL. Done even without\n // the API: a full page load still renders the right route, which is what\n // makes the unsupported-engine degradation \"slow\" rather than \"blank\".\n // Surfaced rather than left as a bare unhandled rejection, matching the\n // convention in routes.ts: on an engine without the API this is the *only*\n // rendering path, and a deep link with no matching route throws here.\n void this.goto(window.location.pathname).catch((err) => {\n queueMicrotask(() => {\n throw err;\n });\n });\n }\n\n override hostDisconnected() {\n super.hostDisconnected();\n if (this._listening) {\n getNavigation()?.removeEventListener('navigate', this._onNavigate);\n this._listening = false;\n }\n }\n\n /**\n * Handles same-document navigation from every source at once: anchor clicks,\n * `navigation.navigate()`, `history.pushState()`, and back/forward.\n */\n private _onNavigate = (e: NavigateEventLike) => {\n // Not ours to handle: anything the browser says cannot be intercepted,\n // fragment-only moves, downloads, and POST form submissions.\n //\n // `!= null`, not `!== null`: the spec types both as nullable-but-present,\n // but a polyfill that leaves either unset would make a strict check true\n // for every ordinary link and silently decline the whole app.\n if (\n !e.canIntercept ||\n e.hashChange ||\n e.downloadRequest != null ||\n e.formData != null\n ) {\n return;\n }\n\n // Reloads must stay reloads. `canIntercept` is true for them, so without\n // this `location.reload()` silently degrades to re-running goto() on the\n // same path — the document is never replaced, breaking the standard\n // \"new version available, reload\" escape hatch. (It would also disagree\n // with the browser's own refresh button, which is not interceptable.)\n if (e.navigationType === 'reload') {\n return;\n }\n\n // `rel=\"external\"` is a convention this router honours — it is not defined\n // by HTML or by the Navigation API, so the browser will not decline these\n // for us. Best-effort: `sourceElement` is not in every engine, and is\n // absent for programmatic navigation.\n if (e.sourceElement?.getAttribute?.('rel') === 'external') {\n return;\n }\n\n // Read per navigation rather than cached at module scope: the value cannot\n // change, but reading it on import makes merely importing this module throw\n // where there is no `location` (SSR, a bundler evaluating for tree-shaking\n // under the package's `sideEffects: false` claim).\n const url = new URL(e.destination.url);\n if (url.origin !== window.location.origin) {\n return;\n }\n\n // Only intercept what we can actually render. `canIntercept` is true for\n // any same-origin URL, including cross-document ones — so without this a\n // link to a server-rendered page, an export endpoint, or a GET form\n // (whose `formData` is null) gets swallowed: the URL commits, goto()\n // throws \"No route found\", and the address bar is left pointing somewhere\n // the outlet never went. Declining lets the browser do the real\n // navigation, which is the correct outcome.\n if (!this.hasRouteFor(url.pathname)) {\n return;\n }\n\n e.intercept({\n ...this.interceptOptions,\n handler: async () => {\n // `e.signal` aborts if another navigation starts before this handler\n // resolves; goto() checks it after `enter()` and stands down.\n await this.goto(url.pathname, {signal: e.signal});\n },\n });\n };\n}\n"]}
@@ -44,6 +44,12 @@ export interface URLPatternRouteConfig extends BaseRouteConfig {
44
44
  * A real `URLPattern` satisfies this, so passing one still type-checks.
45
45
  */
46
46
  export interface URLPatternLike {
47
+ /**
48
+ * The pathname pattern string, as `URLPattern.prototype.pathname` returns
49
+ * it. Read to tell a trailing wildcard from any other positional group —
50
+ * the groups object alone cannot (see `tailOf`).
51
+ */
52
+ readonly pathname: string;
47
53
  test(input: {
48
54
  pathname: string;
49
55
  }): boolean;
@@ -71,7 +77,10 @@ export declare class Routes implements ReactiveController {
71
77
  routes: Array<RouteConfig>;
72
78
  /**
73
79
  * A default fallback route which will always be matched if none of the
74
- * {@link routes} match. Implicitly matches to the path "/*".
80
+ * {@link routes} match. Behaves like a `/*` route: `params[0]` is the whole
81
+ * pathname minus its leading slash, and is handed to child controllers as
82
+ * their tail. A nested controller's own pathname is a tail, with no leading
83
+ * slash; the fallback accepts that too.
75
84
  */
76
85
  fallback?: BaseRouteConfig;
77
86
  private readonly _childRoutes;
@@ -79,6 +88,7 @@ export declare class Routes implements ReactiveController {
79
88
  /** Monotonic goto counter; see the last-goto-wins note in goto(). */
80
89
  private _gotoSeq;
81
90
  private _currentPathname;
91
+ private _currentTail;
82
92
  private _currentRoute;
83
93
  private _currentParams;
84
94
  /**
@@ -122,6 +132,29 @@ export declare class Routes implements ReactiveController {
122
132
  get params(): {
123
133
  [key: string]: string | undefined;
124
134
  };
135
+ /**
136
+ * Hands a tail match to a child controller. Shared by the propagation loop in
137
+ * `goto()` and the late-mount path in `_onRoutesConnected`, so that identical
138
+ * input cannot be silent on one and an uncaught global throw on the other.
139
+ *
140
+ * A child with no route for the new tail is the expected case, not an error —
141
+ * the outgoing branch mid-swap, or a deep link to a path the child cannot
142
+ * render. Filtered structurally rather than by swallowing every rejection, so
143
+ * a genuine `enter()` rejection still surfaces the way it does upstream.
144
+ * Skipping must still supersede: `goto()` is where the counter is bumped, so
145
+ * returning without it would leave an in-flight child navigation current,
146
+ * free to commit over a URL that has moved on. A parent route with no tail
147
+ * at all is the same case: nothing to route, but still something to stand
148
+ * down.
149
+ *
150
+ * No abort signal is threaded through, and the goto is deliberately not
151
+ * awaited. The parent commits its own state before children run, so a child
152
+ * handed an already-aborted signal stands down with no newer goto() arriving
153
+ * to correct it, leaving the nested outlet stuck — reachable, because a
154
+ * hash-only navigation aborts the outstanding one without producing a
155
+ * replacement. Supersession is the counter's job.
156
+ */
157
+ private _routeChild;
125
158
  /**
126
159
  * Invalidate any in-flight `goto()` on this controller without starting a
127
160
  * new one. Same-class access, so `_gotoSeq` stays private to `Routes`.
@@ -138,9 +171,14 @@ export declare class Routes implements ReactiveController {
138
171
  */
139
172
  hasRouteFor(pathname: string): boolean;
140
173
  /**
141
- * Matches `url` against the installed routes and returns the first match.
174
+ * Matches `pathname` against the installed routes and returns the first match
175
+ * with its parsed parameters, or the fallback's match if one is configured.
176
+ *
177
+ * One `exec()` per candidate rather than `test()` to select and `exec()` to
178
+ * extract: that ran the winning pattern twice, and every caller that wants a
179
+ * route wants its params too.
142
180
  */
143
- private _getRoute;
181
+ private _match;
144
182
  hostConnected(): void;
145
183
  hostDisconnected(): void;
146
184
  private _onRoutesConnected;
@@ -1 +1 @@
1
- {"version":3,"file":"routes.d.ts","sourceRoot":"","sources":["../src/routes.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"routes.d.ts","sourceRoot":"","sources":["../src/routes.ts"],"names":[],"mappings":"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"}
@@ -21,6 +21,42 @@ const getPattern = (route) => {
21
21
  }
22
22
  return pattern;
23
23
  };
24
+ /**
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.
33
+ */
34
+ const TRAILING_WILDCARD = /(?:(?<![\\)}]|:[\w$]+)\*|\{\*\})\??$/;
35
+ /**
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.
45
+ */
46
+ const tailOf = (route, params) => {
47
+ if (!TRAILING_WILDCARD.test(getPattern(route).pathname)) {
48
+ return undefined;
49
+ }
50
+ let tailIndex = -1;
51
+ 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.
54
+ if (/^\d+$/.test(key) && Number(key) > tailIndex) {
55
+ tailIndex = Number(key);
56
+ }
57
+ }
58
+ return tailIndex < 0 ? undefined : params[String(tailIndex)];
59
+ };
24
60
  /**
25
61
  * A reactive controller that performs location-based routing using a
26
62
  * configuration of URL patterns and associated render callbacks.
@@ -46,7 +82,10 @@ export class Routes {
46
82
  routes = [];
47
83
  /**
48
84
  * A default fallback route which will always be matched if none of the
49
- * {@link routes} match. Implicitly matches to the path "/*".
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.
50
89
  */
51
90
  fallback;
52
91
  /*
@@ -65,6 +104,7 @@ export class Routes {
65
104
  /** Monotonic goto counter; see the last-goto-wins note in goto(). */
66
105
  _gotoSeq = 0;
67
106
  _currentPathname;
107
+ _currentTail;
68
108
  _currentRoute;
69
109
  _currentParams = {};
70
110
  /**
@@ -114,7 +154,6 @@ export class Routes {
114
154
  // fragments. It currently only handles path names because it's easier to
115
155
  // completely disregard the origin for now. The click handler only does
116
156
  // an in-page navigation if the origin matches anyway.
117
- const signal = options?.signal;
118
157
  // Last-goto-wins, per controller. The navigation signal alone is not
119
158
  // enough: a child controller mounts as a *result* of its parent's render,
120
159
  // so its first goto() comes from `_onRoutesConnected` — after the parent's
@@ -123,26 +162,23 @@ export class Routes {
123
162
  // newer one. This also keeps `Routes` correct when used on its own, with
124
163
  // no `Router` and no Navigation API in the picture.
125
164
  const seq = ++this._gotoSeq;
126
- const superseded = () => signal?.aborted === true || seq !== this._gotoSeq;
127
- let tailGroup;
165
+ let tail;
128
166
  if (this.routes.length === 0 && this.fallback === undefined) {
129
167
  // If a routes controller has none of its own routes it acts like it has
130
168
  // one route of `/*` so that it passes the whole pathname as a tail
131
169
  // match.
132
- tailGroup = pathname;
170
+ tail = pathname;
133
171
  this._currentPathname = '';
134
172
  // Simulate a tail group with the whole pathname
135
- this._currentParams = { 0: tailGroup };
173
+ this._currentParams = { 0: tail };
136
174
  }
137
175
  else {
138
- const route = this._getRoute(pathname);
139
- if (route === undefined) {
176
+ const match = this._match(pathname);
177
+ if (match === undefined) {
140
178
  throw new Error(`No route found for ${pathname}`);
141
179
  }
142
- const pattern = getPattern(route);
143
- const result = pattern.exec({ pathname });
144
- const params = result?.pathname.groups ?? {};
145
- tailGroup = getTailGroup(params);
180
+ const { route, params } = match;
181
+ tail = match.tail;
146
182
  if (typeof route.enter === 'function') {
147
183
  const success = await route.enter(params);
148
184
  // If enter() returns false, cancel this navigation
@@ -152,17 +188,18 @@ export class Routes {
152
188
  }
153
189
  // A newer navigation superseded this one while `enter` was awaiting.
154
190
  // Committing now would swap the outlet onto a route the URL has left.
155
- if (superseded()) {
191
+ if (options?.signal?.aborted === true || seq !== this._gotoSeq) {
156
192
  return;
157
193
  }
158
194
  // Only update route state if the enter handler completes successfully
159
195
  this._currentRoute = route;
160
196
  this._currentParams = params;
161
197
  this._currentPathname =
162
- tailGroup === undefined
198
+ tail === undefined
163
199
  ? pathname
164
- : pathname.substring(0, pathname.length - tailGroup.length);
200
+ : pathname.substring(0, pathname.length - tail.length);
165
201
  }
202
+ this._currentTail = tail;
166
203
  // Propagate the tail match to children — deliberately NOT awaited.
167
204
  //
168
205
  // Awaiting looks like it would make `navigation.finished` cover the whole
@@ -175,35 +212,14 @@ export class Routes {
175
212
  // propagates out of here and `requestUpdate()` below never runs — URL
176
213
  // committed, outlet stranded, i.e. this fork's own thesis bug one level
177
214
  // down. Nested supersession is handled by the goto counter above, not by
178
- // awaiting. Errors are swallowed rather than left as unhandled rejections.
179
- if (tailGroup !== undefined) {
180
- for (const childRoutes of this._childRoutes) {
181
- // No signal, for the same reason as the late-mount path below: the
182
- // parent commits before children run, so a child handed an aborted
183
- // signal stands down with no newer goto() arriving to correct it,
184
- // leaving the nested outlet stuck. A hash-only navigation aborts the
185
- // outstanding one without producing a replacement, so this is
186
- // reachable. Supersession is the counter's job.
187
- //
188
- // The expected failure here is a child with no route for the new tail
189
- // — the outgoing branch, mid-swap. Filter that structurally rather
190
- // than swallowing everything, so a genuine `enter()` rejection still
191
- // surfaces the way it does upstream instead of vanishing.
192
- if (!childRoutes.hasRouteFor(tailGroup)) {
193
- // Skip the navigation but still supersede: `goto()` is where the
194
- // counter is bumped, so returning early here would leave an
195
- // in-flight child navigation current, free to commit over a URL that
196
- // has moved on. Removing the abort signal above is only safe because
197
- // the counter always runs — including here.
198
- childRoutes._supersede();
199
- continue;
200
- }
201
- void childRoutes.goto(tailGroup).catch((err) => {
202
- queueMicrotask(() => {
203
- throw err;
204
- });
205
- });
206
- }
215
+ // awaiting. `_routeChild` covers the per-child filtering and error policy.
216
+ //
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.
221
+ for (const childRoutes of this._childRoutes) {
222
+ this._routeChild(childRoutes, tail);
207
223
  }
208
224
  this._host.requestUpdate();
209
225
  }
@@ -219,6 +235,39 @@ export class Routes {
219
235
  get params() {
220
236
  return this._currentParams;
221
237
  }
238
+ /**
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.
252
+ *
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.
259
+ */
260
+ _routeChild(child, tail) {
261
+ if (tail === undefined || !child.hasRouteFor(tail)) {
262
+ child._supersede();
263
+ return;
264
+ }
265
+ void child.goto(tail).catch((err) => {
266
+ queueMicrotask(() => {
267
+ throw err;
268
+ });
269
+ });
270
+ }
222
271
  /**
223
272
  * Invalidate any in-flight `goto()` on this controller without starting a
224
273
  * new one. Same-class access, so `_gotoSeq` stays private to `Routes`.
@@ -253,27 +302,44 @@ export class Routes {
253
302
  * server-rendered page, an export endpoint, or a GET form still works.
254
303
  */
255
304
  hasRouteFor(pathname) {
256
- // Mirrors goto()'s special case: a controller with no routes of its own
257
- // behaves as if it had a single `/*` route.
258
- if (this.routes.length === 0 && this.fallback === undefined) {
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.
309
+ if (this.fallback !== undefined || this.routes.length === 0) {
259
310
  return true;
260
311
  }
261
- return this._getRoute(pathname) !== undefined;
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 }));
262
315
  }
263
316
  /**
264
- * Matches `url` against the installed routes and returns the first match.
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.
265
323
  */
266
- _getRoute(pathname) {
267
- const matchedRoute = this.routes.find((r) => getPattern(r).test({ pathname: pathname }));
268
- if (matchedRoute || this.fallback === undefined) {
269
- return matchedRoute;
324
+ _match(pathname) {
325
+ for (const route of this.routes) {
326
+ const result = getPattern(route).exec({ pathname });
327
+ if (result !== null) {
328
+ const params = result.pathname.groups;
329
+ return { route, params, tail: tailOf(route, params) };
330
+ }
270
331
  }
271
- if (this.fallback) {
272
- // The fallback route behaves like it has a "/*" path. This is hidden from
273
- // the public API but is added here to return a valid RouteConfig.
274
- return { ...this.fallback, path: '/*' };
332
+ if (this.fallback === undefined) {
333
+ return undefined;
275
334
  }
276
- return undefined;
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.
341
+ const tail = pathname.startsWith('/') ? pathname.slice(1) : pathname;
342
+ return { route: { ...this.fallback, path: '/*' }, params: { 0: tail }, tail };
277
343
  }
278
344
  hostConnected() {
279
345
  this._host.addEventListener(RoutesConnectedEvent.eventName, this._onRoutesConnected);
@@ -306,43 +372,18 @@ export class Routes {
306
372
  childRoutes._parentRoutes = this;
307
373
  e.stopImmediatePropagation();
308
374
  e.onDisconnect = () => {
309
- // Remove route from this._childRoutes:
310
- // `>>> 0` converts -1 to 2**32-1
311
- this._childRoutes?.splice(this._childRoutes.indexOf(childRoutes) >>> 0, 1);
375
+ const index = this._childRoutes.indexOf(childRoutes);
376
+ if (index !== -1) {
377
+ this._childRoutes.splice(index, 1);
378
+ }
312
379
  };
313
- const tailGroup = getTailGroup(this._currentParams);
314
- // Same structural filter as the propagation path in goto(): a child that
315
- // mounts under a tail it cannot render is the expected case (a deep link
316
- // to `/x/unknown`), not an error. Without this the two call sites disagree
317
- // — silent there, uncaught global throw here — for identical input.
318
- if (tailGroup !== undefined && childRoutes.hasRouteFor(tailGroup)) {
319
- // No signal here on purpose. The parent commits its own state before
320
- // children run, so by the time a late child mounts the navigation may
321
- // already have been aborted — handing it that signal makes it stand down
322
- // with no newer goto() ever arriving to correct it, leaving the nested
323
- // outlet blank permanently. The goto counter covers what matters
324
- // (supersession by a newer goto).
325
- void childRoutes.goto(tailGroup).catch((err) => {
326
- queueMicrotask(() => {
327
- throw err;
328
- });
329
- });
330
- }
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.
384
+ this._routeChild(childRoutes, this._currentTail);
331
385
  };
332
386
  }
333
- /**
334
- * Returns the tail of a pathname groups object. This is the match from a
335
- * wildcard at the end of a pathname pattern, like `/foo/*`
336
- */
337
- const getTailGroup = (groups) => {
338
- let tailKey;
339
- for (const key of Object.keys(groups)) {
340
- if (/\d+/.test(key) && (tailKey === undefined || key > tailKey)) {
341
- tailKey = key;
342
- }
343
- }
344
- return tailKey && groups[tailKey];
345
- };
346
387
  /**
347
388
  * This event is fired from Routes controllers when their host is connected to
348
389
  * announce the child route and potentially connect to a parent routes controller.
@@ -1 +1 @@
1
- {"version":3,"file":"routes.js","sourceRoot":"","sources":["../src/routes.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * @license\n * Copyright 2021 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *\n * Modifications Copyright 2026 VanLandingham Labs, same license. See NOTICE.md.\n */\n\n/// <reference types=\"urlpattern-polyfill\" />\n\nimport type {ReactiveController, ReactiveControllerHost} from 'lit';\n\nexport interface BaseRouteConfig {\n name?: string | undefined;\n render?: (params: {[key: string]: string | undefined}) => unknown;\n enter?: (params: {\n [key: string]: string | undefined;\n }) => Promise<boolean> | boolean;\n}\n\n/**\n * A RouteConfig that matches against a `path` string. `path` must be a\n * [`URLPattern` compatible pathname pattern](https://developer.mozilla.org/en-US/docs/Web/API/URLPattern/pathname).\n */\nexport interface PathRouteConfig extends BaseRouteConfig {\n path: string;\n}\n\n/**\n * A RouteConfig that matches against a given [`URLPattern`](https://developer.mozilla.org/en-US/docs/Web/API/URLPattern)\n *\n * While `URLPattern` can match against protocols, hostnames, and ports,\n * routes will only be checked for matches if they're part of the current\n * origin. This means that the pattern is limited to checking `pathname` and\n * `search`.\n */\nexport interface URLPatternRouteConfig extends BaseRouteConfig {\n pattern: URLPatternLike;\n}\n\n/**\n * The part of `URLPattern` this router uses.\n *\n * Declared structurally rather than referencing the global so the emitted\n * `.d.ts` is self-contained: the `/// <reference types=\"urlpattern-polyfill\" />`\n * above is not carried into declaration output, and `URLPattern` is not in\n * TypeScript's bundled `lib.dom`, so a published package typed against the\n * global fails a consumer build with `TS2304: Cannot find name 'URLPattern'`.\n * A real `URLPattern` satisfies this, so passing one still type-checks.\n */\nexport interface URLPatternLike {\n test(input: {pathname: string}): boolean;\n exec(input: {pathname: string}): {\n pathname: {groups: {[key: string]: string | undefined}};\n } | null;\n}\n\n/**\n * A description of a route, which path or pattern to match against, and a\n * render() callback used to render a match to the outlet.\n */\nexport type RouteConfig = PathRouteConfig | URLPatternRouteConfig;\n\n// A cache of URLPatterns created for PathRouteConfig.\n// Rather than converting all given RoutConfigs to URLPatternRouteConfig, this\n// lets us make `routes` mutable so users can add new PathRouteConfigs\n// dynamically.\nconst patternCache = new WeakMap<PathRouteConfig, URLPatternLike>();\n\nconst isPatternConfig = (route: RouteConfig): route is URLPatternRouteConfig =>\n (route as URLPatternRouteConfig).pattern !== undefined;\n\nconst getPattern = (route: RouteConfig): URLPatternLike => {\n if (isPatternConfig(route)) {\n return route.pattern;\n }\n let pattern = patternCache.get(route);\n if (pattern === undefined) {\n patternCache.set(route, (pattern = new URLPattern({pathname: route.path})));\n }\n return pattern;\n};\n\n/**\n * A reactive controller that performs location-based routing using a\n * configuration of URL patterns and associated render callbacks.\n */\nexport class Routes implements ReactiveController {\n private readonly _host: ReactiveControllerHost & HTMLElement;\n\n /*\n * The currently installed set of routes in precedence order.\n *\n * This array is mutable. To dynamically add a new route you can write:\n *\n * ```ts\n * this._routes.routes.push({\n * path: '/foo',\n * render: () => html`<p>Foo</p>`,\n * });\n * ```\n *\n * Mutating this property does not trigger any route transitions. If the\n * changes may result is a different route matching for the current path, you\n * must instigate a route update with `goto()`.\n */\n routes: Array<RouteConfig> = [];\n\n /**\n * A default fallback route which will always be matched if none of the\n * {@link routes} match. Implicitly matches to the path \"/*\".\n */\n fallback?: BaseRouteConfig;\n\n /*\n * The current set of child Routes controllers. These are connected via\n * the routes-connected event.\n */\n private readonly _childRoutes: Array<Routes> = [];\n\n private _parentRoutes: Routes | undefined;\n\n /*\n * State related to the current matching route.\n *\n * We keep this so that consuming code can access current parameters, and so\n * that we can propagate tail matches to child routes if they are added after\n * navigation / matching.\n */\n /** Monotonic goto counter; see the last-goto-wins note in goto(). */\n private _gotoSeq = 0;\n\n private _currentPathname: string | undefined;\n private _currentRoute: RouteConfig | undefined;\n private _currentParams: {\n [key: string]: string | undefined;\n } = {};\n\n /**\n * Callback to call when this controller is disconnected.\n *\n * It's critical to call this immediately in hostDisconnected so that this\n * controller instance doesn't receive a tail match meant for another route.\n */\n // TODO (justinfagnani): Do we need this now that we have a direct reference\n // to the parent? We can call `this._parentRoutes.disconnect(this)`.\n private _onDisconnect: (() => void) | undefined;\n\n constructor(\n host: ReactiveControllerHost & HTMLElement,\n routes: Array<RouteConfig>,\n options?: {fallback?: BaseRouteConfig}\n ) {\n (this._host = host).addController(this);\n this.routes = [...routes];\n this.fallback = options?.fallback;\n }\n\n /**\n * Returns a URL string of the current route, including parent routes,\n * optionally replacing the local path with `pathname`.\n */\n link(pathname?: string): string {\n if (pathname?.startsWith('/')) {\n return pathname;\n }\n if (pathname?.startsWith('.')) {\n throw new Error('Not implemented');\n }\n pathname ??= this._currentPathname;\n return (this._parentRoutes?.link() ?? '') + pathname;\n }\n\n /**\n * Navigates this routes controller to `pathname`.\n *\n * This does not navigate parent routes, so it isn't (yet) a general page\n * navigation API. It does navigate child routes if pathname matches a\n * pattern with a tail wildcard pattern (`/*`).\n *\n * Pass `options.signal` to make the navigation abandonable. `enter()` is\n * awaited, so a second `goto()` can start — and finish — while the first is\n * still resolving its route; without a signal the slower one commits last\n * and the outlet ends up on a route the URL has already left. `Router`\n * threads `NavigateEvent.signal` through for exactly this reason.\n */\n async goto(pathname: string, options?: {signal?: AbortSignal}) {\n // TODO (justinfagnani): handle absolute vs relative paths separately.\n\n // TODO (justinfagnani): generalize this to handle query params and\n // fragments. It currently only handles path names because it's easier to\n // completely disregard the origin for now. The click handler only does\n // an in-page navigation if the origin matches anyway.\n const signal = options?.signal;\n // Last-goto-wins, per controller. The navigation signal alone is not\n // enough: a child controller mounts as a *result* of its parent's render,\n // so its first goto() comes from `_onRoutesConnected` — after the parent's\n // navigation has already finished, and therefore with a signal that will\n // never abort. Without this counter a slow first child load commits over a\n // newer one. This also keeps `Routes` correct when used on its own, with\n // no `Router` and no Navigation API in the picture.\n const seq = ++this._gotoSeq;\n const superseded = () => signal?.aborted === true || seq !== this._gotoSeq;\n let tailGroup: string | undefined;\n\n if (this.routes.length === 0 && this.fallback === undefined) {\n // If a routes controller has none of its own routes it acts like it has\n // one route of `/*` so that it passes the whole pathname as a tail\n // match.\n tailGroup = pathname;\n this._currentPathname = '';\n // Simulate a tail group with the whole pathname\n this._currentParams = {0: tailGroup};\n } else {\n const route = this._getRoute(pathname);\n if (route === undefined) {\n throw new Error(`No route found for ${pathname}`);\n }\n const pattern = getPattern(route);\n const result = pattern.exec({pathname});\n const params = result?.pathname.groups ?? {};\n tailGroup = getTailGroup(params);\n if (typeof route.enter === 'function') {\n const success = await route.enter(params);\n // If enter() returns false, cancel this navigation\n if (success === false) {\n return;\n }\n }\n // A newer navigation superseded this one while `enter` was awaiting.\n // Committing now would swap the outlet onto a route the URL has left.\n if (superseded()) {\n return;\n }\n // Only update route state if the enter handler completes successfully\n this._currentRoute = route;\n this._currentParams = params;\n this._currentPathname =\n tailGroup === undefined\n ? pathname\n : pathname.substring(0, pathname.length - tailGroup.length);\n }\n\n // Propagate the tail match to children — deliberately NOT awaited.\n //\n // Awaiting looks like it would make `navigation.finished` cover the whole\n // tree, and an earlier revision of this fork did it. It is wrong twice\n // over. At this point `requestUpdate()` has not run, so `_childRoutes`\n // still holds the *outgoing* branch's controller: awaiting it gates the\n // parent's outlet swap on an `enter()` for a tail that controller will\n // never render (a hung one blocks the navigation forever), and if that\n // child has no route for the new tail its `No route found` throw\n // propagates out of here and `requestUpdate()` below never runs — URL\n // committed, outlet stranded, i.e. this fork's own thesis bug one level\n // down. Nested supersession is handled by the goto counter above, not by\n // awaiting. Errors are swallowed rather than left as unhandled rejections.\n if (tailGroup !== undefined) {\n for (const childRoutes of this._childRoutes) {\n // No signal, for the same reason as the late-mount path below: the\n // parent commits before children run, so a child handed an aborted\n // signal stands down with no newer goto() arriving to correct it,\n // leaving the nested outlet stuck. A hash-only navigation aborts the\n // outstanding one without producing a replacement, so this is\n // reachable. Supersession is the counter's job.\n //\n // The expected failure here is a child with no route for the new tail\n // — the outgoing branch, mid-swap. Filter that structurally rather\n // than swallowing everything, so a genuine `enter()` rejection still\n // surfaces the way it does upstream instead of vanishing.\n if (!childRoutes.hasRouteFor(tailGroup)) {\n // Skip the navigation but still supersede: `goto()` is where the\n // counter is bumped, so returning early here would leave an\n // in-flight child navigation current, free to commit over a URL that\n // has moved on. Removing the abort signal above is only safe because\n // the counter always runs — including here.\n childRoutes._supersede();\n continue;\n }\n void childRoutes.goto(tailGroup).catch((err) => {\n queueMicrotask(() => {\n throw err;\n });\n });\n }\n }\n this._host.requestUpdate();\n }\n\n /**\n * The result of calling the current route's render() callback.\n */\n outlet() {\n return this._currentRoute?.render?.(this._currentParams);\n }\n\n /**\n * The current parsed route parameters.\n */\n get params() {\n return this._currentParams;\n }\n\n /**\n * Invalidate any in-flight `goto()` on this controller without starting a\n * new one. Same-class access, so `_gotoSeq` stays private to `Routes`.\n */\n private _supersede(seen: Set<Routes> = new Set()): void {\n // Unreachable defence in depth. Upstream *can* produce a `_childRoutes`\n // cycle — a host carrying two Routes controllers, disconnected and\n // reconnected, ends up with each registered as the other's child — but\n // `hostDisconnected` below removes the listener that causes it, and a test\n // asserts the cycle cannot form. Kept because an unguarded recursive walk\n // over a cycle is a stack overflow rather than a misrender.\n if (seen.has(this)) {\n return;\n }\n seen.add(this);\n this._gotoSeq++;\n // Recursive: on the navigating branch the child's own propagation loop\n // reaches the grandchildren, but a skipped child never runs one — so\n // without this an in-flight grandchild `enter()` stays current and commits\n // over a URL that has moved on, the same defect one level deeper.\n for (const child of this._childRoutes) {\n child._supersede(seen);\n }\n }\n\n /**\n * True when this controller can render `pathname` — i.e. a route matches, or\n * a fallback is configured.\n *\n * `Router` gates interception on this: intercepting a path we cannot render\n * commits the URL and then throws out of `goto()`, leaving the address bar\n * moved and the outlet stale. Letting the browser handle it instead means a\n * server-rendered page, an export endpoint, or a GET form still works.\n */\n hasRouteFor(pathname: string): boolean {\n // Mirrors goto()'s special case: a controller with no routes of its own\n // behaves as if it had a single `/*` route.\n if (this.routes.length === 0 && this.fallback === undefined) {\n return true;\n }\n return this._getRoute(pathname) !== undefined;\n }\n\n /**\n * Matches `url` against the installed routes and returns the first match.\n */\n private _getRoute(pathname: string): RouteConfig | undefined {\n const matchedRoute = this.routes.find((r) =>\n getPattern(r).test({pathname: pathname})\n );\n if (matchedRoute || this.fallback === undefined) {\n return matchedRoute;\n }\n if (this.fallback) {\n // The fallback route behaves like it has a \"/*\" path. This is hidden from\n // the public API but is added here to return a valid RouteConfig.\n return {...this.fallback, path: '/*'};\n }\n return undefined;\n }\n\n hostConnected() {\n this._host.addEventListener(\n RoutesConnectedEvent.eventName,\n this._onRoutesConnected\n );\n const event = new RoutesConnectedEvent(this);\n this._host.dispatchEvent(event);\n this._onDisconnect = event.onDisconnect;\n }\n\n hostDisconnected() {\n // Remove the listener hostConnected added. Without this a host that is\n // disconnected and reconnected (a repeat() reorder, a tab swap) leaves the\n // sibling controller's listener installed, so on the second connect it\n // claims the re-dispatching controller as *its* child and the pair point\n // at each other — a real `_childRoutes` cycle, which recursive walks turn\n // into a stack overflow.\n this._host.removeEventListener(\n RoutesConnectedEvent.eventName,\n this._onRoutesConnected\n );\n // When this child routes controller is disconnected because a parent\n // outlet rendered a different template, disconnecting will ensure that\n // this controller doesn't receive a tail match meant for another route.\n this._onDisconnect?.();\n this._parentRoutes = undefined;\n }\n\n private _onRoutesConnected = (e: RoutesConnectedEvent) => {\n // Don't handle the event fired by this routes controller, which we get\n // because we do this.dispatchEvent(...)\n if (e.routes === this) {\n return;\n }\n\n const childRoutes = e.routes;\n this._childRoutes.push(childRoutes);\n childRoutes._parentRoutes = this;\n\n e.stopImmediatePropagation();\n e.onDisconnect = () => {\n // Remove route from this._childRoutes:\n // `>>> 0` converts -1 to 2**32-1\n this._childRoutes?.splice(\n this._childRoutes.indexOf(childRoutes) >>> 0,\n 1\n );\n };\n\n const tailGroup = getTailGroup(this._currentParams);\n // Same structural filter as the propagation path in goto(): a child that\n // mounts under a tail it cannot render is the expected case (a deep link\n // to `/x/unknown`), not an error. Without this the two call sites disagree\n // — silent there, uncaught global throw here — for identical input.\n if (tailGroup !== undefined && childRoutes.hasRouteFor(tailGroup)) {\n // No signal here on purpose. The parent commits its own state before\n // children run, so by the time a late child mounts the navigation may\n // already have been aborted — handing it that signal makes it stand down\n // with no newer goto() ever arriving to correct it, leaving the nested\n // outlet blank permanently. The goto counter covers what matters\n // (supersession by a newer goto).\n void childRoutes.goto(tailGroup).catch((err) => {\n queueMicrotask(() => {\n throw err;\n });\n });\n }\n };\n}\n\n/**\n * Returns the tail of a pathname groups object. This is the match from a\n * wildcard at the end of a pathname pattern, like `/foo/*`\n */\nconst getTailGroup = (groups: {[key: string]: string | undefined}) => {\n let tailKey: string | undefined;\n for (const key of Object.keys(groups)) {\n if (/\\d+/.test(key) && (tailKey === undefined || key > tailKey!)) {\n tailKey = key;\n }\n }\n return tailKey && groups[tailKey];\n};\n\n/**\n * This event is fired from Routes controllers when their host is connected to\n * announce the child route and potentially connect to a parent routes controller.\n */\nexport class RoutesConnectedEvent extends Event {\n static readonly eventName = 'lit-routes-connected';\n readonly routes: Routes;\n onDisconnect?: () => void;\n\n constructor(routes: Routes) {\n super(RoutesConnectedEvent.eventName, {\n bubbles: true,\n composed: true,\n cancelable: false,\n });\n this.routes = routes;\n }\n}\n\ndeclare global {\n interface HTMLElementEventMap {\n [RoutesConnectedEvent.eventName]: RoutesConnectedEvent;\n }\n}\n"]}
1
+ {"version":3,"file":"routes.js","sourceRoot":"","sources":["../src/routes.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * @license\n * Copyright 2021 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n *\n * Modifications Copyright 2026 VanLandingham Labs, same license. See NOTICE.md.\n */\n\n/// <reference types=\"urlpattern-polyfill\" />\n\nimport type {ReactiveController, ReactiveControllerHost} from 'lit';\n\nexport interface BaseRouteConfig {\n name?: string | undefined;\n render?: (params: {[key: string]: string | undefined}) => unknown;\n enter?: (params: {\n [key: string]: string | undefined;\n }) => Promise<boolean> | boolean;\n}\n\n/**\n * A RouteConfig that matches against a `path` string. `path` must be a\n * [`URLPattern` compatible pathname pattern](https://developer.mozilla.org/en-US/docs/Web/API/URLPattern/pathname).\n */\nexport interface PathRouteConfig extends BaseRouteConfig {\n path: string;\n}\n\n/**\n * A RouteConfig that matches against a given [`URLPattern`](https://developer.mozilla.org/en-US/docs/Web/API/URLPattern)\n *\n * While `URLPattern` can match against protocols, hostnames, and ports,\n * routes will only be checked for matches if they're part of the current\n * origin. This means that the pattern is limited to checking `pathname` and\n * `search`.\n */\nexport interface URLPatternRouteConfig extends BaseRouteConfig {\n pattern: URLPatternLike;\n}\n\n/**\n * The part of `URLPattern` this router uses.\n *\n * Declared structurally rather than referencing the global so the emitted\n * `.d.ts` is self-contained: the `/// <reference types=\"urlpattern-polyfill\" />`\n * above is not carried into declaration output, and `URLPattern` is not in\n * TypeScript's bundled `lib.dom`, so a published package typed against the\n * global fails a consumer build with `TS2304: Cannot find name 'URLPattern'`.\n * A real `URLPattern` satisfies this, so passing one still type-checks.\n */\nexport interface URLPatternLike {\n /**\n * The pathname pattern string, as `URLPattern.prototype.pathname` returns\n * it. Read to tell a trailing wildcard from any other positional group —\n * the groups object alone cannot (see `tailOf`).\n */\n readonly pathname: string;\n test(input: {pathname: string}): boolean;\n exec(input: {pathname: string}): {\n pathname: {groups: {[key: string]: string | undefined}};\n } | null;\n}\n\n/**\n * A description of a route, which path or pattern to match against, and a\n * render() callback used to render a match to the outlet.\n */\nexport type RouteConfig = PathRouteConfig | URLPatternRouteConfig;\n\n// A cache of URLPatterns created for PathRouteConfig.\n// Rather than converting all given RoutConfigs to URLPatternRouteConfig, this\n// lets us make `routes` mutable so users can add new PathRouteConfigs\n// dynamically.\nconst patternCache = new WeakMap<PathRouteConfig, URLPatternLike>();\n\nconst isPatternConfig = (route: RouteConfig): route is URLPatternRouteConfig =>\n (route as URLPatternRouteConfig).pattern !== undefined;\n\nconst getPattern = (route: RouteConfig): URLPatternLike => {\n if (isPatternConfig(route)) {\n return route.pattern;\n }\n let pattern = patternCache.get(route);\n if (pattern === undefined) {\n patternCache.set(route, (pattern = new URLPattern({pathname: route.path})));\n }\n return pattern;\n};\n\n/**\n * Matches a pathname pattern that ends in a wildcard, in the forms\n * `URLPattern.prototype.pathname` regenerates one: a bare `*` — which a\n * trailing `(.*)` also normalises to — or `{*}`, which the generator emits for\n * a wildcard after a modified group, e.g. `/docs{/}?*`. Either may be\n * optional (`*?`). Not a wildcard: an escaped `\\*`, or a `*` that is the\n * modifier on a group (`(\\d+)*`, `{/}*`) or on a named param (`:rest*`).\n * Those exclusions matter when an earlier positional group exists —\n * `/x/(\\d+)/:rest*` — since that group would otherwise be taken for the tail.\n */\nconst TRAILING_WILDCARD = /(?:(?<![\\\\)}]|:[\\w$]+)\\*|\\{\\*\\})\\??$/;\n\n/**\n * The tail of a match — what a trailing wildcard (`/foo/*`) captured — or\n * undefined when the pattern has none.\n *\n * Decided from the pattern, not from the groups object: an unnamed regex group\n * (`/post/(\\d+)`) and a wildcard that is not last (`/foo/*` followed by\n * `/bar`) are keyed by index exactly as a tail is, and reading either as one\n * truncated `link()` and handed a child the wrong segment. When a trailing\n * wildcard is present it is the last group in the pattern, so its key is the\n * highest positional index.\n */\nconst tailOf = (\n route: RouteConfig,\n params: {[key: string]: string | undefined}\n): string | undefined => {\n if (!TRAILING_WILDCARD.test(getPattern(route).pathname)) {\n return undefined;\n }\n let tailIndex = -1;\n for (const key of Object.keys(params)) {\n // Numeric, not lexicographic: '9' sorts above '10' as a string, so a\n // pattern with eleven or more wildcards picked group 9 as its tail.\n if (/^\\d+$/.test(key) && Number(key) > tailIndex) {\n tailIndex = Number(key);\n }\n }\n return tailIndex < 0 ? undefined : params[String(tailIndex)];\n};\n\n/**\n * A reactive controller that performs location-based routing using a\n * configuration of URL patterns and associated render callbacks.\n */\nexport class Routes implements ReactiveController {\n private readonly _host: ReactiveControllerHost & HTMLElement;\n\n /*\n * The currently installed set of routes in precedence order.\n *\n * This array is mutable. To dynamically add a new route you can write:\n *\n * ```ts\n * this._routes.routes.push({\n * path: '/foo',\n * render: () => html`<p>Foo</p>`,\n * });\n * ```\n *\n * Mutating this property does not trigger any route transitions. If the\n * changes may result is a different route matching for the current path, you\n * must instigate a route update with `goto()`.\n */\n routes: Array<RouteConfig> = [];\n\n /**\n * A default fallback route which will always be matched if none of the\n * {@link routes} match. Behaves like a `/*` route: `params[0]` is the whole\n * pathname minus its leading slash, and is handed to child controllers as\n * their tail. A nested controller's own pathname is a tail, with no leading\n * slash; the fallback accepts that too.\n */\n fallback?: BaseRouteConfig;\n\n /*\n * The current set of child Routes controllers. These are connected via\n * the routes-connected event.\n */\n private readonly _childRoutes: Array<Routes> = [];\n\n private _parentRoutes: Routes | undefined;\n\n /*\n * State related to the current matching route.\n *\n * We keep this so that consuming code can access current parameters, and so\n * that we can propagate tail matches to child routes if they are added after\n * navigation / matching.\n */\n /** Monotonic goto counter; see the last-goto-wins note in goto(). */\n private _gotoSeq = 0;\n\n private _currentPathname: string | undefined;\n private _currentTail: string | undefined;\n private _currentRoute: RouteConfig | undefined;\n private _currentParams: {\n [key: string]: string | undefined;\n } = {};\n\n /**\n * Callback to call when this controller is disconnected.\n *\n * It's critical to call this immediately in hostDisconnected so that this\n * controller instance doesn't receive a tail match meant for another route.\n */\n // TODO (justinfagnani): Do we need this now that we have a direct reference\n // to the parent? We can call `this._parentRoutes.disconnect(this)`.\n private _onDisconnect: (() => void) | undefined;\n\n constructor(\n host: ReactiveControllerHost & HTMLElement,\n routes: Array<RouteConfig>,\n options?: {fallback?: BaseRouteConfig}\n ) {\n (this._host = host).addController(this);\n this.routes = [...routes];\n this.fallback = options?.fallback;\n }\n\n /**\n * Returns a URL string of the current route, including parent routes,\n * optionally replacing the local path with `pathname`.\n */\n link(pathname?: string): string {\n if (pathname?.startsWith('/')) {\n return pathname;\n }\n if (pathname?.startsWith('.')) {\n throw new Error('Not implemented');\n }\n pathname ??= this._currentPathname;\n return (this._parentRoutes?.link() ?? '') + pathname;\n }\n\n /**\n * Navigates this routes controller to `pathname`.\n *\n * This does not navigate parent routes, so it isn't (yet) a general page\n * navigation API. It does navigate child routes if pathname matches a\n * pattern with a tail wildcard pattern (`/*`).\n *\n * Pass `options.signal` to make the navigation abandonable. `enter()` is\n * awaited, so a second `goto()` can start — and finish — while the first is\n * still resolving its route; without a signal the slower one commits last\n * and the outlet ends up on a route the URL has already left. `Router`\n * threads `NavigateEvent.signal` through for exactly this reason.\n */\n async goto(pathname: string, options?: {signal?: AbortSignal}) {\n // TODO (justinfagnani): handle absolute vs relative paths separately.\n\n // TODO (justinfagnani): generalize this to handle query params and\n // fragments. It currently only handles path names because it's easier to\n // completely disregard the origin for now. The click handler only does\n // an in-page navigation if the origin matches anyway.\n // Last-goto-wins, per controller. The navigation signal alone is not\n // enough: a child controller mounts as a *result* of its parent's render,\n // so its first goto() comes from `_onRoutesConnected` — after the parent's\n // navigation has already finished, and therefore with a signal that will\n // never abort. Without this counter a slow first child load commits over a\n // newer one. This also keeps `Routes` correct when used on its own, with\n // no `Router` and no Navigation API in the picture.\n const seq = ++this._gotoSeq;\n let tail: string | undefined;\n\n if (this.routes.length === 0 && this.fallback === undefined) {\n // If a routes controller has none of its own routes it acts like it has\n // one route of `/*` so that it passes the whole pathname as a tail\n // match.\n tail = pathname;\n this._currentPathname = '';\n // Simulate a tail group with the whole pathname\n this._currentParams = {0: tail};\n } else {\n const match = this._match(pathname);\n if (match === undefined) {\n throw new Error(`No route found for ${pathname}`);\n }\n const {route, params} = match;\n tail = match.tail;\n if (typeof route.enter === 'function') {\n const success = await route.enter(params);\n // If enter() returns false, cancel this navigation\n if (success === false) {\n return;\n }\n }\n // A newer navigation superseded this one while `enter` was awaiting.\n // Committing now would swap the outlet onto a route the URL has left.\n if (options?.signal?.aborted === true || seq !== this._gotoSeq) {\n return;\n }\n // Only update route state if the enter handler completes successfully\n this._currentRoute = route;\n this._currentParams = params;\n this._currentPathname =\n tail === undefined\n ? pathname\n : pathname.substring(0, pathname.length - tail.length);\n }\n this._currentTail = tail;\n\n // Propagate the tail match to children — deliberately NOT awaited.\n //\n // Awaiting looks like it would make `navigation.finished` cover the whole\n // tree, and an earlier revision of this fork did it. It is wrong twice\n // over. At this point `requestUpdate()` has not run, so `_childRoutes`\n // still holds the *outgoing* branch's controller: awaiting it gates the\n // parent's outlet swap on an `enter()` for a tail that controller will\n // never render (a hung one blocks the navigation forever), and if that\n // child has no route for the new tail its `No route found` throw\n // propagates out of here and `requestUpdate()` below never runs — URL\n // committed, outlet stranded, i.e. this fork's own thesis bug one level\n // down. Nested supersession is handled by the goto counter above, not by\n // awaiting. `_routeChild` covers the per-child filtering and error policy.\n //\n // Runs whether or not there is a tail. A route without one has nothing for\n // the children to render, but they must still be superseded — otherwise a\n // child mid-`enter()` for the previous tail stays current and commits over\n // a URL that has moved on.\n for (const childRoutes of this._childRoutes) {\n this._routeChild(childRoutes, tail);\n }\n this._host.requestUpdate();\n }\n\n /**\n * The result of calling the current route's render() callback.\n */\n outlet() {\n return this._currentRoute?.render?.(this._currentParams);\n }\n\n /**\n * The current parsed route parameters.\n */\n get params() {\n return this._currentParams;\n }\n\n /**\n * Hands a tail match to a child controller. Shared by the propagation loop in\n * `goto()` and the late-mount path in `_onRoutesConnected`, so that identical\n * input cannot be silent on one and an uncaught global throw on the other.\n *\n * A child with no route for the new tail is the expected case, not an error —\n * the outgoing branch mid-swap, or a deep link to a path the child cannot\n * render. Filtered structurally rather than by swallowing every rejection, so\n * a genuine `enter()` rejection still surfaces the way it does upstream.\n * Skipping must still supersede: `goto()` is where the counter is bumped, so\n * returning without it would leave an in-flight child navigation current,\n * free to commit over a URL that has moved on. A parent route with no tail\n * at all is the same case: nothing to route, but still something to stand\n * down.\n *\n * No abort signal is threaded through, and the goto is deliberately not\n * awaited. The parent commits its own state before children run, so a child\n * handed an already-aborted signal stands down with no newer goto() arriving\n * to correct it, leaving the nested outlet stuck — reachable, because a\n * hash-only navigation aborts the outstanding one without producing a\n * replacement. Supersession is the counter's job.\n */\n private _routeChild(child: Routes, tail: string | undefined) {\n if (tail === undefined || !child.hasRouteFor(tail)) {\n child._supersede();\n return;\n }\n void child.goto(tail).catch((err) => {\n queueMicrotask(() => {\n throw err;\n });\n });\n }\n\n /**\n * Invalidate any in-flight `goto()` on this controller without starting a\n * new one. Same-class access, so `_gotoSeq` stays private to `Routes`.\n */\n private _supersede(seen: Set<Routes> = new Set()): void {\n // Unreachable defence in depth. Upstream *can* produce a `_childRoutes`\n // cycle — a host carrying two Routes controllers, disconnected and\n // reconnected, ends up with each registered as the other's child — but\n // `hostDisconnected` below removes the listener that causes it, and a test\n // asserts the cycle cannot form. Kept because an unguarded recursive walk\n // over a cycle is a stack overflow rather than a misrender.\n if (seen.has(this)) {\n return;\n }\n seen.add(this);\n this._gotoSeq++;\n // Recursive: on the navigating branch the child's own propagation loop\n // reaches the grandchildren, but a skipped child never runs one — so\n // without this an in-flight grandchild `enter()` stays current and commits\n // over a URL that has moved on, the same defect one level deeper.\n for (const child of this._childRoutes) {\n child._supersede(seen);\n }\n }\n\n /**\n * True when this controller can render `pathname` — i.e. a route matches, or\n * a fallback is configured.\n *\n * `Router` gates interception on this: intercepting a path we cannot render\n * commits the URL and then throws out of `goto()`, leaving the address bar\n * moved and the outlet stale. Letting the browser handle it instead means a\n * server-rendered page, an export endpoint, or a GET form still works.\n */\n hasRouteFor(pathname: string): boolean {\n // A fallback matches everything, and a controller with no routes of its own\n // behaves as if it had a single `/*` route (goto()'s special case). Either\n // way the answer is yes without running a single pattern — worth\n // short-circuiting, since `Router` asks this on every navigation.\n if (this.fallback !== undefined || this.routes.length === 0) {\n return true;\n }\n // `test()`, not `_match()`: this only needs the yes/no, and `exec()` pays\n // ~8x on a hit to build a groups object the caller would throw away.\n return this.routes.some((r) => getPattern(r).test({pathname}));\n }\n\n /**\n * Matches `pathname` against the installed routes and returns the first match\n * with its parsed parameters, or the fallback's match if one is configured.\n *\n * One `exec()` per candidate rather than `test()` to select and `exec()` to\n * extract: that ran the winning pattern twice, and every caller that wants a\n * route wants its params too.\n */\n private _match(pathname: string):\n | {\n route: RouteConfig;\n params: {[key: string]: string | undefined};\n tail: string | undefined;\n }\n | undefined {\n for (const route of this.routes) {\n const result = getPattern(route).exec({pathname});\n if (result !== null) {\n const params = result.pathname.groups;\n return {route, params, tail: tailOf(route, params)};\n }\n }\n if (this.fallback === undefined) {\n return undefined;\n }\n // The fallback route behaves like it has a \"/*\" path. This is hidden from\n // the public API; the `path` is there to return a valid RouteConfig. The\n // match itself is done by hand rather than with a real `/*` pattern: a\n // nested controller is handed its tail *without* a leading slash, which\n // `/*` does not match, so a nested fallback matched nothing — empty\n // params, no tail, and its own children never routed.\n const tail = pathname.startsWith('/') ? pathname.slice(1) : pathname;\n return {route: {...this.fallback, path: '/*'}, params: {0: tail}, tail};\n }\n\n hostConnected() {\n this._host.addEventListener(\n RoutesConnectedEvent.eventName,\n this._onRoutesConnected\n );\n const event = new RoutesConnectedEvent(this);\n this._host.dispatchEvent(event);\n this._onDisconnect = event.onDisconnect;\n }\n\n hostDisconnected() {\n // Remove the listener hostConnected added. Without this a host that is\n // disconnected and reconnected (a repeat() reorder, a tab swap) leaves the\n // sibling controller's listener installed, so on the second connect it\n // claims the re-dispatching controller as *its* child and the pair point\n // at each other — a real `_childRoutes` cycle, which recursive walks turn\n // into a stack overflow.\n this._host.removeEventListener(\n RoutesConnectedEvent.eventName,\n this._onRoutesConnected\n );\n // When this child routes controller is disconnected because a parent\n // outlet rendered a different template, disconnecting will ensure that\n // this controller doesn't receive a tail match meant for another route.\n this._onDisconnect?.();\n this._parentRoutes = undefined;\n }\n\n private _onRoutesConnected = (e: RoutesConnectedEvent) => {\n // Don't handle the event fired by this routes controller, which we get\n // because we do this.dispatchEvent(...)\n if (e.routes === this) {\n return;\n }\n\n const childRoutes = e.routes;\n this._childRoutes.push(childRoutes);\n childRoutes._parentRoutes = this;\n\n e.stopImmediatePropagation();\n e.onDisconnect = () => {\n const index = this._childRoutes.indexOf(childRoutes);\n if (index !== -1) {\n this._childRoutes.splice(index, 1);\n }\n };\n\n // A child that mounts under an existing tail match has to be caught up to\n // it — it missed the propagation loop in goto() that ran before it existed.\n // With no tail there is nothing to catch up to, and `_routeChild` then only\n // supersedes, a no-op on a freshly mounted child.\n this._routeChild(childRoutes, this._currentTail);\n };\n}\n\n/**\n * This event is fired from Routes controllers when their host is connected to\n * announce the child route and potentially connect to a parent routes controller.\n */\nexport class RoutesConnectedEvent extends Event {\n static readonly eventName = 'lit-routes-connected';\n readonly routes: Routes;\n onDisconnect?: () => void;\n\n constructor(routes: Routes) {\n super(RoutesConnectedEvent.eventName, {\n bubbles: true,\n composed: true,\n cancelable: false,\n });\n this.routes = routes;\n }\n}\n\ndeclare global {\n interface HTMLElementEventMap {\n [RoutesConnectedEvent.eventName]: RoutesConnectedEvent;\n }\n}\n"]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lit-navigation-router",
3
- "version": "0.2.0",
3
+ "version": "0.4.0",
4
4
  "description": "A router for Lit built on the Navigation API. Fork of @lit-labs/router.",
5
5
  "license": "BSD-3-Clause",
6
6
  "repository": {
@@ -31,7 +31,8 @@
31
31
  "!/development/test/",
32
32
  "/src/",
33
33
  "!/src/test/",
34
- "/NOTICE.md"
34
+ "/NOTICE.md",
35
+ "/CHANGELOG.md"
35
36
  ],
36
37
  "scripts": {
37
38
  "prepare": "npm run build",
package/src/router.ts CHANGED
@@ -9,9 +9,6 @@
9
9
 
10
10
  import {Routes} from './routes.js';
11
11
 
12
- // We cache the origin since it can't change
13
- const origin = location.origin || location.protocol + '//' + location.host;
14
-
15
12
  /**
16
13
  * The slice of `NavigateEvent` this router reads. Declared locally rather than
17
14
  * typing the handler `any`: these properties *are* the correctness boundary, so
@@ -153,10 +150,16 @@ export class Router extends Routes {
153
150
  private _onNavigate = (e: NavigateEventLike) => {
154
151
  // Not ours to handle: anything the browser says cannot be intercepted,
155
152
  // fragment-only moves, downloads, and POST form submissions.
156
- if (!e.canIntercept || e.hashChange || e.downloadRequest !== null) {
157
- return;
158
- }
159
- if (e.formData) {
153
+ //
154
+ // `!= null`, not `!== null`: the spec types both as nullable-but-present,
155
+ // but a polyfill that leaves either unset would make a strict check true
156
+ // for every ordinary link and silently decline the whole app.
157
+ if (
158
+ !e.canIntercept ||
159
+ e.hashChange ||
160
+ e.downloadRequest != null ||
161
+ e.formData != null
162
+ ) {
160
163
  return;
161
164
  }
162
165
 
@@ -177,8 +180,12 @@ export class Router extends Routes {
177
180
  return;
178
181
  }
179
182
 
183
+ // Read per navigation rather than cached at module scope: the value cannot
184
+ // change, but reading it on import makes merely importing this module throw
185
+ // where there is no `location` (SSR, a bundler evaluating for tree-shaking
186
+ // under the package's `sideEffects: false` claim).
180
187
  const url = new URL(e.destination.url);
181
- if (url.origin !== origin) {
188
+ if (url.origin !== window.location.origin) {
182
189
  return;
183
190
  }
184
191
 
package/src/routes.ts CHANGED
@@ -49,6 +49,12 @@ export interface URLPatternRouteConfig extends BaseRouteConfig {
49
49
  * A real `URLPattern` satisfies this, so passing one still type-checks.
50
50
  */
51
51
  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
+ */
57
+ readonly pathname: string;
52
58
  test(input: {pathname: string}): boolean;
53
59
  exec(input: {pathname: string}): {
54
60
  pathname: {groups: {[key: string]: string | undefined}};
@@ -81,6 +87,47 @@ const getPattern = (route: RouteConfig): URLPatternLike => {
81
87
  return pattern;
82
88
  };
83
89
 
90
+ /**
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.
99
+ */
100
+ const TRAILING_WILDCARD = /(?:(?<![\\)}]|:[\w$]+)\*|\{\*\})\??$/;
101
+
102
+ /**
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.
112
+ */
113
+ const tailOf = (
114
+ route: RouteConfig,
115
+ params: {[key: string]: string | undefined}
116
+ ): string | undefined => {
117
+ if (!TRAILING_WILDCARD.test(getPattern(route).pathname)) {
118
+ return undefined;
119
+ }
120
+ let tailIndex = -1;
121
+ 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.
124
+ if (/^\d+$/.test(key) && Number(key) > tailIndex) {
125
+ tailIndex = Number(key);
126
+ }
127
+ }
128
+ return tailIndex < 0 ? undefined : params[String(tailIndex)];
129
+ };
130
+
84
131
  /**
85
132
  * A reactive controller that performs location-based routing using a
86
133
  * configuration of URL patterns and associated render callbacks.
@@ -108,7 +155,10 @@ export class Routes implements ReactiveController {
108
155
 
109
156
  /**
110
157
  * A default fallback route which will always be matched if none of the
111
- * {@link routes} match. Implicitly matches to the path "/*".
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.
112
162
  */
113
163
  fallback?: BaseRouteConfig;
114
164
 
@@ -131,6 +181,7 @@ export class Routes implements ReactiveController {
131
181
  private _gotoSeq = 0;
132
182
 
133
183
  private _currentPathname: string | undefined;
184
+ private _currentTail: string | undefined;
134
185
  private _currentRoute: RouteConfig | undefined;
135
186
  private _currentParams: {
136
187
  [key: string]: string | undefined;
@@ -191,7 +242,6 @@ export class Routes implements ReactiveController {
191
242
  // fragments. It currently only handles path names because it's easier to
192
243
  // completely disregard the origin for now. The click handler only does
193
244
  // an in-page navigation if the origin matches anyway.
194
- const signal = options?.signal;
195
245
  // Last-goto-wins, per controller. The navigation signal alone is not
196
246
  // enough: a child controller mounts as a *result* of its parent's render,
197
247
  // so its first goto() comes from `_onRoutesConnected` — after the parent's
@@ -200,26 +250,23 @@ export class Routes implements ReactiveController {
200
250
  // newer one. This also keeps `Routes` correct when used on its own, with
201
251
  // no `Router` and no Navigation API in the picture.
202
252
  const seq = ++this._gotoSeq;
203
- const superseded = () => signal?.aborted === true || seq !== this._gotoSeq;
204
- let tailGroup: string | undefined;
253
+ let tail: string | undefined;
205
254
 
206
255
  if (this.routes.length === 0 && this.fallback === undefined) {
207
256
  // If a routes controller has none of its own routes it acts like it has
208
257
  // one route of `/*` so that it passes the whole pathname as a tail
209
258
  // match.
210
- tailGroup = pathname;
259
+ tail = pathname;
211
260
  this._currentPathname = '';
212
261
  // Simulate a tail group with the whole pathname
213
- this._currentParams = {0: tailGroup};
262
+ this._currentParams = {0: tail};
214
263
  } else {
215
- const route = this._getRoute(pathname);
216
- if (route === undefined) {
264
+ const match = this._match(pathname);
265
+ if (match === undefined) {
217
266
  throw new Error(`No route found for ${pathname}`);
218
267
  }
219
- const pattern = getPattern(route);
220
- const result = pattern.exec({pathname});
221
- const params = result?.pathname.groups ?? {};
222
- tailGroup = getTailGroup(params);
268
+ const {route, params} = match;
269
+ tail = match.tail;
223
270
  if (typeof route.enter === 'function') {
224
271
  const success = await route.enter(params);
225
272
  // If enter() returns false, cancel this navigation
@@ -229,17 +276,18 @@ export class Routes implements ReactiveController {
229
276
  }
230
277
  // A newer navigation superseded this one while `enter` was awaiting.
231
278
  // Committing now would swap the outlet onto a route the URL has left.
232
- if (superseded()) {
279
+ if (options?.signal?.aborted === true || seq !== this._gotoSeq) {
233
280
  return;
234
281
  }
235
282
  // Only update route state if the enter handler completes successfully
236
283
  this._currentRoute = route;
237
284
  this._currentParams = params;
238
285
  this._currentPathname =
239
- tailGroup === undefined
286
+ tail === undefined
240
287
  ? pathname
241
- : pathname.substring(0, pathname.length - tailGroup.length);
288
+ : pathname.substring(0, pathname.length - tail.length);
242
289
  }
290
+ this._currentTail = tail;
243
291
 
244
292
  // Propagate the tail match to children — deliberately NOT awaited.
245
293
  //
@@ -253,35 +301,14 @@ export class Routes implements ReactiveController {
253
301
  // propagates out of here and `requestUpdate()` below never runs — URL
254
302
  // committed, outlet stranded, i.e. this fork's own thesis bug one level
255
303
  // 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
- }
304
+ // awaiting. `_routeChild` covers the per-child filtering and error policy.
305
+ //
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.
310
+ for (const childRoutes of this._childRoutes) {
311
+ this._routeChild(childRoutes, tail);
285
312
  }
286
313
  this._host.requestUpdate();
287
314
  }
@@ -300,6 +327,40 @@ export class Routes implements ReactiveController {
300
327
  return this._currentParams;
301
328
  }
302
329
 
330
+ /**
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.
344
+ *
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.
351
+ */
352
+ private _routeChild(child: Routes, tail: string | undefined) {
353
+ if (tail === undefined || !child.hasRouteFor(tail)) {
354
+ child._supersede();
355
+ return;
356
+ }
357
+ void child.goto(tail).catch((err) => {
358
+ queueMicrotask(() => {
359
+ throw err;
360
+ });
361
+ });
362
+ }
363
+
303
364
  /**
304
365
  * Invalidate any in-flight `goto()` on this controller without starting a
305
366
  * new one. Same-class access, so `_gotoSeq` stays private to `Routes`.
@@ -335,30 +396,51 @@ export class Routes implements ReactiveController {
335
396
  * server-rendered page, an export endpoint, or a GET form still works.
336
397
  */
337
398
  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) {
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.
403
+ if (this.fallback !== undefined || this.routes.length === 0) {
341
404
  return true;
342
405
  }
343
- return this._getRoute(pathname) !== undefined;
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}));
344
409
  }
345
410
 
346
411
  /**
347
- * Matches `url` against the installed routes and returns the first match.
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.
348
418
  */
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;
419
+ private _match(pathname: string):
420
+ | {
421
+ route: RouteConfig;
422
+ params: {[key: string]: string | undefined};
423
+ tail: string | undefined;
424
+ }
425
+ | undefined {
426
+ for (const route of this.routes) {
427
+ const result = getPattern(route).exec({pathname});
428
+ if (result !== null) {
429
+ const params = result.pathname.groups;
430
+ return {route, params, tail: tailOf(route, params)};
431
+ }
355
432
  }
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: '/*'};
433
+ if (this.fallback === undefined) {
434
+ return undefined;
360
435
  }
361
- return undefined;
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.
442
+ const tail = pathname.startsWith('/') ? pathname.slice(1) : pathname;
443
+ return {route: {...this.fallback, path: '/*'}, params: {0: tail}, tail};
362
444
  }
363
445
 
364
446
  hostConnected() {
@@ -402,49 +484,20 @@ export class Routes implements ReactiveController {
402
484
 
403
485
  e.stopImmediatePropagation();
404
486
  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
- );
487
+ const index = this._childRoutes.indexOf(childRoutes);
488
+ if (index !== -1) {
489
+ this._childRoutes.splice(index, 1);
490
+ }
411
491
  };
412
492
 
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
- }
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.
497
+ this._routeChild(childRoutes, this._currentTail);
431
498
  };
432
499
  }
433
500
 
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
501
  /**
449
502
  * This event is fired from Routes controllers when their host is connected to
450
503
  * announce the child route and potentially connect to a parent routes controller.