lit-navigation-router 0.4.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,70 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.6.0
4
+
5
+ ### Added
6
+
7
+ - **`linkTo(name, params)` builds a URL from a route's `name`**
8
+ ([#16](https://github.com/VanLandinghamLabs/lit-router/issues/16), inherited
9
+ from [lit/lit#3352](https://github.com/lit/lit/issues/3352)).
10
+ `RouteConfig.name` has existed since the fork began and was never read by
11
+ anything, so a component could only link by URL and had to know where its
12
+ parent was mounted. `linkTo` runs a route's pattern backwards and prefixes it
13
+ with that route's ancestors, so moving a subtree no longer breaks the links
14
+ inside it.
15
+
16
+ Resolution covers the mounted controller tree: the controller it is called
17
+ on, its ancestors, and any descendant that has rendered. A route in an
18
+ unmounted branch is not addressable, because the mapping from a parent route
19
+ to its child controller only exists once that parent has rendered. An unknown
20
+ name, a duplicated one, or a missing parameter throws rather than returning a
21
+ wrong URL.
22
+
23
+ Patterns are reversed for literals, `:name`, `*` and `{...}` groups, each
24
+ optionally `?`. A regex group cannot be reversed and `+` or `*` repetition
25
+ has no single answer, so both throw. A trailing wildcard may be omitted,
26
+ since the empty tail is the index of a nested route space; any other wildcard
27
+ is required.
28
+
29
+ ## 0.5.0
30
+
31
+ ### Fixed
32
+
33
+ - **Routes can match on `search` and `hash`**
34
+ ([#13](https://github.com/VanLandinghamLabs/lit-router/issues/13), inherited
35
+ from [lit/lit#3517](https://github.com/lit/lit/issues/3517)). Patterns were
36
+ executed as `exec({pathname})`, and `URLPattern` defaults every component the
37
+ caller omits to the empty string, so `new URLPattern({hash: 'one'})` was
38
+ compared against a hash of `''` and could never match. A hash- or
39
+ search-constrained route matched nothing at all, so `goto()` threw
40
+ `No route found` on every load instead of just failing to fire. Matching
41
+ now sees the whole location. Named groups captured from the search and hash
42
+ are merged into `params`; positional ones are not, since each component
43
+ numbers its groups from zero and a merged `"0"` would masquerade as a tail.
44
+ - **`goto()` is handed the search and hash.** `Router` called
45
+ `goto(window.location.pathname)` and `goto(url.pathname)`, so both were
46
+ discarded before matching ran. `goto()` now accepts and parses
47
+ `path?search#hash`, and passes them down to child controllers alongside the
48
+ tail.
49
+ - **Fragment-only navigation is routed when a route asks for it.** `Router`
50
+ declined every navigation with `hashChange` set, so a hash route could not be
51
+ reached by clicking a link even once matching was fixed. Such navigations are
52
+ now intercepted only when some route in the tree constrains the hash, so
53
+ pathname-only apps keep the browser's native in-page scrolling.
54
+
55
+ ### Changed
56
+
57
+ - `URLPatternLike` requires `hash`: the pattern string, which a real
58
+ `URLPattern` exposes and which is how `Router` decides whether a
59
+ fragment-only navigation is its business. Its `test()`/`exec()` take a
60
+ `{pathname, search, hash}` input, and `exec()` returns the `search` and
61
+ `hash` groups alongside the pathname's.
62
+
63
+ ### Documentation
64
+
65
+ - The `URLPatternRouteConfig` doc comment claimed patterns were "limited to
66
+ checking `pathname` and `search`". `search` never worked either; both now do.
67
+
3
68
  ## 0.4.0
4
69
 
5
70
  ### Fixed
@@ -61,7 +126,7 @@
61
126
  the second-to-last group as its tail.
62
127
 
63
128
  **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
129
+ containing a digit, the child controller now receives a different path, the
65
130
  correct one. Anything relying on the old selection was relying on the child
66
131
  being given the wrong segment.
67
132
 
@@ -80,7 +145,7 @@
80
145
  running every pattern, and the fallback no longer rebuilds a `URLPattern` on
81
146
  every navigation.
82
147
  - `location.origin` is read per navigation rather than at module scope, so
83
- importing the package no longer touches `location` — importing it where there
148
+ importing the package no longer touches `location`. Importing it where there
84
149
  is no DOM previously threw, contradicting `sideEffects: false`.
85
150
 
86
151
  ### Known issues
package/README.md CHANGED
@@ -1,9 +1,12 @@
1
1
  # lit-navigation-router
2
2
 
3
+ [![npm version](https://img.shields.io/npm/v/lit-navigation-router)](https://www.npmjs.com/package/lit-navigation-router)
4
+
3
5
  A router for Lit, built on the [Navigation API](https://developer.mozilla.org/en-US/docs/Web/API/Navigation_API).
6
+ Published on npm as [`lit-navigation-router`](https://www.npmjs.com/package/lit-navigation-router).
4
7
 
5
- Fork of [`@lit-labs/router`](https://github.com/lit/lit/tree/main/packages/labs/router)
6
- — see [NOTICE.md](./NOTICE.md) for provenance, licence and the full list of
8
+ Fork of [`@lit-labs/router`](https://github.com/lit/lit/tree/main/packages/labs/router).
9
+ See [NOTICE.md](./NOTICE.md) for provenance, licence and the full list of
7
10
  changes. Not affiliated with or endorsed by Google or the Lit team.
8
11
 
9
12
  ```sh
@@ -59,9 +62,10 @@ await routes.goto('/item/1', {signal});
59
62
 
60
63
  ## Nested routes and tails
61
64
 
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
+ A route whose pattern ends in a wildcard, such as `/docs/*`, hands what the
66
+ wildcard matched (the tail) to any `Routes` controller mounted by its
67
+ `render()`. The tail has no leading slash, and child routes are written the
68
+ same way:
65
69
 
66
70
  ```ts
67
71
  // Parent
@@ -75,7 +79,7 @@ private _routes = new Routes(this, [
75
79
  ```
76
80
 
77
81
  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
82
+ `{path: '/'}` matches nothing there. It would need a tail of `/`, so a URL of
79
83
  `/docs//`.
80
84
 
81
85
  Only a trailing wildcard produces a tail. An unnamed regex group
@@ -85,25 +89,83 @@ children nor stripped from `link()`. A `fallback` behaves like a `/*` route and
85
89
  passes the whole pathname on as the tail. Nested, it accepts the slash-less
86
90
  tail it is handed and passes that on.
87
91
 
88
- ## Browser support — please read
92
+ ## Search and hash routes
93
+
94
+ A `pattern` route can constrain `search` and `hash` as well as `pathname`:
95
+
96
+ ```ts
97
+ {pattern: new URLPattern({pathname: '/docs', hash: ':section'}),
98
+ render: ({section}) => html`<doc-page .section=${section}></doc-page>`}
99
+
100
+ {pattern: new URLPattern({pathname: '/search', search: 'q=:term'}),
101
+ render: ({term}) => html`<results-for .term=${term}></results-for>`}
102
+ ```
103
+
104
+ Named groups captured from the search and hash join the pathname's in `params`.
105
+ Positional ones do not: every component numbers its groups from zero
106
+ independently, so merging them would make a fragment indistinguishable from a
107
+ tail. Name the groups you need.
108
+
109
+ Only the pathname nests. A child controller is handed the parent's tail as its
110
+ pathname and the same search and hash. There is no way to split a query string
111
+ or a fragment across a route tree.
112
+
113
+ Fragment-only navigation (`<a href="#section">`) is intercepted only when some
114
+ route constrains the hash. An app that routes on pathnames alone keeps the
115
+ browser's native in-page scrolling.
116
+
117
+ ## Named routes
118
+
119
+ Give a route a `name` and link to it by that name instead of by URL, so a
120
+ component does not have to know where the route currently lives:
121
+
122
+ ```ts
123
+ {name: 'page', path: ':page', render: ({page}) => html`<doc-page .page=${page}></doc-page>`}
124
+
125
+ // Anywhere in the mounted tree:
126
+ html`<a href="${this._routes.linkTo('page', {page: 'intro'})}">Intro</a>`
127
+ // => /docs/intro
128
+ ```
129
+
130
+ `linkTo(name, params)` runs the route's pattern backwards and prefixes it with
131
+ the route's ancestors, so moving a subtree from `/docs/*` to `/guide/*` does not
132
+ break the links inside it. To navigate rather than render an href, hand the
133
+ result to `goto()`.
134
+
135
+ Resolution covers the mounted controller tree: the controller you call it on,
136
+ its ancestors, and any descendant that has rendered. A route in a branch that
137
+ has not mounted yet is not addressable, because the mapping from a parent route
138
+ to its child controller only exists once that parent has rendered. An unknown
139
+ name, an ambiguous one, or a missing parameter throws rather than producing a
140
+ wrong URL.
141
+
142
+ A trailing wildcard may be left out, since the empty tail is the index of a
143
+ nested route space: `linkTo('docs')` on `{name: 'docs', path: '/docs/*'}` gives
144
+ `/docs/`. Any other wildcard is required, because dropping it would silently
145
+ join the segments around it.
146
+
147
+ Patterns are reversed for literals, `:name`, `*` and `{...}` groups, each
148
+ optionally `?`. A regex group cannot be reversed and `+` or `*` repetition has
149
+ no single answer, so both throw. Name the groups you want to link to.
150
+
151
+ ## Browser support, please read
89
152
 
90
- This router **requires the Navigation API**. There is no legacy fallback.
153
+ This router requires the Navigation API. There is no legacy fallback.
91
154
 
92
- The API is Baseline **Newly** Available (January 2026: Chrome/Edge, Safari 26.2,
93
- Firefox 147). Baseline **Widely** Available is not until roughly mid-2028, so
94
- older engines are still in the wild.
155
+ The API is Baseline Newly Available (January 2026: Chrome/Edge, Safari 26.2,
156
+ Firefox 147). Baseline Widely Available is not until roughly mid-2028, so older
157
+ engines are still in the wild.
95
158
 
96
159
  On an engine without it, `Router` renders the current route on load but does not
97
- intercept navigation — every link becomes an ordinary full page load. If your
160
+ intercept navigation, so every link becomes an ordinary full page load. If your
98
161
  server serves the app shell on every route that is slower, not broken. If it
99
162
  does not, those links 404.
100
163
 
101
- **One case is genuinely broken, not just slow.** If you navigate
102
- programmatically with `history.pushState()` + `router.goto()`, nothing listens
103
- for the resulting `popstate` — so Back moves the URL while the outlet stays put,
104
- which is the URL/outlet split this fork exists to eliminate. Apps using that
105
- pattern should gate on `supportsNavigationApi()` rather than accept the
106
- degradation.
164
+ One case is broken rather than slow. If you navigate programmatically with
165
+ `history.pushState()` plus `router.goto()`, nothing listens for the resulting
166
+ `popstate`, so Back moves the URL while the outlet stays put. That is the
167
+ URL/outlet split this fork exists to eliminate. Apps using that pattern should
168
+ gate on `supportsNavigationApi()` rather than accept the degradation.
107
169
 
108
170
  `supportsNavigationApi()` is exported so you can detect this at boot:
109
171
 
@@ -116,15 +178,15 @@ if (!supportsNavigationApi()) {
116
178
  ```
117
179
 
118
180
  If you need real pre-2026 support, pair this with a Navigation API polyfill
119
- rather than a second router: one decision path, with compatibility isolated in
120
- a layer whose whole job is spec accuracy.
181
+ rather than a second router. That keeps one decision path, with compatibility
182
+ isolated in a layer whose whole job is spec accuracy.
121
183
 
122
184
  ## `URLPattern`
123
185
 
124
186
  Route patterns are compiled with `URLPattern`, which this package uses from the
125
- global scope and does **not** polyfill — same as upstream. Every engine that has
126
- the Navigation API also has `URLPattern`, so if the support check above passes
127
- you need nothing. If you support older engines anyway, load
187
+ global scope and does not polyfill, same as upstream. Every engine that has the
188
+ Navigation API also has `URLPattern`, so if the support check above passes you
189
+ need nothing. If you support older engines anyway, load
128
190
  [`urlpattern-polyfill`](https://www.npmjs.com/package/urlpattern-polyfill)
129
191
  before the router:
130
192
 
@@ -135,14 +197,14 @@ if (!globalThis.URLPattern) {
135
197
  }
136
198
  ```
137
199
 
138
- The published types do not depend on it — `URLPatternRouteConfig.pattern` is
200
+ The published types do not depend on it. `URLPatternRouteConfig.pattern` is
139
201
  typed structurally, so a consumer build never needs the polyfill's types.
140
202
 
141
203
  ## Develop
142
204
 
143
205
  ```sh
144
206
  npm install
145
- npm run build # tsc → development/
207
+ npm run build # tsc to development/
146
208
  npm test # Web Test Runner (Chromium via Playwright)
147
209
  npm run check-types
148
210
  ```
@@ -13,61 +13,28 @@ export interface InterceptOptions {
13
13
  scroll?: 'after-transition' | 'manual';
14
14
  }
15
15
  /**
16
- * True when the Navigation API is available — Baseline Newly Available since
17
- * January 2026 (Chrome/Edge, Safari 26.2, Firefox 147).
18
- *
19
- * This router **requires** it. Exported so an app can detect an unsupported
20
- * engine at boot and say so, rather than leaving the user to notice that every
21
- * link reloads the page.
16
+ * True when the Navigation API is available (Baseline Newly Available, January
17
+ * 2026). This router requires it; exported so an app can detect an unsupported
18
+ * engine at boot rather than leaving users to notice every link reloading.
22
19
  */
23
20
  export declare const supportsNavigationApi: () => boolean;
24
21
  /**
25
- * A root-level router that intercepts navigation via the Navigation API.
26
- *
27
- * This class extends Routes so that it can also have a route configuration.
28
- *
29
- * There should only be one Router instance on a page, since the Router
30
- * installs a global listener. Nested routes should be configured with the
31
- * `Routes` class.
32
- *
33
- * ## Why the Navigation API
34
- *
35
- * Upstream intercepted navigation with a global click listener plus `popstate`
36
- * and committed with `history.pushState()`. That is structurally racy:
37
- * `pushState` is synchronous and `popstate` fires *after* the URL has already
38
- * moved, but `goto()` awaits `route.enter()` before swapping the outlet. The
39
- * URL leads and the outlet lags, leaving two sources of truth — the outgoing
40
- * route re-renders with stale params, and two quick navigations commit in
41
- * whatever order their `enter()` hooks happen to resolve.
42
- *
43
- * `navigateEvent.intercept({handler})` collapses that. The browser commits the
44
- * URL and holds the navigation un-finished while the handler runs, and it
45
- * aborts `navigateEvent.signal` when a newer navigation supersedes this one —
46
- * which `goto()` honours, so a superseded route can no longer win the outlet.
47
- *
48
- * ## No legacy fallback
49
- *
50
- * An earlier version of this fork kept upstream's click/popstate path for
51
- * pre-2026 engines. It was removed deliberately. Ten review rounds found
52
- * divergences between the two paths and **every one was in the click handler**,
53
- * never in this one — which is structural, not luck: this handler reads a
54
- * decision the browser has already made, while the click handler had to
55
- * re-derive it, re-implementing the rules for choosing a navigable, the
56
- * fragment-navigation predicate, and the modifier-key rules. Each round found
57
- * another place where the re-implementation and the spec disagreed.
58
- *
59
- * On an engine without the API, links fall back to ordinary full page loads.
60
- * For an app whose server serves the shell on every route that still works —
61
- * it is slower, not broken — and `supportsNavigationApi()` lets you detect it.
62
- * If real pre-2026 support is ever needed, use a Navigation API polyfill: one
63
- * decision path, with compatibility isolated in a layer whose whole job is
64
- * spec accuracy.
22
+ * A root-level router that intercepts navigation via the Navigation API. Use one
23
+ * per page, since it installs a global listener, and `Routes` for nested spaces.
24
+ *
25
+ * Upstream committed with `history.pushState()` and reacted to `popstate`, but
26
+ * swapped the outlet only after awaiting `enter()`. The URL led and the outlet
27
+ * lagged, so the outgoing route re-rendered with stale params and quick
28
+ * navigations committed out of order. `intercept({handler})` collapses that:
29
+ * the browser holds the navigation unfinished while the handler runs and aborts
30
+ * `signal` when a newer one supersedes it.
31
+ *
32
+ * There is no legacy fallback for pre-2026 engines. Without the API links
33
+ * become full page loads, which `supportsNavigationApi()` detects. See the
34
+ * README for what that degrades and what it breaks.
65
35
  */
66
36
  export declare class Router extends Routes {
67
- /**
68
- * Options forwarded to `navigateEvent.intercept()`. Leaving these unset
69
- * gives the browser's default scroll and focus handling.
70
- */
37
+ /** Forwarded to `navigateEvent.intercept()`. Unset uses browser defaults. */
71
38
  interceptOptions?: InterceptOptions;
72
39
  private _listening;
73
40
  hostConnected(): void;
@@ -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;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"}
1
+ {"version":3,"file":"router.d.ts","sourceRoot":"","sources":["../src/router.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAEH,OAAO,EAAC,MAAM,EAAC,MAAM,aAAa,CAAC;AAoBnC,uEAAuE;AACvE,MAAM,WAAW,gBAAgB;IAC/B,UAAU,CAAC,EAAE,kBAAkB,GAAG,QAAQ,CAAC;IAC3C,MAAM,CAAC,EAAE,kBAAkB,GAAG,QAAQ,CAAC;CACxC;AAoBD;;;;GAIG;AACH,eAAO,MAAM,qBAAqB,QAAO,OAEgB,CAAC;AAE1D;;;;;;;;;;;;;;GAcG;AACH,qBAAa,MAAO,SAAQ,MAAM;IAChC,6EAA6E;IAC7E,gBAAgB,CAAC,EAAE,gBAAgB,CAAC;IAEpC,OAAO,CAAC,UAAU,CAAS;IAElB,aAAa,SAerB;IAEQ,gBAAgB,SAMxB;IAED;;;OAGG;IACH,OAAO,CAAC,WAAW,CAgDjB;CACH"}
@@ -8,83 +8,45 @@
8
8
  */
9
9
  import { Routes } from './routes.js';
10
10
  const getNavigation = () => window.navigation;
11
+ /** The current location in the form `goto()` parses, minus the origin. */
12
+ const currentPath = () => window.location.pathname + window.location.search + window.location.hash;
11
13
  /**
12
- * True when the Navigation API is available — Baseline Newly Available since
13
- * January 2026 (Chrome/Edge, Safari 26.2, Firefox 147).
14
- *
15
- * This router **requires** it. Exported so an app can detect an unsupported
16
- * engine at boot and say so, rather than leaving the user to notice that every
17
- * link reloads the page.
14
+ * True when the Navigation API is available (Baseline Newly Available, January
15
+ * 2026). This router requires it; exported so an app can detect an unsupported
16
+ * engine at boot rather than leaving users to notice every link reloading.
18
17
  */
19
18
  export const supportsNavigationApi = () => typeof window !== 'undefined' &&
20
19
  typeof getNavigation()?.addEventListener === 'function';
21
20
  /**
22
- * A root-level router that intercepts navigation via the Navigation API.
23
- *
24
- * This class extends Routes so that it can also have a route configuration.
25
- *
26
- * There should only be one Router instance on a page, since the Router
27
- * installs a global listener. Nested routes should be configured with the
28
- * `Routes` class.
29
- *
30
- * ## Why the Navigation API
31
- *
32
- * Upstream intercepted navigation with a global click listener plus `popstate`
33
- * and committed with `history.pushState()`. That is structurally racy:
34
- * `pushState` is synchronous and `popstate` fires *after* the URL has already
35
- * moved, but `goto()` awaits `route.enter()` before swapping the outlet. The
36
- * URL leads and the outlet lags, leaving two sources of truth — the outgoing
37
- * route re-renders with stale params, and two quick navigations commit in
38
- * whatever order their `enter()` hooks happen to resolve.
39
- *
40
- * `navigateEvent.intercept({handler})` collapses that. The browser commits the
41
- * URL and holds the navigation un-finished while the handler runs, and it
42
- * aborts `navigateEvent.signal` when a newer navigation supersedes this one —
43
- * which `goto()` honours, so a superseded route can no longer win the outlet.
21
+ * A root-level router that intercepts navigation via the Navigation API. Use one
22
+ * per page, since it installs a global listener, and `Routes` for nested spaces.
44
23
  *
45
- * ## No legacy fallback
24
+ * Upstream committed with `history.pushState()` and reacted to `popstate`, but
25
+ * swapped the outlet only after awaiting `enter()`. The URL led and the outlet
26
+ * lagged, so the outgoing route re-rendered with stale params and quick
27
+ * navigations committed out of order. `intercept({handler})` collapses that:
28
+ * the browser holds the navigation unfinished while the handler runs and aborts
29
+ * `signal` when a newer one supersedes it.
46
30
  *
47
- * An earlier version of this fork kept upstream's click/popstate path for
48
- * pre-2026 engines. It was removed deliberately. Ten review rounds found
49
- * divergences between the two paths and **every one was in the click handler**,
50
- * never in this one — which is structural, not luck: this handler reads a
51
- * decision the browser has already made, while the click handler had to
52
- * re-derive it, re-implementing the rules for choosing a navigable, the
53
- * fragment-navigation predicate, and the modifier-key rules. Each round found
54
- * another place where the re-implementation and the spec disagreed.
55
- *
56
- * On an engine without the API, links fall back to ordinary full page loads.
57
- * For an app whose server serves the shell on every route that still works —
58
- * it is slower, not broken — and `supportsNavigationApi()` lets you detect it.
59
- * If real pre-2026 support is ever needed, use a Navigation API polyfill: one
60
- * decision path, with compatibility isolated in a layer whose whole job is
61
- * spec accuracy.
31
+ * There is no legacy fallback for pre-2026 engines. Without the API links
32
+ * become full page loads, which `supportsNavigationApi()` detects. See the
33
+ * README for what that degrades and what it breaks.
62
34
  */
63
35
  export class Router extends Routes {
64
- /**
65
- * Options forwarded to `navigateEvent.intercept()`. Leaving these unset
66
- * gives the browser's default scroll and focus handling.
67
- */
36
+ /** Forwarded to `navigateEvent.intercept()`. Unset uses browser defaults. */
68
37
  interceptOptions;
69
38
  _listening = false;
70
39
  hostConnected() {
71
40
  super.hostConnected();
72
- // Gated on the exported predicate, not on `navigation !== undefined`:
73
- // a stub or partial polyfill under that name would otherwise make this
74
- // branch throw out of connectedCallback while `supportsNavigationApi()`
75
- // told the app it was unsupported — and then even the initial render below
76
- // would not run.
41
+ // The predicate, not `navigation !== undefined`: a partial polyfill would
42
+ // otherwise throw out of connectedCallback and skip the render below.
77
43
  if (supportsNavigationApi()) {
78
44
  getNavigation().addEventListener('navigate', this._onNavigate);
79
45
  this._listening = true;
80
46
  }
81
- // Kick off routed rendering by going to the current URL. Done even without
82
- // the API: a full page load still renders the right route, which is what
83
- // makes the unsupported-engine degradation "slow" rather than "blank".
84
- // Surfaced rather than left as a bare unhandled rejection, matching the
85
- // convention in routes.ts: on an engine without the API this is the *only*
86
- // rendering path, and a deep link with no matching route throws here.
87
- void this.goto(window.location.pathname).catch((err) => {
47
+ // Runs even without the API, which is what makes the degradation slow
48
+ // rather than blank. Surfaced because it is then the only rendering path.
49
+ void this.goto(currentPath()).catch((err) => {
88
50
  queueMicrotask(() => {
89
51
  throw err;
90
52
  });
@@ -102,57 +64,45 @@ export class Router extends Routes {
102
64
  * `navigation.navigate()`, `history.pushState()`, and back/forward.
103
65
  */
104
66
  _onNavigate = (e) => {
105
- // Not ours to handle: anything the browser says cannot be intercepted,
106
- // fragment-only moves, downloads, and POST form submissions.
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) {
67
+ // `!= null`: a polyfill leaving either unset would make a strict check
68
+ // true for every link and silently decline the whole app.
69
+ if (!e.canIntercept || e.downloadRequest != null || e.formData != null) {
70
+ return;
71
+ }
72
+ // Fragment-only moves are the browser's unless a route reads the fragment.
73
+ // Always intercepting costs native scrolling; never intercepting is
74
+ // lit/lit#3517. See #13.
75
+ if (e.hashChange && !this._constrainsHash()) {
115
76
  return;
116
77
  }
117
78
  // Reloads must stay reloads. `canIntercept` is true for them, so without
118
- // this `location.reload()` silently degrades to re-running goto() on the
119
- // same path — the document is never replaced, breaking the standard
120
- // "new version available, reload" escape hatch. (It would also disagree
121
- // with the browser's own refresh button, which is not interceptable.)
79
+ // this `location.reload()` never replaces the document.
122
80
  if (e.navigationType === 'reload') {
123
81
  return;
124
82
  }
125
- // `rel="external"` is a convention this router honours — it is not defined
126
- // by HTML or by the Navigation API, so the browser will not decline these
127
- // for us. Best-effort: `sourceElement` is not in every engine, and is
128
- // absent for programmatic navigation.
83
+ // This router's convention, not HTML's, so the browser will not decline
84
+ // these for us. Best-effort: `sourceElement` is not everywhere.
129
85
  if (e.sourceElement?.getAttribute?.('rel') === 'external') {
130
86
  return;
131
87
  }
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).
88
+ // Per navigation, not module scope: reading `location` on import throws
89
+ // under SSR, contradicting the package's `sideEffects: false`.
136
90
  const url = new URL(e.destination.url);
137
91
  if (url.origin !== window.location.origin) {
138
92
  return;
139
93
  }
140
- // Only intercept what we can actually render. `canIntercept` is true for
141
- // any same-origin URL, including cross-document ones — so without this a
142
- // link to a server-rendered page, an export endpoint, or a GET form
143
- // (whose `formData` is null) gets swallowed: the URL commits, goto()
144
- // throws "No route found", and the address bar is left pointing somewhere
145
- // the outlet never went. Declining lets the browser do the real
146
- // navigation, which is the correct outcome.
147
- if (!this.hasRouteFor(url.pathname)) {
94
+ // `canIntercept` is true for cross-document same-origin URLs too, so
95
+ // without this a server-rendered page or GET form gets swallowed: the URL
96
+ // commits, goto() throws, and the outlet never moves.
97
+ const path = url.pathname + url.search + url.hash;
98
+ if (!this.hasRouteFor(path)) {
148
99
  return;
149
100
  }
150
101
  e.intercept({
151
102
  ...this.interceptOptions,
152
103
  handler: async () => {
153
- // `e.signal` aborts if another navigation starts before this handler
154
- // resolves; goto() checks it after `enter()` and stands down.
155
- await this.goto(url.pathname, { signal: e.signal });
104
+ // `e.signal` aborts if a newer navigation starts; goto() checks it.
105
+ await this.goto(path, { signal: e.signal });
156
106
  },
157
107
  });
158
108
  };
@@ -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/**\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"]}
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. Typed rather than `any`\n * because these properties are the correctness boundary: `hashchange` for\n * `hashChange` would silently disable a filter forever.\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/** The current location in the form `goto()` parses, minus the origin. */\nconst currentPath = (): string =>\n window.location.pathname + window.location.search + window.location.hash;\n\n/**\n * True when the Navigation API is available (Baseline Newly Available, January\n * 2026). This router requires it; exported so an app can detect an unsupported\n * engine at boot rather than leaving users to notice every link reloading.\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. Use one\n * per page, since it installs a global listener, and `Routes` for nested spaces.\n *\n * Upstream committed with `history.pushState()` and reacted to `popstate`, but\n * swapped the outlet only after awaiting `enter()`. The URL led and the outlet\n * lagged, so the outgoing route re-rendered with stale params and quick\n * navigations committed out of order. `intercept({handler})` collapses that:\n * the browser holds the navigation unfinished while the handler runs and aborts\n * `signal` when a newer one supersedes it.\n *\n * There is no legacy fallback for pre-2026 engines. Without the API links\n * become full page loads, which `supportsNavigationApi()` detects. See the\n * README for what that degrades and what it breaks.\n */\nexport class Router extends Routes {\n /** Forwarded to `navigateEvent.intercept()`. Unset uses browser defaults. */\n interceptOptions?: InterceptOptions;\n\n private _listening = false;\n\n override hostConnected() {\n super.hostConnected();\n // The predicate, not `navigation !== undefined`: a partial polyfill would\n // otherwise throw out of connectedCallback and skip the render below.\n if (supportsNavigationApi()) {\n getNavigation()!.addEventListener('navigate', this._onNavigate);\n this._listening = true;\n }\n // Runs even without the API, which is what makes the degradation slow\n // rather than blank. Surfaced because it is then the only rendering path.\n void this.goto(currentPath()).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 // `!= null`: a polyfill leaving either unset would make a strict check\n // true for every link and silently decline the whole app.\n if (!e.canIntercept || e.downloadRequest != null || e.formData != null) {\n return;\n }\n\n // Fragment-only moves are the browser's unless a route reads the fragment.\n // Always intercepting costs native scrolling; never intercepting is\n // lit/lit#3517. See #13.\n if (e.hashChange && !this._constrainsHash()) {\n return;\n }\n\n // Reloads must stay reloads. `canIntercept` is true for them, so without\n // this `location.reload()` never replaces the document.\n if (e.navigationType === 'reload') {\n return;\n }\n\n // This router's convention, not HTML's, so the browser will not decline\n // these for us. Best-effort: `sourceElement` is not everywhere.\n if (e.sourceElement?.getAttribute?.('rel') === 'external') {\n return;\n }\n\n // Per navigation, not module scope: reading `location` on import throws\n // under SSR, contradicting the package's `sideEffects: false`.\n const url = new URL(e.destination.url);\n if (url.origin !== window.location.origin) {\n return;\n }\n\n // `canIntercept` is true for cross-document same-origin URLs too, so\n // without this a server-rendered page or GET form gets swallowed: the URL\n // commits, goto() throws, and the outlet never moves.\n const path = url.pathname + url.search + url.hash;\n if (!this.hasRouteFor(path)) {\n return;\n }\n\n e.intercept({\n ...this.interceptOptions,\n handler: async () => {\n // `e.signal` aborts if a newer navigation starts; goto() checks it.\n await this.goto(path, {signal: e.signal});\n },\n });\n };\n}\n"]}