waku-navigation 0.0.1 → 0.0.3

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/README.md CHANGED
@@ -1,6 +1,10 @@
1
1
  # waku-navigation
2
2
 
3
- Experimental Waku Router implementation with Navigation API
3
+ A drop-in replacement for `waku/router/client` built on the [Navigation API](https://developer.mozilla.org/docs/Web/API/Navigation_API) instead of the History API.
4
+
5
+ The entire public surface of `waku/router/client` — including every `unstable_*` feature — has a path to the same behavior with `waku-navigation`. This README walks through every feature and shows what the migration looks like.
6
+
7
+ > **Browser support**: the Navigation API ships in Chromium 102+ and Safari 26 / Firefox 145 (behind/with caveats on some older versions). Check [caniuse](https://caniuse.com/mdn-api_navigation) for current coverage.
4
8
 
5
9
  ## Install
6
10
 
@@ -8,9 +12,9 @@ Experimental Waku Router implementation with Navigation API
8
12
  npm install waku-navigation
9
13
  ```
10
14
 
11
- ## Usage
15
+ ## Quick start
12
16
 
13
- Create this file as `./src/waku.client.tsx`:
17
+ Create `./src/waku.client.tsx`:
14
18
 
15
19
  ```tsx
16
20
  import { StrictMode } from 'react';
@@ -23,9 +27,286 @@ const rootElement = (
23
27
  </StrictMode>
24
28
  );
25
29
 
26
- if ((globalThis as any).__WAKU_HYDRATE__) {
30
+ if ((globalThis as Record<string, unknown>).__WAKU_HYDRATE__) {
27
31
  hydrateRoot(document, rootElement);
28
32
  } else {
29
- createRoot(document as any).render(rootElement);
33
+ createRoot(document).render(rootElement);
34
+ }
35
+ ```
36
+
37
+ Pages and `pages/_slices/*` work exactly as in any Waku app — `waku-navigation` only replaces the client-side router.
38
+
39
+ ## Examples
40
+
41
+ - `examples/01_minimal` — `useRouter`, `<Slice>`, 404, prefetch, scroll option, events, HMR ([StackBlitz](https://stackblitz.com/github/wakujs/waku-navigation/tree/main/examples/01_minimal))
42
+ - `examples/02_pending` — `useNavigationStatus_UNSTABLE` pending indicators on plain `<a>` for slow routes, client-suspense settling
43
+
44
+ ---
45
+
46
+ ## API reference
47
+
48
+ ### `<Router>`
49
+
50
+ ```tsx
51
+ import { Router } from 'waku-navigation';
52
+
53
+ <Router />;
54
+ ```
55
+
56
+ No props. It reads the initial route from `window.navigation.currentEntry.url` (preferring the route recorded in the RSC payload, so a server-rendered 404 page resolves to `/404`), sets up the navigate-event listener, and renders the page slot. It mirrors the shape Waku's `INTERNAL_ServerRouter` provides during SSR, so server-rendered markup hydrates without a flicker.
57
+
58
+ ### `useRouter()`
59
+
60
+ Same shape as `waku/router/client`'s `useRouter`:
61
+
62
+ ```tsx
63
+ import { useRouter } from 'waku-navigation';
64
+
65
+ function Nav() {
66
+ const router = useRouter();
67
+ // router.path -- current pathname (no leading base)
68
+ // router.query -- query string (no leading '?')
69
+ // router.hash -- '#section' or ''
70
+ // router.push(to, { scroll? })
71
+ // router.replace(to, { scroll? })
72
+ // router.reload()
73
+ // router.back()
74
+ // router.forward()
75
+ // router.prefetch(to)
76
+ // router.unstable_events.on('start' | 'complete', handler)
77
+ // router.unstable_events.off('start' | 'complete', handler)
30
78
  }
31
79
  ```
80
+
81
+ Notes:
82
+
83
+ - `push`/`replace` return `navigation.navigate(...).finished` (a promise that resolves when the navigation commits or rejects on abort).
84
+ - `scroll: false` is forwarded to the navigate event via the Navigation API's `info` channel, which is not persisted in history. The internal handler then intercepts with `scroll: 'manual'` so the browser skips its default after-transition scroll.
85
+ - `prefetch(to)` calls `unstable_prefetchRsc` and, if the build publishes a `__WAKU_ROUTER_PREFETCH__` helper, preloads the route's JS chunks via `react-dom`'s `preloadModule`.
86
+
87
+ ### `useNavigationStatus_UNSTABLE({ href?, dataNavKey? })`
88
+
89
+ There is no `<Link>` — plain `<a>` navigates (the Navigation API intercepts same-origin clicks; see [`<Link>` → plain `<a>`](#link--plain-a)). The one thing a bare `<a>` can't express is per-link _pending_ state, because the indicator needs to bind a DOM anchor to React state. This hook supplies that binding, two ways:
90
+
91
+ ```tsx
92
+ 'use client';
93
+ import { useNavigationStatus_UNSTABLE } from 'waku-navigation';
94
+
95
+ // (a) by destination href — nothing extra on the <a>:
96
+ function NavSpinner({ href }: { href: string }) {
97
+ const { pending } = useNavigationStatus_UNSTABLE({ href });
98
+ return pending ? <span>…</span> : null;
99
+ }
100
+
101
+ // (b) by data-nav-key — distinguishes two same-href anchors:
102
+ function IdSpinner({ dataNavKey }: { dataNavKey: string }) {
103
+ const { pending } = useNavigationStatus_UNSTABLE({ dataNavKey });
104
+ return pending ? <span>…</span> : null;
105
+ }
106
+ ```
107
+
108
+ ```tsx
109
+ <a href="/slow">Slow <NavSpinner href="/slow" /></a>
110
+
111
+ <a href="/slow" data-nav-key="slow">Slow <IdSpinner dataNavKey="slow" /></a>
112
+ ```
113
+
114
+ `pending` is `true` while a matching navigation is in flight and clears in the same commit that reveals the new route — after the destination's client-side `<Suspense>` boundaries settle, and also on abort or error.
115
+
116
+ The two match modes:
117
+
118
+ - **`{ href }`** matches any navigation whose destination is that href — the consumer just names the destination, and the `<a>` needs no attribute. Matching is same-origin and compares path **and query** (not bare pathname), so `{ href: '/search?q=a' }` does not light for `/search?q=b`. The fragment is ignored: `{ href: '/slow#x' }` is treated the same as `{ href: '/slow' }` (hash-only navigations never set `pending`). The trade-off: it keys off the destination, so every anchor to the same path+query shares it (no per-anchor independence). Think `<label htmlFor>` pointing at a route rather than an element.
119
+ - **`{ dataNavKey }`** matches the navigation from the `<a data-nav-key="…">` with that id. This is what keeps two same-href anchors independent — give them different ids. For repeated or list-rendered links, generate the id with `useId()` in the client component that renders the `<a>` and pass it to both sides:
120
+
121
+ ```tsx
122
+ 'use client';
123
+ function SlowLink() {
124
+ const dataNavKey = useId();
125
+ return (
126
+ <a href="/slow" data-nav-key={dataNavKey}>
127
+ Slow <IdSpinner dataNavKey={dataNavKey} />
128
+ </a>
129
+ );
130
+ }
131
+ ```
132
+
133
+ Pass both (`{ href, dataNavKey }`) to match either. The consumer can live anywhere — inside the `<a>`, beside it, or in a distant component (e.g. a global loading bar) — since the match is by value, not DOM position. A match that nothing satisfies simply never goes `pending` (the empty-state equivalent of calling upstream's hook outside a `<Link>`).
134
+
135
+ The counterpart of `waku/router/client`'s `useNavigationStatus_UNSTABLE`. A click matches the clicked anchor (its `data-nav-key` and/or the destination href); programmatic and back-forward navigations (no `sourceElement`) match by destination href, and resolve a `data-nav-key` from the first matching anchor in the DOM. Hash-only navigations complete instantly and never set `pending`.
136
+
137
+ Internally the hook holds a `useOptimistic` state that the router flips inside the navigation transition; React reverts it automatically when the transition settles, so there's no subscription or cleanup to manage.
138
+
139
+ ### `<Slice>`
140
+
141
+ ```tsx
142
+ import { Slice } from 'waku-navigation';
143
+
144
+ <Slice id="clock" />
145
+ <Slice id="banner" lazy fallback={<div>Loading…</div>} />
146
+ ```
147
+
148
+ `Slice` is re-exported from `waku/router/client` unchanged. It works because our `<Router>` provides the same `unstable_RouterContext` shape Waku's `<Slice>` expects (the `fetchingSlices` set and `useElementsPromise`).
149
+
150
+ ---
151
+
152
+ ## Migration from `waku/router/client`
153
+
154
+ ### Drop-in: `<Router>` and `useRouter`
155
+
156
+ ```diff
157
+ - import { Router, useRouter } from 'waku/router/client';
158
+ + import { Router, useRouter } from 'waku-navigation';
159
+ ```
160
+
161
+ `<Router>` takes no props in `waku-navigation` — there is no `initialRoute`, `unstable_fetchRscStore`, or `unstable_routeInterceptor`. The initial route comes from `window.navigation`. If you used `unstable_routeInterceptor` to rewrite a path before refetch, do it in your `useRouter().push` call site instead.
162
+
163
+ ### `<Link>` → plain `<a>`
164
+
165
+ There is no `<Link>` — drop it and use a plain `<a>`. The Navigation API intercepts same-origin `<a>` clicks for you, and cross-origin links, hash-only links, download links, and modifier-keyed clicks all behave correctly:
166
+
167
+ ```diff
168
+ - import { Link } from 'waku/router/client';
169
+ - <Link to="/about">About</Link>
170
+ + <a href="/about">About</a>
171
+ ```
172
+
173
+ Specific `<Link>` props translate as follows:
174
+
175
+ | `<Link>` prop | `waku-navigation` equivalent |
176
+ | ---------------------------- | ------------------------------------------------------------------------------------------------------------- |
177
+ | `to="/x"` | `<a href="/x">` |
178
+ | `scroll={false}` | Click handler that calls `useRouter().push(href, { scroll: false })` |
179
+ | `unstable_pending={node}` | A consumer using `useNavigationStatus_UNSTABLE({ href })` (or `{ dataNavKey }`) to render `node` when pending |
180
+ | `unstable_notPending={node}` | Same, rendering `node` when `!pending` |
181
+ | `unstable_prefetchOnEnter` | `onMouseEnter={() => useRouter().prefetch(href)}` in a client component |
182
+ | `unstable_prefetchOnView` | `IntersectionObserver` + `useRouter().prefetch(href)` |
183
+ | `unstable_startTransition` | Not needed — the router runs every navigation in a transition internally |
184
+
185
+ Example for prefetch-on-hover:
186
+
187
+ ```tsx
188
+ 'use client';
189
+ import { useRouter } from 'waku-navigation';
190
+
191
+ export function PrefetchLink({
192
+ to,
193
+ children,
194
+ }: {
195
+ to: string;
196
+ children: ReactNode;
197
+ }) {
198
+ const { prefetch } = useRouter();
199
+ return (
200
+ <a href={to} onMouseEnter={() => prefetch(to)}>
201
+ {children}
202
+ </a>
203
+ );
204
+ }
205
+ ```
206
+
207
+ ### `<Link>…<Consumer/></Link>` (navigation status)
208
+
209
+ `waku/router` lets any descendant of a `<Link>` read its navigation status via `useNavigationStatus_UNSTABLE`, relying on the `<Link>` for context. With a plain `<a>` there's no context, so the consumer names what it watches — the destination `href` is the simplest, and needs nothing on the `<a>`:
210
+
211
+ ```diff
212
+ - import { Link, useNavigationStatus_UNSTABLE } from 'waku/router/client';
213
+ + import { useNavigationStatus_UNSTABLE } from 'waku-navigation';
214
+
215
+ - function NavSpinner() {
216
+ - const { pending } = useNavigationStatus_UNSTABLE();
217
+ + function NavSpinner({ href }: { href: string }) {
218
+ + const { pending } = useNavigationStatus_UNSTABLE({ href });
219
+ return pending ? <span>…</span> : null;
220
+ }
221
+
222
+ - <Link to="/slow">Slow <NavSpinner /></Link>
223
+ + <a href="/slow">Slow <NavSpinner href="/slow" /></a>
224
+ ```
225
+
226
+ Reach for `{ dataNavKey }` + `data-nav-key` on the `<a>` only when you need two same-href anchors to light up independently.
227
+
228
+ ### `<Slice>`
229
+
230
+ Same import path change as `useRouter`. All props (`id`, `lazy`, `fallback`, children) are unchanged.
231
+
232
+ ### `ErrorBoundary` → your own
233
+
234
+ `waku-navigation` does not ship an error boundary; any standard React error boundary works. Place it around `<Router>`:
235
+
236
+ ```tsx
237
+ <ErrorBoundary>
238
+ <Router />
239
+ </ErrorBoundary>
240
+ ```
241
+
242
+ Non-404 refetch failures (network errors, server 5xx) are rethrown during render and bubble to the nearest boundary. 404s are handled internally — the router refetches `/404` and renders that route's tree, so you keep using your `pages/404.tsx` (with `getConfig` returning a `404` http status) the same as before.
243
+
244
+ ### `unstable_events`
245
+
246
+ Same shape as in `waku/router/client`:
247
+
248
+ ```tsx
249
+ const { unstable_events } = useRouter();
250
+
251
+ useEffect(() => {
252
+ const onStart = (route) => console.log('start', route.path);
253
+ const onComplete = (route) => console.log('complete', route.path);
254
+ unstable_events.on('start', onStart);
255
+ unstable_events.on('complete', onComplete);
256
+ return () => {
257
+ unstable_events.off('start', onStart);
258
+ unstable_events.off('complete', onComplete);
259
+ };
260
+ }, [unstable_events]);
261
+ ```
262
+
263
+ `'start'` fires before the refetch; `'complete'` fires after `setRoute` inside the transition. Hash-only navigations fire both back-to-back.
264
+
265
+ ### Lower-level `unstable_*` exports
266
+
267
+ These are unchanged primitives — keep importing them from `waku/router/client` directly:
268
+
269
+ ```ts
270
+ import {
271
+ unstable_HAS404_ID,
272
+ unstable_IS_STATIC_ID,
273
+ unstable_ROUTE_ID,
274
+ unstable_SKIP_HEADER,
275
+ unstable_encodeRoutePath,
276
+ unstable_encodeSliceId,
277
+ unstable_getRouteSlotId,
278
+ unstable_getSliceSlotId,
279
+ unstable_getErrorInfo,
280
+ unstable_addBase,
281
+ unstable_removeBase,
282
+ unstable_RouterContext,
283
+ unstable_parseRoute,
284
+ } from 'waku/router/client';
285
+ ```
286
+
287
+ Internally `waku-navigation` uses these to interop with Waku's RSC store, slot IDs, and error metadata.
288
+
289
+ ---
290
+
291
+ ## What the router does for you
292
+
293
+ These are all handled inside the navigate-event listener so apps usually don't need to think about them:
294
+
295
+ - **Same-origin guard** — cross-origin navigations have `canIntercept: false` and are passed through to the browser.
296
+ - **Download guard** — `<a download>` clicks (`event.downloadRequest !== null`) are passed through, so the browser issues the download instead of an RSC fetch.
297
+ - **Form submission guard** — `<form method="POST">` submissions (`event.formData != null`) are passed through to the server.
298
+ - **Hash-only navigations** — not intercepted by default (the browser scrolls to the anchor natively), but state is synced so `useRouter().hash` reflects the new fragment. If `useRouter().push('#x', { scroll: false })` is used, the handler intercepts with `scroll: 'manual'` to honor that.
299
+ - **Abort during transition** — `event.signal` is checked between async steps so a fast-clicked second navigation cleanly cancels the first without committing stale state.
300
+ - **404 on the client** — a refetch that throws with `getErrorInfo(err)?.status === 404` is handled by refetching `/404` and pointing the slot there, mirroring Waku's behavior. The URL still reflects the user's request.
301
+ - **Static route cache** — routes with `getConfig({ render: 'static' })` are added to a `staticPathSet` after their first fetch; revisits skip the refetch entirely (the RSC payload is already in Waku's store).
302
+ - **`X-Waku-Router-Skip` header** — every refetch sends the etags of elements we already have (harvested from the RSC payload's `ETAG:`-prefixed entries) so the server can skip re-rendering shared layouts/slices whose etag still matches.
303
+ - **HMR cache invalidation** — when Waku's dev runtime fires `globalThis.__WAKU_RSC_RELOAD_LISTENERS__` (Vite HMR update), the router clears `staticPathSet` and `cachedEtags` and refetches the current route. Guarded by `import.meta.hot` so it's stripped in production.
304
+
305
+ ---
306
+
307
+ ## Caveats / not yet implemented
308
+
309
+ - No `<Link>` component — navigation is just plain `<a>`. Pending status is opt-in via `useNavigationStatus_UNSTABLE({ href })` (by destination) or `{ dataNavKey }` (by `data-nav-key`, for same-href independence). The `<Link>` niceties (`scroll`, `unstable_prefetchOnEnter`/`OnView`) compose from `useRouter().push(href, { scroll })` / `useRouter().prefetch(href)`.
310
+ - `unstable_routeInterceptor` (server-side route rewrite hook) is not supported.
311
+ - `unstable_fetchRscStore` (custom RSC store) is not exposed on `<Router>`.
312
+ - Requires a browser with the Navigation API. There is currently no fallback for older browsers.
package/dist/client.d.ts CHANGED
@@ -1 +1,39 @@
1
+ import { Slice } from 'waku/router/client';
2
+ export { Slice };
3
+ type Route = {
4
+ path: string;
5
+ query: string;
6
+ hash: string;
7
+ };
8
+ type NavigationStatus = {
9
+ pending?: boolean;
10
+ };
11
+ type NavStatusMatch = {
12
+ href: string;
13
+ dataNavKey?: string;
14
+ } | {
15
+ href?: string;
16
+ dataNavKey: string;
17
+ };
18
+ type PushReplaceOptions = {
19
+ scroll?: boolean;
20
+ };
21
+ type RouteChangeEvents = {
22
+ on: (name: 'start' | 'complete', handler: (route: Route) => void) => void;
23
+ off: (name: 'start' | 'complete', handler: (route: Route) => void) => void;
24
+ };
25
+ export declare function useRouter(): {
26
+ path: string;
27
+ query: string;
28
+ hash: string;
29
+ push: (to: string, options?: PushReplaceOptions) => Promise<NavigationHistoryEntry> | undefined;
30
+ replace: (to: string, options?: PushReplaceOptions) => Promise<NavigationHistoryEntry> | undefined;
31
+ reload: () => Promise<NavigationHistoryEntry> | undefined;
32
+ back: () => void;
33
+ forward: () => void;
34
+ prefetch: (to: string) => void;
35
+ unstable_events: RouteChangeEvents;
36
+ };
37
+ declare function useNavigationStatus({ href, dataNavKey, }: NavStatusMatch): NavigationStatus;
38
+ export { useNavigationStatus as useNavigationStatus_UNSTABLE };
1
39
  export declare function Router(): import("react/jsx-runtime").JSX.Element;
package/dist/client.js CHANGED
@@ -1,30 +1,376 @@
1
1
  /// <reference types="dom-navigation" />
2
2
  'use client';
3
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
4
- import { useEffect, useState } from 'react';
5
- import { Root, Slot, useRefetch } from 'waku/minimal/client';
6
- import { unstable_encodeRoutePath as encodeRoutePath, unstable_getHttpStatusFromMeta as getHttpStatusFromMeta, unstable_parseRoute as parseRoute, unstable_getRouteSlotId as getRouteSlotId, } from 'waku/router/client';
7
- function InnerRouter({ initialRoute, httpStatus, }) {
3
+ import { jsx as _jsx } from "react/jsx-runtime";
4
+ import { createContext, startTransition, use, useCallback, useContext, useEffect, useId, useLayoutEffect, useMemo, useOptimistic, useRef, useState, } from 'react';
5
+ import { preloadModule } from 'react-dom';
6
+ import { Root, Slot, unstable_prefetchRsc as prefetchRsc, unstable_withEnhanceFetchFn as withEnhanceFetchFn, useElementsPromise_UNSTABLE as useElementsPromise, useRefetch, } from 'waku/minimal/client';
7
+ import { Slice, unstable_encodeRoutePath as encodeRoutePath, unstable_getErrorInfo as getErrorInfo, unstable_parseRoute as parseRoute, unstable_getRouteSlotId as getRouteSlotId, unstable_IS_STATIC_ID as IS_STATIC_ID, unstable_ROUTE_ID as ROUTE_ID, unstable_RouterContext as RouterContext, unstable_SKIP_HEADER as SKIP_HEADER, } from 'waku/router/client';
8
+ // Slice is re-exported from waku/router/client unchanged. It only needs the
9
+ // router context (fetchingSlices + the elements promise) -- both of which our
10
+ // <Router> already provides -- so the component works as-is.
11
+ export { Slice };
12
+ const NOT_FOUND_PATH = '/404';
13
+ // Authored by the app on a plain <a> to correlate it with a navigation-status
14
+ // consumer, the way <label htmlFor> correlates with <input id>. The router
15
+ // reads it off the clicked <a> (or, for programmatic navs, off the matching
16
+ // <a> in the DOM) to know which consumers to mark pending.
17
+ const NAV_KEY_ATTR = 'data-nav-key';
18
+ // Mirrors ETAG_ID_PREFIX in waku's router/common.js, which is not exported
19
+ // from waku/router/client. Elements under this prefix carry the etag for the
20
+ // same-named slot; the X-Waku-Router-Skip header echoes them back so the
21
+ // server can skip re-rendering unchanged slots.
22
+ const ETAG_ID_PREFIX = 'ETAG:';
23
+ const noopRegister = () => () => { };
24
+ const NavStatusRegistryContext = createContext({
25
+ register: noopRegister,
26
+ });
27
+ const noopEvents = { on: () => { }, off: () => { } };
28
+ export function useRouter() {
29
+ var _a, _b;
30
+ const ctx = useContext(RouterContext);
31
+ const route = (_a = ctx === null || ctx === void 0 ? void 0 : ctx.route) !== null && _a !== void 0 ? _a : { path: '/', query: '', hash: '' };
32
+ return {
33
+ path: route.path,
34
+ query: route.query,
35
+ hash: route.hash,
36
+ push: (to, options) => window.navigation.navigate(to, {
37
+ history: 'push',
38
+ info: { scroll: options === null || options === void 0 ? void 0 : options.scroll },
39
+ }).finished,
40
+ replace: (to, options) => window.navigation.navigate(to, {
41
+ history: 'replace',
42
+ info: { scroll: options === null || options === void 0 ? void 0 : options.scroll },
43
+ }).finished,
44
+ reload: () => window.navigation.reload().finished,
45
+ back: () => {
46
+ window.navigation.back();
47
+ },
48
+ forward: () => {
49
+ window.navigation.forward();
50
+ },
51
+ prefetch: (to) => {
52
+ ctx === null || ctx === void 0 ? void 0 : ctx.prefetchRoute(parseRoute(new URL(to, window.location.href)));
53
+ },
54
+ unstable_events: ((_b = ctx === null || ctx === void 0 ? void 0 : ctx.routeChangeEvents) !== null && _b !== void 0 ? _b : noopEvents),
55
+ };
56
+ }
57
+ // Counterpart of waku/router/client's useNavigationStatus_UNSTABLE, adapted
58
+ // for plain <a>. Two ways to say which navigation you care about:
59
+ //
60
+ // { href: '/slow' } -- any navigation whose destination is /slow. Nothing
61
+ // extra on the <a>; matches by destination, so every
62
+ // anchor to /slow shares it (no independence).
63
+ // { dataNavKey: 'x' } -- the navigation from <a data-nav-key="x">. Tells two
64
+ // same-href anchors apart (give them different ids;
65
+ // useId() for list-rendered ones).
66
+ //
67
+ // Pass both to match either. The consumer can live anywhere -- inside the <a>,
68
+ // beside it, or far away. Returns { pending: undefined } until a matching
69
+ // navigation is in flight; pending clears when the new route commits (after
70
+ // client-side Suspense), or on abort/error.
71
+ function useNavigationStatus({ href, dataNavKey, }) {
72
+ const [status, setOptimisticStatus] = useOptimistic({});
73
+ const { register } = useContext(NavStatusRegistryContext);
74
+ const id = useId();
75
+ useLayoutEffect(() => register(id, { href, dataNavKey, setOptimisticStatus }), [id, href, dataNavKey, register, setOptimisticStatus]);
76
+ return status;
77
+ }
78
+ export { useNavigationStatus as useNavigationStatus_UNSTABLE };
79
+ // True when `href` (possibly relative) resolves to the same internal route as
80
+ // `route`. Compared on origin + path + query -- not just pathname -- so
81
+ // /search?q=a doesn't match /search?q=b, and a cross-origin href that happens
82
+ // to share a path doesn't match an internal navigation. The fragment is
83
+ // ignored (hash-only navigations never set pending). Malformed input returns
84
+ // false rather than throwing, so a bad consumer href or odd DOM anchor can't
85
+ // break the navigation handler.
86
+ const routeMatchesHref = (href, route) => {
87
+ let url;
88
+ try {
89
+ url = new URL(href, window.location.href);
90
+ }
91
+ catch (_a) {
92
+ return false;
93
+ }
94
+ if (url.origin !== window.location.origin)
95
+ return false;
96
+ const parsed = parseRoute(url);
97
+ return parsed.path === route.path && parsed.query === route.query;
98
+ };
99
+ function InnerRouter({ fallbackRoute }) {
8
100
  const refetch = useRefetch();
9
- const [routePath, setRoutePath] = useState(initialRoute.path);
101
+ const elementsPromise = useElementsPromise();
102
+ const [routeState, setRoute] = useState();
103
+ let route = routeState;
104
+ if (route === undefined) {
105
+ // First render only: the RSC payload records which route the server
106
+ // actually rendered (ROUTE_ID), so an unknown URL that was served the
107
+ // /404 page resolves to '/404' here. Suspending on `use` is free at this
108
+ // point -- the slots below suspend on the same promise during hydration
109
+ // -- but it must not happen on later renders: suspending InnerRouter
110
+ // inside the navigation transition keeps the navigation from ever
111
+ // committing. The hash starts as '' to match Waku's INTERNAL_ServerRouter
112
+ // SSR output (URL fragments aren't sent to the server) and is upgraded
113
+ // post-hydration in the effect below.
114
+ const elements = use(elementsPromise);
115
+ const routeData = elements[ROUTE_ID];
116
+ route =
117
+ routeData && routeData[0] !== fallbackRoute.path
118
+ ? { path: routeData[0], query: routeData[1], hash: '' }
119
+ : { ...fallbackRoute, hash: '' };
120
+ setRoute(route);
121
+ }
122
+ // Non-404 refetch failures (network errors, server 500s, etc.) get surfaced
123
+ // by rethrowing during render so the user's <ErrorBoundary> can catch them.
124
+ // The state clears on the next successful navigation.
125
+ const [renderError, setRenderError] = useState(null);
126
+ if (renderError)
127
+ throw renderError;
128
+ useEffect(() => {
129
+ if (fallbackRoute.hash) {
130
+ // eslint-disable-next-line react-hooks/set-state-in-effect
131
+ setRoute((r) => ({ ...r, hash: fallbackRoute.hash }));
132
+ }
133
+ // Only on mount.
134
+ // eslint-disable-next-line react-hooks/exhaustive-deps
135
+ }, []);
136
+ const registryRef = useRef(new Map());
137
+ const staticPathSetRef = useRef(new Set());
138
+ const cachedEtagsRef = useRef({});
139
+ // Stable Set so Waku's <Slice> can mutate it (add on fetch start, delete on
140
+ // fetch end) without losing state across re-renders. useMemo with [] keeps
141
+ // the same instance and avoids reading ref.current during render.
142
+ const fetchingSlices = useMemo(() => new Set(), []);
143
+ useEffect(() => {
144
+ elementsPromise.then((elements) => {
145
+ const routeData = elements[ROUTE_ID];
146
+ if (routeData && elements[IS_STATIC_ID]) {
147
+ staticPathSetRef.current.add(routeData[0]);
148
+ }
149
+ const etags = {};
150
+ for (const [key, value] of Object.entries(elements)) {
151
+ // Drop empty (clear signal) and non-Latin1 (breaks fetch) tags.
152
+ if (key.startsWith(ETAG_ID_PREFIX) &&
153
+ typeof value === 'string' &&
154
+ /^[\u0020-\u00ff]+$/.test(value)) {
155
+ etags[key.slice(ETAG_ID_PREFIX.length)] = value;
156
+ }
157
+ }
158
+ cachedEtagsRef.current = etags;
159
+ }, () => { });
160
+ }, [elementsPromise]);
161
+ const register = useCallback((id, entry) => {
162
+ registryRef.current.set(id, entry);
163
+ return () => {
164
+ registryRef.current.delete(id);
165
+ };
166
+ }, []);
167
+ // Adds the X-Waku-Router-Skip header mapping element ids to the etags we
168
+ // already hold, so the server can skip re-rendering elements whose etag
169
+ // still matches. Shared by navigate + prefetch.
170
+ const enhanceFetchWithSkip = useMemo(() => withEnhanceFetchFn((fetchFn) => (input, init) => {
171
+ const headers = new Headers(init === null || init === void 0 ? void 0 : init.headers);
172
+ headers.set(SKIP_HEADER, JSON.stringify(cachedEtagsRef.current));
173
+ return fetchFn(input, { ...init, headers });
174
+ }), []);
175
+ // Waku's prefetch cache keys the URLSearchParams by identity, so a fresh
176
+ // `new URLSearchParams(...)` on every call would invalidate the prefetch
177
+ // entry. We memoize by query string so the same params object is reused.
178
+ const rscParamsByQueryRef = useRef(new Map());
179
+ const getRscParams = useCallback((query) => {
180
+ let params = rscParamsByQueryRef.current.get(query);
181
+ if (!params) {
182
+ params = new URLSearchParams({ query });
183
+ rscParamsByQueryRef.current.set(query, params);
184
+ }
185
+ return params;
186
+ }, []);
187
+ const routeChangeListeners = useMemo(() => ({
188
+ start: new Set(),
189
+ complete: new Set(),
190
+ }), []);
191
+ const emitRouteEvent = useCallback((name, r) => {
192
+ for (const listener of routeChangeListeners[name])
193
+ listener(r);
194
+ }, [routeChangeListeners]);
195
+ const routeChangeEvents = useMemo(() => ({
196
+ on: (name, handler) => {
197
+ routeChangeListeners[name].add(handler);
198
+ },
199
+ off: (name, handler) => {
200
+ routeChangeListeners[name].delete(handler);
201
+ },
202
+ }), [routeChangeListeners]);
203
+ // Eagerly fetch the RSC for a route (used by useRouter().prefetch). Build
204
+ // output may also publish a __WAKU_ROUTER_PREFETCH__ helper that returns the
205
+ // JS chunk ids for a path; if present, we preload them too.
206
+ const prefetchRoute = useCallback((next) => {
207
+ var _a, _b;
208
+ if (staticPathSetRef.current.has(next.path))
209
+ return;
210
+ prefetchRsc(encodeRoutePath(next.path), getRscParams(next.query), enhanceFetchWithSkip);
211
+ (_b = (_a = globalThis).__WAKU_ROUTER_PREFETCH__) === null || _b === void 0 ? void 0 : _b.call(_a, next.path, (id) => preloadModule(id, { as: 'script' }));
212
+ }, [enhanceFetchWithSkip, getRscParams]);
213
+ // Vite HMR: when a server file changes, Waku's dev runtime invokes any
214
+ // callbacks in __WAKU_RSC_RELOAD_LISTENERS__. We register one that drops
215
+ // our path/id caches (so a "static" route picks up the new content) and
216
+ // refetches the current route. In production import.meta.hot is undefined
217
+ // and the effect body returns early.
218
+ useEffect(() => {
219
+ var _a;
220
+ if (!import.meta.hot)
221
+ return;
222
+ const refetchRoute = () => {
223
+ staticPathSetRef.current.clear();
224
+ cachedEtagsRef.current = {};
225
+ refetch(encodeRoutePath(route.path), getRscParams(route.query));
226
+ };
227
+ const listeners = ((_a = globalThis).__WAKU_RSC_RELOAD_LISTENERS__ || (_a.__WAKU_RSC_RELOAD_LISTENERS__ = []));
228
+ listeners.unshift(refetchRoute);
229
+ return () => {
230
+ const i = listeners.indexOf(refetchRoute);
231
+ if (i !== -1)
232
+ listeners.splice(i, 1);
233
+ };
234
+ }, [route, refetch, getRscParams]);
10
235
  useEffect(() => {
11
236
  const callback = (event) => {
12
- // TODO: check if it's an external navigation
13
- event.intercept();
14
- const route = parseRoute(new URL(event.destination.url));
15
- const rscPath = encodeRoutePath(route.path);
16
- refetch(rscPath);
17
- setRoutePath(route.path);
237
+ var _a, _b, _c;
238
+ if (!event.canIntercept)
239
+ return;
240
+ if (event.downloadRequest !== null || event.formData)
241
+ return;
242
+ const nextRoute = parseRoute(new URL(event.destination.url));
243
+ // useRouter().push/replace forward { scroll } via `info`. The Navigation
244
+ // API itself doesn't persist `info` in history, so it only applies to
245
+ // this single navigation -- exactly what we want.
246
+ const info = event.info;
247
+ const suppressScroll = (info === null || info === void 0 ? void 0 : info.scroll) === false;
248
+ // Hash-only navigations: by default we don't intercept (the browser
249
+ // handles URL + scroll natively), but if the caller explicitly asked
250
+ // to suppress scrolling we still need to intercept so we can pass
251
+ // scroll: 'manual' and skip the browser's anchor scroll.
252
+ if (event.hashChange) {
253
+ // Hash-only navigations don't refetch, so 'start' and 'complete'
254
+ // both fire effectively together; emit both so subscribers don't
255
+ // have to special-case them.
256
+ emitRouteEvent('start', nextRoute);
257
+ if (suppressScroll) {
258
+ event.intercept({
259
+ scroll: 'manual',
260
+ handler: async () => {
261
+ setRoute(nextRoute);
262
+ emitRouteEvent('complete', nextRoute);
263
+ },
264
+ });
265
+ }
266
+ else {
267
+ setRoute(nextRoute);
268
+ emitRouteEvent('complete', nextRoute);
269
+ }
270
+ return;
271
+ }
272
+ emitRouteEvent('start', nextRoute);
273
+ const signal = event.signal;
274
+ const source = event
275
+ .sourceElement;
276
+ // Resolve the navigating <a>'s data-nav-key (for dataNavKey matching):
277
+ // the clicked <a> (most precise, so two same-href anchors stay
278
+ // independent), or -- for programmatic push/replace and browser
279
+ // back/forward, which have no sourceElement -- the first nav-key anchor in
280
+ // the live DOM whose href resolves to the destination. href matching
281
+ // needs none of this; it keys off the destination route directly.
282
+ let navDataKey = (_c = (_b = (_a = source === null || source === void 0 ? void 0 : source.closest) === null || _a === void 0 ? void 0 : _a.call(source, `a[${NAV_KEY_ATTR}]`)) === null || _b === void 0 ? void 0 : _b.getAttribute(NAV_KEY_ATTR)) !== null && _c !== void 0 ? _c : null;
283
+ if (navDataKey === null && !source) {
284
+ for (const anchor of document.querySelectorAll(`a[${NAV_KEY_ATTR}]`)) {
285
+ const href = anchor.getAttribute('href');
286
+ if (href !== null && routeMatchesHref(href, nextRoute)) {
287
+ navDataKey = anchor.getAttribute(NAV_KEY_ATTR);
288
+ break;
289
+ }
290
+ }
291
+ }
292
+ const pendingSetters = [];
293
+ for (const entry of registryRef.current.values()) {
294
+ const byKey = entry.dataNavKey !== undefined && entry.dataNavKey === navDataKey;
295
+ const byHref = entry.href !== undefined && routeMatchesHref(entry.href, nextRoute);
296
+ if (byKey || byHref)
297
+ pendingSetters.push(entry.setOptimisticStatus);
298
+ }
299
+ event.intercept({
300
+ ...(suppressScroll ? { scroll: 'manual' } : {}),
301
+ handler: () => new Promise((resolve, reject) => {
302
+ // Always a transition: it keeps the previous page visible while the
303
+ // next tree suspends, and scopes the optimistic pending updates so
304
+ // React reverts them on commit/abort/error.
305
+ startTransition(async () => {
306
+ var _a;
307
+ try {
308
+ for (const set of pendingSetters)
309
+ set({ pending: true });
310
+ let targetRoute = nextRoute;
311
+ try {
312
+ if (!staticPathSetRef.current.has(nextRoute.path)) {
313
+ await refetch(encodeRoutePath(nextRoute.path), getRscParams(nextRoute.query), enhanceFetchWithSkip);
314
+ }
315
+ if (signal.aborted)
316
+ return resolve();
317
+ }
318
+ catch (err) {
319
+ if (signal.aborted)
320
+ return resolve();
321
+ if (((_a = getErrorInfo(err)) === null || _a === void 0 ? void 0 : _a.status) === 404) {
322
+ if (!staticPathSetRef.current.has(NOT_FOUND_PATH)) {
323
+ await refetch(encodeRoutePath(NOT_FOUND_PATH), getRscParams(''), enhanceFetchWithSkip);
324
+ }
325
+ if (signal.aborted)
326
+ return resolve();
327
+ targetRoute = { path: NOT_FOUND_PATH, query: '', hash: '' };
328
+ }
329
+ else {
330
+ setRenderError(err);
331
+ throw err;
332
+ }
333
+ }
334
+ // Updates after the first await lose the enclosing transition
335
+ // scope (https://react.dev/reference/react/startTransition#caveats),
336
+ // so re-wrap the commit -- otherwise it renders urgently and
337
+ // the optimistic pending state reverts before the new tree is
338
+ // ready.
339
+ startTransition(() => {
340
+ setRenderError(null);
341
+ setRoute(targetRoute);
342
+ });
343
+ emitRouteEvent('complete', targetRoute);
344
+ resolve();
345
+ }
346
+ catch (err) {
347
+ reject(err);
348
+ }
349
+ });
350
+ }),
351
+ });
18
352
  };
19
353
  window.navigation.addEventListener('navigate', callback);
20
354
  return () => {
21
355
  window.navigation.removeEventListener('navigate', callback);
22
356
  };
23
- }, [refetch]);
24
- return (_jsxs(Slot, { id: "root", children: [_jsx("meta", { name: "httpstatus", content: httpStatus }), _jsx(Slot, { id: getRouteSlotId(routePath) })] }));
357
+ }, [refetch, enhanceFetchWithSkip, getRscParams, emitRouteEvent]);
358
+ // Mirror the shape Waku's INTERNAL_ServerRouter provides. We only care about
359
+ // `route` and `prefetchRoute`; the other fields are no-ops so the context
360
+ // value is type-compatible.
361
+ const notAvailable = (name) => () => {
362
+ throw new Error(`${name} is not available in waku-navigation`);
363
+ };
364
+ const routerCtxValue = useMemo(() => ({
365
+ route,
366
+ changeRoute: notAvailable('changeRoute'),
367
+ prefetchRoute,
368
+ routeChangeEvents,
369
+ fetchingSlices,
370
+ }), [route, prefetchRoute, routeChangeEvents, fetchingSlices]);
371
+ return (_jsx(RouterContext.Provider, { value: routerCtxValue, children: _jsx(NavStatusRegistryContext.Provider, { value: { register }, children: _jsx(Slot, { id: "root", children: _jsx(Slot, { id: getRouteSlotId(route.path) }) }) }) }));
25
372
  }
26
373
  export function Router() {
27
374
  const initialRoute = parseRoute(new URL(window.navigation.currentEntry.url));
28
- const httpStatus = getHttpStatusFromMeta();
29
- return (_jsx(Root, { initialRscPath: encodeRoutePath(initialRoute.path), children: _jsx(InnerRouter, { initialRoute: initialRoute, httpStatus: httpStatus }) }));
375
+ return (_jsx(Root, { initialRscPath: encodeRoutePath(initialRoute.path), children: _jsx(InnerRouter, { fallbackRoute: initialRoute }) }));
30
376
  }
package/dist/index.d.ts CHANGED
@@ -1 +1 @@
1
- export { Router } from './client.js';
1
+ export { Router, Slice, useNavigationStatus_UNSTABLE, useRouter, } from './client.js';
package/dist/index.js CHANGED
@@ -1 +1 @@
1
- export { Router } from './client.js';
1
+ export { Router, Slice, useNavigationStatus_UNSTABLE, useRouter, } from './client.js';
package/package.json CHANGED
@@ -1,9 +1,8 @@
1
1
  {
2
2
  "name": "waku-navigation",
3
3
  "description": "Waku Router implementation with Navigation API",
4
- "version": "0.0.1",
4
+ "version": "0.0.3",
5
5
  "type": "module",
6
- "packageManager": "pnpm@10.28.0",
7
6
  "author": "Daishi Kato",
8
7
  "repository": {
9
8
  "type": "git",
@@ -20,16 +19,6 @@
20
19
  "files": [
21
20
  "dist"
22
21
  ],
23
- "scripts": {
24
- "compile": "rm -rf dist && tsc -p .",
25
- "test": "pnpm run '/^test:.*/'",
26
- "test:format": "prettier -c .",
27
- "test:lint": "eslint .",
28
- "test:types": "tsc -p . --noEmit",
29
- "test:types:examples": "tsc -p examples --noEmit",
30
- "test:spec": "vitest run",
31
- "examples:01_minimal": "(cd examples/01_minimal; waku dev)"
32
- },
33
22
  "keywords": [
34
23
  "react",
35
24
  "waku",
@@ -40,35 +29,48 @@
40
29
  "singleQuote": true
41
30
  },
42
31
  "devDependencies": {
43
- "@eslint/js": "^9.39.2",
32
+ "@eslint/js": "9.39.4",
33
+ "@playwright/test": "^1.60.0",
44
34
  "@testing-library/jest-dom": "^6.9.1",
45
- "@testing-library/react": "^16.3.1",
35
+ "@testing-library/react": "^16.3.2",
46
36
  "@testing-library/user-event": "^14.6.1",
47
- "@types/dom-navigation": "^1.0.6",
48
- "@types/node": "^25.0.6",
49
- "@types/react": "^19.2.8",
37
+ "@types/dom-navigation": "^1.0.7",
38
+ "@types/node": "^25.7.0",
39
+ "@types/react": "^19.2.14",
50
40
  "@types/react-dom": "^19.2.3",
51
- "eslint": "^9.39.2",
41
+ "eslint": "9.39.4",
52
42
  "eslint-import-resolver-typescript": "^4.4.4",
53
43
  "eslint-plugin-import": "^2.32.0",
54
44
  "eslint-plugin-jsx-a11y": "^6.10.2",
55
45
  "eslint-plugin-react": "^7.37.5",
56
- "eslint-plugin-react-hooks": "^7.0.1",
57
- "happy-dom": "^20.1.0",
58
- "prettier": "^3.7.4",
59
- "react": "^19.2.3",
60
- "react-dom": "^19.2.3",
61
- "react-server-dom-webpack": "^19.2.3",
46
+ "eslint-plugin-react-hooks": "^7.1.1",
47
+ "happy-dom": "^20.9.0",
48
+ "prettier": "^3.8.3",
49
+ "react": "^19.2.6",
50
+ "react-dom": "^19.2.6",
51
+ "react-server-dom-webpack": "^19.2.6",
62
52
  "ts-expect": "^1.3.0",
63
- "typescript": "^5.9.3",
64
- "typescript-eslint": "^8.52.0",
65
- "vite": "^7.3.1",
66
- "vitest": "^4.0.16",
67
- "waku": "https://pkg.pr.new/wakujs/waku@fc78f84",
53
+ "typescript": "^6.0.3",
54
+ "typescript-eslint": "^8.59.3",
55
+ "vite": "^8.0.12",
56
+ "vitest": "^4.1.6",
57
+ "waku": "1.0.0-beta.3",
68
58
  "waku-navigation": "link:"
69
59
  },
70
60
  "peerDependencies": {
71
61
  "react": ">=19.0.0",
72
- "waku": ">=1.0.0-alpha.1"
62
+ "waku": ">=1.0.0-beta.3"
63
+ },
64
+ "scripts": {
65
+ "compile": "rm -rf dist && tsc -p .",
66
+ "test": "pnpm run '/^test:.*/'",
67
+ "test:format": "prettier -c .",
68
+ "test:lint": "eslint .",
69
+ "test:types": "tsc -p . --noEmit",
70
+ "test:types:examples": "tsc -p examples --noEmit",
71
+ "test:types:e2e": "tsc -p tsconfig.e2e.json",
72
+ "test:spec": "vitest run",
73
+ "e2e": "pnpm compile && playwright test",
74
+ "examples:01_minimal": "(cd examples/01_minimal; waku dev)"
73
75
  }
74
- }
76
+ }