lit-navigation-router 0.5.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,31 @@
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
+
3
29
  ## 0.5.0
4
30
 
5
31
  ### Fixed
@@ -8,10 +34,10 @@
8
34
  ([#13](https://github.com/VanLandinghamLabs/lit-router/issues/13), inherited
9
35
  from [lit/lit#3517](https://github.com/lit/lit/issues/3517)). Patterns were
10
36
  executed as `exec({pathname})`, and `URLPattern` defaults every component the
11
- caller omits to the empty string — so `new URLPattern({hash: 'one'})` was
37
+ caller omits to the empty string, so `new URLPattern({hash: 'one'})` was
12
38
  compared against a hash of `''` and could never match. A hash- or
13
- search-constrained route matched nothing at all, which made `goto()` throw
14
- `No route found` on every load rather than merely failing to fire. Matching
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
15
41
  now sees the whole location. Named groups captured from the search and hash
16
42
  are merged into `params`; positional ones are not, since each component
17
43
  numbers its groups from zero and a merged `"0"` would masquerade as a tail.
@@ -23,8 +49,8 @@
23
49
  - **Fragment-only navigation is routed when a route asks for it.** `Router`
24
50
  declined every navigation with `hashChange` set, so a hash route could not be
25
51
  reached by clicking a link even once matching was fixed. Such navigations are
26
- now intercepted when — and only when — some route in the tree constrains the
27
- hash, so pathname-only apps keep the browser's native in-page scrolling.
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.
28
54
 
29
55
  ### Changed
30
56
 
@@ -100,7 +126,7 @@
100
126
  the second-to-last group as its tail.
101
127
 
102
128
  **This changes behaviour.** If a route combines a wildcard with a param name
103
- 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
104
130
  correct one. Anything relying on the old selection was relying on the child
105
131
  being given the wrong segment.
106
132
 
@@ -119,7 +145,7 @@
119
145
  running every pattern, and the fallback no longer rebuilds a `URLPattern` on
120
146
  every navigation.
121
147
  - `location.origin` is read per navigation rather than at module scope, so
122
- importing the package no longer touches `location` — importing it where there
148
+ importing the package no longer touches `location`. Importing it where there
123
149
  is no DOM previously threw, contradicting `sideEffects: false`.
124
150
 
125
151
  ### Known issues
package/README.md CHANGED
@@ -5,8 +5,8 @@
5
5
  A router for Lit, built on the [Navigation API](https://developer.mozilla.org/en-US/docs/Web/API/Navigation_API).
6
6
  Published on npm as [`lit-navigation-router`](https://www.npmjs.com/package/lit-navigation-router).
7
7
 
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
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
10
10
  changes. Not affiliated with or endorsed by Google or the Lit team.
11
11
 
12
12
  ```sh
@@ -62,9 +62,10 @@ await routes.goto('/item/1', {signal});
62
62
 
63
63
  ## Nested routes and tails
64
64
 
65
- A route whose pattern ends in a wildcard — `/docs/*` — hands what the wildcard
66
- matched (the *tail*) to any `Routes` controller mounted by its `render()`. The
67
- 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:
68
69
 
69
70
  ```ts
70
71
  // Parent
@@ -78,7 +79,7 @@ private _routes = new Routes(this, [
78
79
  ```
79
80
 
80
81
  The index of a nested route space is the empty tail, spelled `{path: ''}`.
81
- `{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
82
83
  `/docs//`.
83
84
 
84
85
  Only a trailing wildcard produces a tail. An unnamed regex group
@@ -90,7 +91,7 @@ tail it is handed and passes that on.
90
91
 
91
92
  ## Search and hash routes
92
93
 
93
- A `pattern` route can constrain `search` and `hash`, not just `pathname`:
94
+ A `pattern` route can constrain `search` and `hash` as well as `pathname`:
94
95
 
95
96
  ```ts
96
97
  {pattern: new URLPattern({pathname: '/docs', hash: ':section'}),
@@ -106,32 +107,65 @@ independently, so merging them would make a fragment indistinguishable from a
106
107
  tail. Name the groups you need.
107
108
 
108
109
  Only the pathname nests. A child controller is handed the parent's tail as its
109
- pathname and the *same* search and hash — there is no way to split a query
110
- string or a fragment across a route tree.
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.
111
112
 
112
- Fragment-only navigation (`<a href="#section">`) is intercepted **only** when
113
- some route constrains the hash. An app that routes on pathnames alone keeps the
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
114
115
  browser's native in-page scrolling.
115
116
 
116
- ## Browser support — please read
117
+ ## Named routes
117
118
 
118
- This router **requires the Navigation API**. There is no legacy fallback.
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:
119
121
 
120
- The API is Baseline **Newly** Available (January 2026: Chrome/Edge, Safari 26.2,
121
- Firefox 147). Baseline **Widely** Available is not until roughly mid-2028, so
122
- older engines are still in the wild.
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
152
+
153
+ This router requires the Navigation API. There is no legacy fallback.
154
+
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.
123
158
 
124
159
  On an engine without it, `Router` renders the current route on load but does not
125
- 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
126
161
  server serves the app shell on every route that is slower, not broken. If it
127
162
  does not, those links 404.
128
163
 
129
- **One case is genuinely broken, not just slow.** If you navigate
130
- programmatically with `history.pushState()` + `router.goto()`, nothing listens
131
- for the resulting `popstate` — so Back moves the URL while the outlet stays put,
132
- which is the URL/outlet split this fork exists to eliminate. Apps using that
133
- pattern should gate on `supportsNavigationApi()` rather than accept the
134
- 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.
135
169
 
136
170
  `supportsNavigationApi()` is exported so you can detect this at boot:
137
171
 
@@ -144,15 +178,15 @@ if (!supportsNavigationApi()) {
144
178
  ```
145
179
 
146
180
  If you need real pre-2026 support, pair this with a Navigation API polyfill
147
- rather than a second router: one decision path, with compatibility isolated in
148
- 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.
149
183
 
150
184
  ## `URLPattern`
151
185
 
152
186
  Route patterns are compiled with `URLPattern`, which this package uses from the
153
- global scope and does **not** polyfill — same as upstream. Every engine that has
154
- the Navigation API also has `URLPattern`, so if the support check above passes
155
- 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
156
190
  [`urlpattern-polyfill`](https://www.npmjs.com/package/urlpattern-polyfill)
157
191
  before the router:
158
192
 
@@ -163,14 +197,14 @@ if (!globalThis.URLPattern) {
163
197
  }
164
198
  ```
165
199
 
166
- The published types do not depend on it — `URLPatternRouteConfig.pattern` is
200
+ The published types do not depend on it. `URLPatternRouteConfig.pattern` is
167
201
  typed structurally, so a consumer build never needs the polyfill's types.
168
202
 
169
203
  ## Develop
170
204
 
171
205
  ```sh
172
206
  npm install
173
- npm run build # tsc → development/
207
+ npm run build # tsc to development/
174
208
  npm test # Web Test Runner (Chromium via Playwright)
175
209
  npm run check-types
176
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;AAyBD;;;;;;;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,CAkEjB;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,89 +8,44 @@
8
8
  */
9
9
  import { Routes } from './routes.js';
10
10
  const getNavigation = () => window.navigation;
11
- /**
12
- * The current location as a path string, in the form `goto()` parses.
13
- *
14
- * Only the origin is dropped: this router matches within one origin, and
15
- * `_onNavigate` declines anything else before it gets here.
16
- */
11
+ /** The current location in the form `goto()` parses, minus the origin. */
17
12
  const currentPath = () => window.location.pathname + window.location.search + window.location.hash;
18
13
  /**
19
- * True when the Navigation API is available — Baseline Newly Available since
20
- * January 2026 (Chrome/Edge, Safari 26.2, Firefox 147).
21
- *
22
- * This router **requires** it. Exported so an app can detect an unsupported
23
- * engine at boot and say so, rather than leaving the user to notice that every
24
- * 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.
25
17
  */
26
18
  export const supportsNavigationApi = () => typeof window !== 'undefined' &&
27
19
  typeof getNavigation()?.addEventListener === 'function';
28
20
  /**
29
- * A root-level router that intercepts navigation via the Navigation API.
30
- *
31
- * This class extends Routes so that it can also have a route configuration.
32
- *
33
- * There should only be one Router instance on a page, since the Router
34
- * installs a global listener. Nested routes should be configured with the
35
- * `Routes` class.
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.
36
23
  *
37
- * ## Why the Navigation API
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.
38
30
  *
39
- * Upstream intercepted navigation with a global click listener plus `popstate`
40
- * and committed with `history.pushState()`. That is structurally racy:
41
- * `pushState` is synchronous and `popstate` fires *after* the URL has already
42
- * moved, but `goto()` awaits `route.enter()` before swapping the outlet. The
43
- * URL leads and the outlet lags, leaving two sources of truth — the outgoing
44
- * route re-renders with stale params, and two quick navigations commit in
45
- * whatever order their `enter()` hooks happen to resolve.
46
- *
47
- * `navigateEvent.intercept({handler})` collapses that. The browser commits the
48
- * URL and holds the navigation un-finished while the handler runs, and it
49
- * aborts `navigateEvent.signal` when a newer navigation supersedes this one —
50
- * which `goto()` honours, so a superseded route can no longer win the outlet.
51
- *
52
- * ## No legacy fallback
53
- *
54
- * An earlier version of this fork kept upstream's click/popstate path for
55
- * pre-2026 engines. It was removed deliberately. Ten review rounds found
56
- * divergences between the two paths and **every one was in the click handler**,
57
- * never in this one — which is structural, not luck: this handler reads a
58
- * decision the browser has already made, while the click handler had to
59
- * re-derive it, re-implementing the rules for choosing a navigable, the
60
- * fragment-navigation predicate, and the modifier-key rules. Each round found
61
- * another place where the re-implementation and the spec disagreed.
62
- *
63
- * On an engine without the API, links fall back to ordinary full page loads.
64
- * For an app whose server serves the shell on every route that still works —
65
- * it is slower, not broken — and `supportsNavigationApi()` lets you detect it.
66
- * If real pre-2026 support is ever needed, use a Navigation API polyfill: one
67
- * decision path, with compatibility isolated in a layer whose whole job is
68
- * 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.
69
34
  */
70
35
  export class Router extends Routes {
71
- /**
72
- * Options forwarded to `navigateEvent.intercept()`. Leaving these unset
73
- * gives the browser's default scroll and focus handling.
74
- */
36
+ /** Forwarded to `navigateEvent.intercept()`. Unset uses browser defaults. */
75
37
  interceptOptions;
76
38
  _listening = false;
77
39
  hostConnected() {
78
40
  super.hostConnected();
79
- // Gated on the exported predicate, not on `navigation !== undefined`:
80
- // a stub or partial polyfill under that name would otherwise make this
81
- // branch throw out of connectedCallback while `supportsNavigationApi()`
82
- // told the app it was unsupported — and then even the initial render below
83
- // would not run.
41
+ // The predicate, not `navigation !== undefined`: a partial polyfill would
42
+ // otherwise throw out of connectedCallback and skip the render below.
84
43
  if (supportsNavigationApi()) {
85
44
  getNavigation().addEventListener('navigate', this._onNavigate);
86
45
  this._listening = true;
87
46
  }
88
- // Kick off routed rendering by going to the current URL. Done even without
89
- // the API: a full page load still renders the right route, which is what
90
- // makes the unsupported-engine degradation "slow" rather than "blank".
91
- // Surfaced rather than left as a bare unhandled rejection, matching the
92
- // convention in routes.ts: on an engine without the API this is the *only*
93
- // rendering path, and a deep link with no matching route throws here.
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.
94
49
  void this.goto(currentPath()).catch((err) => {
95
50
  queueMicrotask(() => {
96
51
  throw err;
@@ -109,53 +64,36 @@ export class Router extends Routes {
109
64
  * `navigation.navigate()`, `history.pushState()`, and back/forward.
110
65
  */
111
66
  _onNavigate = (e) => {
112
- // Not ours to handle: anything the browser says cannot be intercepted,
113
- // downloads, and POST form submissions.
114
- //
115
- // `!= null`, not `!== null`: the spec types both as nullable-but-present,
116
- // but a polyfill that leaves either unset would make a strict check true
117
- // for every ordinary link and silently decline the whole app.
67
+ // `!= null`: a polyfill leaving either unset would make a strict check
68
+ // true for every link and silently decline the whole app.
118
69
  if (!e.canIntercept || e.downloadRequest != null || e.formData != null) {
119
70
  return;
120
71
  }
121
- // Fragment-only moves belong to the browser unless a route actually reads
122
- // the fragment. Intercepting them unconditionally would cost every
123
- // pathname-only app its native in-page scrolling — and re-render the same
124
- // route to no effect — while declining them unconditionally is the
125
- // lit/lit#3517 bug this router inherited.
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.
126
75
  if (e.hashChange && !this._constrainsHash()) {
127
76
  return;
128
77
  }
129
78
  // Reloads must stay reloads. `canIntercept` is true for them, so without
130
- // this `location.reload()` silently degrades to re-running goto() on the
131
- // same path — the document is never replaced, breaking the standard
132
- // "new version available, reload" escape hatch. (It would also disagree
133
- // with the browser's own refresh button, which is not interceptable.)
79
+ // this `location.reload()` never replaces the document.
134
80
  if (e.navigationType === 'reload') {
135
81
  return;
136
82
  }
137
- // `rel="external"` is a convention this router honours — it is not defined
138
- // by HTML or by the Navigation API, so the browser will not decline these
139
- // for us. Best-effort: `sourceElement` is not in every engine, and is
140
- // 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.
141
85
  if (e.sourceElement?.getAttribute?.('rel') === 'external') {
142
86
  return;
143
87
  }
144
- // Read per navigation rather than cached at module scope: the value cannot
145
- // change, but reading it on import makes merely importing this module throw
146
- // where there is no `location` (SSR, a bundler evaluating for tree-shaking
147
- // 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`.
148
90
  const url = new URL(e.destination.url);
149
91
  if (url.origin !== window.location.origin) {
150
92
  return;
151
93
  }
152
- // Only intercept what we can actually render. `canIntercept` is true for
153
- // any same-origin URL, including cross-document ones — so without this a
154
- // link to a server-rendered page, an export endpoint, or a GET form
155
- // (whose `formData` is null) gets swallowed: the URL commits, goto()
156
- // throws "No route found", and the address bar is left pointing somewhere
157
- // the outlet never went. Declining lets the browser do the real
158
- // navigation, which is the correct outcome.
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.
159
97
  const path = url.pathname + url.search + url.hash;
160
98
  if (!this.hasRouteFor(path)) {
161
99
  return;
@@ -163,8 +101,7 @@ export class Router extends Routes {
163
101
  e.intercept({
164
102
  ...this.interceptOptions,
165
103
  handler: async () => {
166
- // `e.signal` aborts if another navigation starts before this handler
167
- // resolves; goto() checks it after `enter()` and stands down.
104
+ // `e.signal` aborts if a newer navigation starts; goto() checks it.
168
105
  await this.goto(path, { signal: e.signal });
169
106
  },
170
107
  });
@@ -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 * The current location as a path string, in the form `goto()` parses.\n *\n * Only the origin is dropped: this router matches within one origin, and\n * `_onNavigate` declines anything else before it gets here.\n */\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 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(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 // Not ours to handle: anything the browser says cannot be intercepted,\n // 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 (!e.canIntercept || e.downloadRequest != null || e.formData != null) {\n return;\n }\n\n // Fragment-only moves belong to the browser unless a route actually reads\n // the fragment. Intercepting them unconditionally would cost every\n // pathname-only app its native in-page scrolling — and re-render the same\n // route to no effect — while declining them unconditionally is the\n // lit/lit#3517 bug this router inherited.\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()` 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 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 another navigation starts before this handler\n // resolves; goto() checks it after `enter()` and stands down.\n await this.goto(path, {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"]}