lit-navigation-router 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,28 @@
1
+ BSD 3-Clause License
2
+
3
+ Copyright (c) 2021 Google LLC. All rights reserved.
4
+
5
+ Redistribution and use in source and binary forms, with or without
6
+ modification, are permitted provided that the following conditions are met:
7
+
8
+ 1. Redistributions of source code must retain the above copyright notice, this
9
+ list of conditions and the following disclaimer.
10
+
11
+ 2. Redistributions in binary form must reproduce the above copyright notice,
12
+ this list of conditions and the following disclaimer in the documentation
13
+ and/or other materials provided with the distribution.
14
+
15
+ 3. Neither the name of the copyright holder nor the names of its
16
+ contributors may be used to endorse or promote products derived from
17
+ this software without specific prior written permission.
18
+
19
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
20
+ AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
21
+ IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
22
+ DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
23
+ FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
24
+ DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
25
+ SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
26
+ CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
27
+ OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
28
+ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
package/NOTICE.md ADDED
@@ -0,0 +1,168 @@
1
+ # Provenance
2
+
3
+ This is a fork of **`@lit-labs/router`**, which is not a standalone repository —
4
+ it lives inside the `lit/lit` monorepo at `packages/labs/router`.
5
+
6
+ | | |
7
+ |---|---|
8
+ | Upstream | https://github.com/lit/lit/tree/main/packages/labs/router |
9
+ | Upstream version | `@lit-labs/router@0.1.4` (latest published at fork time) |
10
+ | Upstream commit | `c42ee1e96b8fd61f7256f61d715daef572e76e52` |
11
+ | Forked | 2026-07-31 |
12
+ | Licence | BSD-3-Clause — unchanged, see `LICENSE` |
13
+
14
+ Original copyright **Google LLC**, retained verbatim in `LICENSE` and in every
15
+ per-file header. BSD-3-Clause requires retention of the copyright notice, the
16
+ conditions and the disclaimer — it does *not* require a modification notice
17
+ (that is Apache-2.0 §4(b)). Modified files carry one anyway, as a courtesy to
18
+ anyone diffing against upstream.
19
+
20
+ Published as `lit-navigation-router`. Deliberately *not* named
21
+ `@lit-labs/router` — that name belongs to the Lit team, and a fork
22
+ masquerading as upstream would be worse than useless when debugging. This
23
+ package is not affiliated with or endorsed by Google or the Lit team; the
24
+ reference to Lit describes what it is for.
25
+
26
+ In the source repository (not the published tarball), `UPSTREAM-README.md` and
27
+ `UPSTREAM-CHANGELOG.md` are upstream's, kept verbatim for reference, and
28
+ `.upstream-commit` records the fork point.
29
+
30
+ ## Why fork
31
+
32
+ Upstream `Router` intercepts navigation with a global click listener plus
33
+ `popstate`, and commits with `history.pushState()`. That has a structural
34
+ flaw: `pushState` is synchronous and `popstate` fires *after* the URL has
35
+ already moved, but `Routes.goto()` awaits `route.enter()` before swapping the
36
+ outlet. The URL leads and the outlet lags, which leaves two sources of truth.
37
+
38
+ In arcsync that produced a run of bugs with one root cause — the outgoing route
39
+ re-rendering with stale params, and quick successive navigations committing in
40
+ whichever order their `enter()` hooks happened to resolve:
41
+
42
+ - `VanLandinghamLabs/arcsync#258` — direct load of `/markdown/:id` degraded to a demo route
43
+ - `VanLandinghamLabs/arcsync#632` — route commits landed in chunk-download order
44
+ - `VanLandinghamLabs/arcsync#640` — a route re-entered its own loader
45
+ - Tracking issue: `VanLandinghamLabs/arcsync#648`
46
+
47
+ Upstream's own source carries the matching TODO, removed in this fork because
48
+ it is now answered:
49
+
50
+ > `// TODO (justinfagnani): do we need to detect when goto() is called while a previous goto() call is still pending?`
51
+
52
+ ## Changes from upstream
53
+
54
+ ### `src/router.ts` — rewritten on the Navigation API
55
+
56
+ - Listens for `navigate` on `window.navigation` and calls
57
+ `navigateEvent.intercept({handler})`. The browser commits the URL and holds
58
+ the navigation un-finished while `goto()` runs, so URL and outlet move
59
+ together.
60
+ - Threads `navigateEvent.signal` into `goto()` so a superseded navigation
61
+ stands down instead of racing.
62
+ - Covers navigation the legacy path could not see at all: `navigation.navigate()`,
63
+ `history.pushState()`, and traversals — not just anchor clicks and popstate.
64
+ - Skips what isn't ours: `!canIntercept`, `hashChange`, `downloadRequest`,
65
+ form submissions, and cross-origin destinations.
66
+ - New `interceptOptions` field forwards `focusReset` / `scroll` to `intercept()`.
67
+ - **The legacy click/popstate path is deleted.** Ten review rounds found
68
+ divergences between it and `_onNavigate`, and every one was in the click
69
+ handler — structural, not luck: `_onNavigate` reads a decision the browser
70
+ already made, while the click handler re-derived it, re-implementing the
71
+ rules for choosing a navigable, the fragment-navigation predicate and the
72
+ modifier-key rules. Each round found another place the re-implementation and
73
+ the spec disagreed. On an engine without the API links become ordinary full
74
+ page loads (slower, not broken, for a server that serves the shell on every
75
+ route); `supportsNavigationApi()` is exported so an app can detect it.
76
+
77
+ ### `src/routes.ts` — behavioural changes
78
+
79
+ - `hostDisconnected` removes the `lit-routes-connected` listener that
80
+ `hostConnected` adds. Upstream leaves it, so a host that is disconnected and
81
+ reconnected (a `repeat()` reorder, a tab swap) ends up with two sibling
82
+ controllers registered as each other's child — a real `_childRoutes` cycle,
83
+ which turns `goto()`'s propagation loop and any recursive walk into a stack
84
+ overflow. `_supersede()` also carries a visited set as defence in depth.
85
+
86
+ - `goto(pathname, options?)` accepts `options.signal`; an aborted signal after
87
+ `enter()` resolves makes `goto()` return without committing.
88
+ - **Per-controller last-goto-wins counter.** The signal alone is not enough: a
89
+ child controller mounts as a *result* of its parent's render, so its first
90
+ `goto()` comes from `_onRoutesConnected` — after the parent's navigation has
91
+ already finished, holding a signal that will never abort. Without the counter
92
+ a slow first child load commits over a newer one. This also keeps `Routes`
93
+ correct standalone, with no `Router` and no Navigation API involved.
94
+ - Child `goto()`s stay **unawaited** (as upstream) and, like upstream, are given
95
+ **no abort signal** — the parent commits before children run, so a child
96
+ handed an aborted signal stands down with no newer `goto()` to correct it and
97
+ the nested outlet sticks. A child with no route for the new tail (the outgoing
98
+ branch, mid-swap) is skipped structurally via `hasRouteFor`, so a genuine
99
+ `enter()` rejection still surfaces rather than being swallowed. An
100
+ earlier revision awaited them to make `navigation.finished` cover the whole
101
+ tree; that was wrong twice over — at that point `requestUpdate()` has not run,
102
+ so `_childRoutes` still holds the *outgoing* branch, and awaiting it both
103
+ gated the parent's outlet swap on an `enter()` for a tail that child would
104
+ never render, and let its `No route found` throw strand the parent entirely.
105
+ Nested supersession is the counter's job, not the await's.
106
+ - New `hasRouteFor(pathname)`, used by `Router` to decline what it cannot
107
+ render.
108
+
109
+ ### Known limits
110
+
111
+ - `navigation.finished` covers the top-level route, not nested ones. Making it
112
+ cover the tree needs awaiting the children the navigation is moving *to*,
113
+ which means awaiting past `requestUpdate()` and the host's update cycle — a
114
+ materially bigger change than it looks.
115
+ - `hasRouteFor()` is a no-op for apps that configure a root `fallback`, since
116
+ every path then matches. That is the app's stated intent, but it means the
117
+ "decline what we cannot render" fix does not reach that configuration.
118
+ - `goto()` still ignores the query string (upstream limitation), so a GET form
119
+ whose action matches a route is intercepted and loses its query.
120
+ - The `seen` set in `_supersede()` is defence in depth and unpinned **by
121
+ construction**: since `hostDisconnected` removes its listener, no test can
122
+ 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
126
+ had the same end state by a different route (`No route found` threw before
127
+ any state changed). Clearing it needs `_currentRoute` reset plus a host
128
+ update, which is a behaviour change rather than a bug fix.
129
+
130
+ ### Build and tests
131
+
132
+ Upstream's package is wired into the lit monorepo (wireit, `@lit-internal/scripts`,
133
+ `treemirror`, `../../tests`). This fork replaces that with plain `tsc` + Web Test
134
+ Runner so it stands alone. Test changes:
135
+
136
+ - Imports rewritten from `@lit-labs/router/*` to relative paths.
137
+ - `stripExpressionComments` reimplemented locally (`src/test/test-helpers.ts`)
138
+ in place of the monorepo-internal `@lit-labs/testing`.
139
+ - `chai` → `@open-wc/testing` (browser-native ESM).
140
+ - Two `(r: RouteConfig)` annotations added in `router_test.ts` (upstream relied
141
+ on monorepo-wide inference). **Otherwise upstream's 6 tests are unmodified and
142
+ 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.
144
+ The first asserts the suite is actually running against `window.navigation`
145
+ rather than silently falling back — without it the rest would pass against the
146
+ legacy path and prove nothing. It is kept as a guard for the day this
147
+ package's requirement changes.
148
+
149
+ ## Verified
150
+
151
+ `npm test` → 20 passed (6 upstream + 14 new), Chromium via Playwright.
152
+
153
+ Run `npm run clean` before a mutation check: the build is `composite`/
154
+ `incremental`, and a stale `development/` can contain a hunk's comment without
155
+ its code, greening the suite against output that lacks the change.
156
+
157
+ ## Merging upstream later
158
+
159
+ `.upstream-commit` in the repository records the fork point. To pull upstream
160
+ changes:
161
+
162
+ ```sh
163
+ git clone --depth 1 --filter=blob:none --sparse https://github.com/lit/lit.git
164
+ cd lit && git sparse-checkout set packages/labs/router
165
+ ```
166
+
167
+ then diff `packages/labs/router/src` against this repo's `src`. Only
168
+ `router.ts` and `goto()` in `routes.ts` diverge meaningfully.
package/README.md ADDED
@@ -0,0 +1,120 @@
1
+ # lit-navigation-router
2
+
3
+ A router for Lit, built on the [Navigation API](https://developer.mozilla.org/en-US/docs/Web/API/Navigation_API).
4
+
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
7
+ changes. Not affiliated with or endorsed by Google or the Lit team.
8
+
9
+ ```sh
10
+ npm i lit-navigation-router
11
+ ```
12
+
13
+ ## Why
14
+
15
+ Upstream commits navigation with `history.pushState()` and reacts to `popstate`,
16
+ but swaps the outlet only after awaiting `route.enter()`. The URL therefore
17
+ leads and the outlet lags, so the route being left re-renders with stale params
18
+ and two quick navigations can commit out of order.
19
+
20
+ `navigateEvent.intercept()` removes that: the browser commits the URL and holds
21
+ the navigation un-finished while the handler runs, and aborts
22
+ `navigateEvent.signal` when a newer navigation supersedes this one.
23
+
24
+ ## Usage
25
+
26
+ Identical to upstream:
27
+
28
+ ```ts
29
+ import {Router} from 'lit-navigation-router/router.js';
30
+
31
+ class MyApp extends LitElement {
32
+ private _router = new Router(this, [
33
+ {path: '/', render: () => html`<h1>Home</h1>`},
34
+ {
35
+ path: '/item/:id',
36
+ enter: async () => {
37
+ await import('./item-view.js'); // awaited before the outlet swaps
38
+ return true;
39
+ },
40
+ render: ({id}) => html`<item-view .id=${id}></item-view>`,
41
+ },
42
+ ]);
43
+
44
+ render() {
45
+ return html`${this._router.outlet()}`;
46
+ }
47
+ }
48
+ ```
49
+
50
+ Two additions:
51
+
52
+ ```ts
53
+ // Forwarded to navigateEvent.intercept()
54
+ this._router.interceptOptions = {scroll: 'manual', focusReset: 'manual'};
55
+
56
+ // goto() takes an abort signal; Router passes navigateEvent.signal for you
57
+ await routes.goto('/item/1', {signal});
58
+ ```
59
+
60
+ ## Browser support — please read
61
+
62
+ This router **requires the Navigation API**. There is no legacy fallback.
63
+
64
+ The API is Baseline **Newly** Available (January 2026: Chrome/Edge, Safari 26.2,
65
+ Firefox 147). Baseline **Widely** Available is not until roughly mid-2028, so
66
+ older engines are still in the wild.
67
+
68
+ On an engine without it, `Router` renders the current route on load but does not
69
+ intercept navigation — every link becomes an ordinary full page load. If your
70
+ server serves the app shell on every route that is slower, not broken. If it
71
+ does not, those links 404.
72
+
73
+ **One case is genuinely broken, not just slow.** If you navigate
74
+ programmatically with `history.pushState()` + `router.goto()`, nothing listens
75
+ for the resulting `popstate` — so Back moves the URL while the outlet stays put,
76
+ which is the URL/outlet split this fork exists to eliminate. Apps using that
77
+ pattern should gate on `supportsNavigationApi()` rather than accept the
78
+ degradation.
79
+
80
+ `supportsNavigationApi()` is exported so you can detect this at boot:
81
+
82
+ ```ts
83
+ import {supportsNavigationApi} from 'lit-navigation-router/router.js';
84
+
85
+ if (!supportsNavigationApi()) {
86
+ showUpgradePrompt();
87
+ }
88
+ ```
89
+
90
+ If you need real pre-2026 support, pair this with a Navigation API polyfill
91
+ rather than a second router: one decision path, with compatibility isolated in
92
+ a layer whose whole job is spec accuracy.
93
+
94
+ ## `URLPattern`
95
+
96
+ Route patterns are compiled with `URLPattern`, which this package uses from the
97
+ global scope and does **not** polyfill — same as upstream. Every engine that has
98
+ the Navigation API also has `URLPattern`, so if the support check above passes
99
+ you need nothing. If you support older engines anyway, load
100
+ [`urlpattern-polyfill`](https://www.npmjs.com/package/urlpattern-polyfill)
101
+ before the router:
102
+
103
+ ```ts
104
+ import {URLPattern} from 'urlpattern-polyfill';
105
+ if (!globalThis.URLPattern) {
106
+ (globalThis as {URLPattern?: unknown}).URLPattern = URLPattern;
107
+ }
108
+ ```
109
+
110
+ The published types do not depend on it — `URLPatternRouteConfig.pattern` is
111
+ typed structurally, so a consumer build never needs the polyfill's types.
112
+
113
+ ## Develop
114
+
115
+ ```sh
116
+ npm install
117
+ npm run build # tsc → development/
118
+ npm test # Web Test Runner (Chromium via Playwright)
119
+ npm run check-types
120
+ ```
@@ -0,0 +1,9 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2021 Google LLC
4
+ * SPDX-License-Identifier: BSD-3-Clause
5
+ */
6
+ export * from './routes.js';
7
+ export { Router, supportsNavigationApi } from './router.js';
8
+ export type { InterceptOptions } from './router.js';
9
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AACH,cAAc,aAAa,CAAC;AAC5B,OAAO,EAAC,MAAM,EAAE,qBAAqB,EAAC,MAAM,aAAa,CAAC;AAC1D,YAAY,EAAC,gBAAgB,EAAC,MAAM,aAAa,CAAC"}
@@ -0,0 +1,8 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2021 Google LLC
4
+ * SPDX-License-Identifier: BSD-3-Clause
5
+ */
6
+ export * from './routes.js';
7
+ export { Router, supportsNavigationApi } from './router.js';
8
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AACH,cAAc,aAAa,CAAC;AAC5B,OAAO,EAAC,MAAM,EAAE,qBAAqB,EAAC,MAAM,aAAa,CAAC","sourcesContent":["/**\n * @license\n * Copyright 2021 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n */\nexport * from './routes.js';\nexport {Router, supportsNavigationApi} from './router.js';\nexport type {InterceptOptions} from './router.js';\n"]}
@@ -0,0 +1,81 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2021 Google LLC
4
+ * SPDX-License-Identifier: BSD-3-Clause
5
+ *
6
+ * Modifications Copyright 2026 VanLandingham Labs, same license.
7
+ * Rebuilt on the Navigation API; see NOTICE.md.
8
+ */
9
+ import { Routes } from './routes.js';
10
+ /** The subset of `NavigationInterceptOptions` this router forwards. */
11
+ export interface InterceptOptions {
12
+ focusReset?: 'after-transition' | 'manual';
13
+ scroll?: 'after-transition' | 'manual';
14
+ }
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.
22
+ */
23
+ export declare const supportsNavigationApi: () => boolean;
24
+ /**
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.
65
+ */
66
+ 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
+ */
71
+ interceptOptions?: InterceptOptions;
72
+ private _listening;
73
+ hostConnected(): void;
74
+ hostDisconnected(): void;
75
+ /**
76
+ * Handles same-document navigation from every source at once: anchor clicks,
77
+ * `navigation.navigate()`, `history.pushState()`, and back/forward.
78
+ */
79
+ private _onNavigate;
80
+ }
81
+ //# sourceMappingURL=router.d.ts.map
@@ -0,0 +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"}
@@ -0,0 +1,154 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2021 Google LLC
4
+ * SPDX-License-Identifier: BSD-3-Clause
5
+ *
6
+ * Modifications Copyright 2026 VanLandingham Labs, same license.
7
+ * Rebuilt on the Navigation API; see NOTICE.md.
8
+ */
9
+ 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
+ const getNavigation = () => window.navigation;
13
+ /**
14
+ * True when the Navigation API is available — Baseline Newly Available since
15
+ * January 2026 (Chrome/Edge, Safari 26.2, Firefox 147).
16
+ *
17
+ * This router **requires** it. Exported so an app can detect an unsupported
18
+ * engine at boot and say so, rather than leaving the user to notice that every
19
+ * link reloads the page.
20
+ */
21
+ export const supportsNavigationApi = () => typeof window !== 'undefined' &&
22
+ typeof getNavigation()?.addEventListener === 'function';
23
+ /**
24
+ * A root-level router that intercepts navigation via the Navigation API.
25
+ *
26
+ * This class extends Routes so that it can also have a route configuration.
27
+ *
28
+ * There should only be one Router instance on a page, since the Router
29
+ * installs a global listener. Nested routes should be configured with the
30
+ * `Routes` class.
31
+ *
32
+ * ## Why the Navigation API
33
+ *
34
+ * Upstream intercepted navigation with a global click listener plus `popstate`
35
+ * and committed with `history.pushState()`. That is structurally racy:
36
+ * `pushState` is synchronous and `popstate` fires *after* the URL has already
37
+ * moved, but `goto()` awaits `route.enter()` before swapping the outlet. The
38
+ * URL leads and the outlet lags, leaving two sources of truth — the outgoing
39
+ * route re-renders with stale params, and two quick navigations commit in
40
+ * whatever order their `enter()` hooks happen to resolve.
41
+ *
42
+ * `navigateEvent.intercept({handler})` collapses that. The browser commits the
43
+ * URL and holds the navigation un-finished while the handler runs, and it
44
+ * aborts `navigateEvent.signal` when a newer navigation supersedes this one —
45
+ * which `goto()` honours, so a superseded route can no longer win the outlet.
46
+ *
47
+ * ## No legacy fallback
48
+ *
49
+ * An earlier version of this fork kept upstream's click/popstate path for
50
+ * pre-2026 engines. It was removed deliberately. Ten review rounds found
51
+ * divergences between the two paths and **every one was in the click handler**,
52
+ * never in this one — which is structural, not luck: this handler reads a
53
+ * decision the browser has already made, while the click handler had to
54
+ * re-derive it, re-implementing the rules for choosing a navigable, the
55
+ * fragment-navigation predicate, and the modifier-key rules. Each round found
56
+ * another place where the re-implementation and the spec disagreed.
57
+ *
58
+ * On an engine without the API, links fall back to ordinary full page loads.
59
+ * For an app whose server serves the shell on every route that still works —
60
+ * it is slower, not broken — and `supportsNavigationApi()` lets you detect it.
61
+ * If real pre-2026 support is ever needed, use a Navigation API polyfill: one
62
+ * decision path, with compatibility isolated in a layer whose whole job is
63
+ * spec accuracy.
64
+ */
65
+ export class Router extends Routes {
66
+ /**
67
+ * Options forwarded to `navigateEvent.intercept()`. Leaving these unset
68
+ * gives the browser's default scroll and focus handling.
69
+ */
70
+ interceptOptions;
71
+ _listening = false;
72
+ hostConnected() {
73
+ super.hostConnected();
74
+ // Gated on the exported predicate, not on `navigation !== undefined`:
75
+ // a stub or partial polyfill under that name would otherwise make this
76
+ // branch throw out of connectedCallback while `supportsNavigationApi()`
77
+ // told the app it was unsupported — and then even the initial render below
78
+ // would not run.
79
+ if (supportsNavigationApi()) {
80
+ getNavigation().addEventListener('navigate', this._onNavigate);
81
+ this._listening = true;
82
+ }
83
+ // Kick off routed rendering by going to the current URL. Done even without
84
+ // the API: a full page load still renders the right route, which is what
85
+ // makes the unsupported-engine degradation "slow" rather than "blank".
86
+ // Surfaced rather than left as a bare unhandled rejection, matching the
87
+ // convention in routes.ts: on an engine without the API this is the *only*
88
+ // rendering path, and a deep link with no matching route throws here.
89
+ void this.goto(window.location.pathname).catch((err) => {
90
+ queueMicrotask(() => {
91
+ throw err;
92
+ });
93
+ });
94
+ }
95
+ hostDisconnected() {
96
+ super.hostDisconnected();
97
+ if (this._listening) {
98
+ getNavigation()?.removeEventListener('navigate', this._onNavigate);
99
+ this._listening = false;
100
+ }
101
+ }
102
+ /**
103
+ * Handles same-document navigation from every source at once: anchor clicks,
104
+ * `navigation.navigate()`, `history.pushState()`, and back/forward.
105
+ */
106
+ _onNavigate = (e) => {
107
+ // Not ours to handle: anything the browser says cannot be intercepted,
108
+ // fragment-only moves, downloads, and POST form submissions.
109
+ if (!e.canIntercept || e.hashChange || e.downloadRequest !== null) {
110
+ return;
111
+ }
112
+ if (e.formData) {
113
+ return;
114
+ }
115
+ // Reloads must stay reloads. `canIntercept` is true for them, so without
116
+ // this `location.reload()` silently degrades to re-running goto() on the
117
+ // same path — the document is never replaced, breaking the standard
118
+ // "new version available, reload" escape hatch. (It would also disagree
119
+ // with the browser's own refresh button, which is not interceptable.)
120
+ if (e.navigationType === 'reload') {
121
+ return;
122
+ }
123
+ // `rel="external"` is a convention this router honours — it is not defined
124
+ // by HTML or by the Navigation API, so the browser will not decline these
125
+ // for us. Best-effort: `sourceElement` is not in every engine, and is
126
+ // absent for programmatic navigation.
127
+ if (e.sourceElement?.getAttribute?.('rel') === 'external') {
128
+ return;
129
+ }
130
+ const url = new URL(e.destination.url);
131
+ if (url.origin !== origin) {
132
+ return;
133
+ }
134
+ // Only intercept what we can actually render. `canIntercept` is true for
135
+ // any same-origin URL, including cross-document ones — so without this a
136
+ // link to a server-rendered page, an export endpoint, or a GET form
137
+ // (whose `formData` is null) gets swallowed: the URL commits, goto()
138
+ // throws "No route found", and the address bar is left pointing somewhere
139
+ // the outlet never went. Declining lets the browser do the real
140
+ // navigation, which is the correct outcome.
141
+ if (!this.hasRouteFor(url.pathname)) {
142
+ return;
143
+ }
144
+ e.intercept({
145
+ ...this.interceptOptions,
146
+ handler: async () => {
147
+ // `e.signal` aborts if another navigation starts before this handler
148
+ // resolves; goto() checks it after `enter()` and stands down.
149
+ await this.goto(url.pathname, { signal: e.signal });
150
+ },
151
+ });
152
+ };
153
+ }
154
+ //# sourceMappingURL=router.js.map
@@ -0,0 +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"]}