waku-navigation 0.0.3 → 0.0.4

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
@@ -4,6 +4,8 @@ A drop-in replacement for `waku/router/client` built on the [Navigation API](htt
4
4
 
5
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
6
 
7
+ Because the Navigation API intercepts plain `<a>` clicks, **every `<a>` already navigates client-side — no `<Link>` required.** `<Link>` is still here, as an _enhancement_: it adds a type-safe `to`, prefetching, and per-link navigation status. Reach for `<Link>` when you want those; use a plain `<a>` when you don't.
8
+
7
9
  > **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.
8
10
 
9
11
  ## Install
@@ -39,7 +41,7 @@ Pages and `pages/_slices/*` work exactly as in any Waku app — `waku-navigation
39
41
  ## Examples
40
42
 
41
43
  - `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
44
+ - `examples/02_pending` — `<Link>` with per-link `useNavigationStatus_UNSTABLE` pending indicators, two same-route links staying independent on click, a View Transitions link, and a non-navigation transition that leans on the browser's native spinner
43
45
 
44
46
  ---
45
47
 
@@ -84,57 +86,62 @@ Notes:
84
86
  - `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
87
  - `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
88
 
87
- ### `useNavigationStatus_UNSTABLE({ href?, dataNavKey? })`
89
+ ### `<Link>`
90
+
91
+ A plain `<a>` already navigates client-side, so `<Link>` is an _enhancement_, not a requirement. It adds the three things a bare `<a>` can't express:
88
92
 
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:
93
+ - a **type-safe `to`** checked against your generated routes, like `waku/router`'s `<Link>`;
94
+ - **prefetching** — `unstable_prefetchOnEnter` / `unstable_prefetchOnView`;
95
+ - **navigation status** — readable by any descendant via `useNavigationStatus_UNSTABLE()`.
90
96
 
91
97
  ```tsx
92
- 'use client';
93
- import { useNavigationStatus_UNSTABLE } from 'waku-navigation';
98
+ import { Link } from 'waku-navigation';
94
99
 
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
+ <Link to="/slow">
101
+ Slow <NavSpinner />
102
+ </Link>;
103
+ ```
100
104
 
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
- }
105
+ ```ts
106
+ export type LinkProps = {
107
+ to: InferredPaths; // type-safe, from your generated routes
108
+ scroll?: boolean; // false keeps scroll position; otherwise browser default
109
+ unstable_prefetchOnEnter?: boolean; // prefetch on pointer enter
110
+ unstable_prefetchOnView?: boolean; // prefetch when scrolled into view
111
+ unstable_startTransition?: (fn: TransitionFunction) => void; // e.g. View Transitions
112
+ ref?: Ref<HTMLAnchorElement>;
113
+ } & Omit<AnchorHTMLAttributes<HTMLAnchorElement>, 'href'>;
106
114
  ```
107
115
 
108
- ```tsx
109
- <a href="/slow">Slow <NavSpinner href="/slow" /></a>
116
+ `<Link>` does not intercept the click itself — the browser fires the navigate event and the router correlates it back to this instance. So modifier-clicks, `target`, `download`, and cross-origin `to` all keep their native behavior (use a plain `<a>` for those anyway). The props mirror `waku/router`'s `<Link>`, so migrating across is an import swap.
110
117
 
111
- <a href="/slow" data-nav-key="slow">Slow <IdSpinner dataNavKey="slow" /></a>
112
- ```
118
+ `unstable_startTransition` overrides how the route-commit transition is started — for example, to integrate the browser View Transitions API. When provided, the per-link pending state is bypassed (it stays `{}`), matching `waku/router`.
113
119
 
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.
120
+ ### `useNavigationStatus_UNSTABLE()`
115
121
 
116
- The two match modes:
122
+ Returns the navigation status of the enclosing `<Link>`, like React's `useFormStatus`. No arguments — it reads the `<Link>` by context.
117
123
 
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:
124
+ ```tsx
125
+ 'use client';
126
+ import { useNavigationStatus_UNSTABLE } from 'waku-navigation';
120
127
 
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
- ```
128
+ function NavSpinner() {
129
+ const { pending } = useNavigationStatus_UNSTABLE();
130
+ return pending ? <span>…</span> : null;
131
+ }
132
+ ```
132
133
 
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
+ ```tsx
135
+ <Link to="/slow">
136
+ Slow <NavSpinner />
137
+ </Link>
138
+ ```
134
139
 
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`.
140
+ `pending` is `true` while the link's 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. Two `<Link>`s to the same route stay **independent on click** (the router correlates the clicked one by its own element); programmatic and back/forward navigations have no source element, so every `<Link>` to the destination lights up together. Outside a `<Link>`, the hook returns `{}`.
136
141
 
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.
142
+ Internally each `<Link>` 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.
143
+
144
+ > **You often don't need a custom spinner at all.** React already drives the browser's native spinner for transitions — during a navigation the Navigation API holds the request pending until the new route commits, and for non-navigation transitions React (≥19.2) fires a fake navigation via `onDefaultTransitionIndicator` to spin that same native indicator. Reach for `useNavigationStatus_UNSTABLE` when you want an _in-page_, per-link indicator on top of the browser-level one. `examples/02_pending` includes a transition (the home page's "Load batch" button) that relies on the native spinner alone.
138
145
 
139
146
  ### `<Slice>`
140
147
 
@@ -160,71 +167,41 @@ import { Slice } from 'waku-navigation';
160
167
 
161
168
  `<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
169
 
163
- ### `<Link>` plain `<a>`
170
+ ### `<Link>` (drop-in) or plain `<a>`
164
171
 
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:
172
+ `<Link>` is a drop-in same import path swap, same props (`to`, `scroll`, `unstable_prefetchOnEnter`, `unstable_prefetchOnView`, `unstable_startTransition`, `ref`, and any `<a>` attributes):
166
173
 
167
174
  ```diff
168
175
  - import { Link } from 'waku/router/client';
169
- - <Link to="/about">About</Link>
170
- + <a href="/about">About</a>
176
+ + import { Link } from 'waku-navigation';
177
+ <Link to="/about">About</Link>
171
178
  ```
172
179
 
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';
180
+ Or drop `<Link>` entirely where you don't need type-safety, prefetching, or status — a plain `<a>` navigates client-side on its own:
190
181
 
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
- }
182
+ ```diff
183
+ - <Link to="/about">About</Link>
184
+ + <a href="/about">About</a>
205
185
  ```
206
186
 
187
+ Cross-origin links, hash-only links, download links, and modifier-keyed clicks all behave correctly with a plain `<a>` — the Navigation API passes them through.
188
+
207
189
  ### `<Link>…<Consumer/></Link>` (navigation status)
208
190
 
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>`:
191
+ Unchanged a descendant reads the enclosing `<Link>`'s status via the no-arg hook, exactly as in `waku/router`:
210
192
 
211
193
  ```diff
212
194
  - import { Link, useNavigationStatus_UNSTABLE } from 'waku/router/client';
213
- + import { useNavigationStatus_UNSTABLE } from 'waku-navigation';
195
+ + import { Link, useNavigationStatus_UNSTABLE } from 'waku-navigation';
214
196
 
215
- - function NavSpinner() {
216
- - const { pending } = useNavigationStatus_UNSTABLE();
217
- + function NavSpinner({ href }: { href: string }) {
218
- + const { pending } = useNavigationStatus_UNSTABLE({ href });
197
+ function NavSpinner() {
198
+ const { pending } = useNavigationStatus_UNSTABLE();
219
199
  return pending ? <span>…</span> : null;
220
200
  }
221
201
 
222
- - <Link to="/slow">Slow <NavSpinner /></Link>
223
- + <a href="/slow">Slow <NavSpinner href="/slow" /></a>
202
+ <Link to="/slow">Slow <NavSpinner /></Link>
224
203
  ```
225
204
 
226
- Reach for `{ dataNavKey }` + `data-nav-key` on the `<a>` only when you need two same-href anchors to light up independently.
227
-
228
205
  ### `<Slice>`
229
206
 
230
207
  Same import path change as `useRouter`. All props (`id`, `lazy`, `fallback`, children) are unchanged.
@@ -297,6 +274,7 @@ These are all handled inside the navigate-event listener so apps usually don't n
297
274
  - **Form submission guard** — `<form method="POST">` submissions (`event.formData != null`) are passed through to the server.
298
275
  - **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
276
  - **Abort during transition** — `event.signal` is checked between async steps so a fast-clicked second navigation cleanly cancels the first without committing stale state.
277
+ - **React's default transition indicator** — React (≥19.2) fires a fake same-URL navigation tagged `info: 'react-transition'` for every transition, intercepting it to show the browser's native spinner. The router skips these (they aren't route changes), so an unrelated `useTransition` anywhere in your app never triggers a refetch.
300
278
  - **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
279
  - **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
280
  - **`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.
@@ -306,7 +284,7 @@ These are all handled inside the navigate-event listener so apps usually don't n
306
284
 
307
285
  ## Caveats / not yet implemented
308
286
 
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)`.
287
+ - `<Link>` is an enhancement over plain `<a>`, not a requirement: a plain `<a>` navigates client-side on its own; `<Link>` adds a type-safe `to`, prefetching, and per-link navigation status via `useNavigationStatus_UNSTABLE()`.
310
288
  - `unstable_routeInterceptor` (server-side route rewrite hook) is not supported.
311
289
  - `unstable_fetchRscStore` (custom RSC store) is not exposed on `<Router>`.
312
290
  - Requires a browser with the Navigation API. There is currently no fallback for older browsers.
package/dist/client.d.ts CHANGED
@@ -1,4 +1,5 @@
1
- import { Slice } from 'waku/router/client';
1
+ import { type AnchorHTMLAttributes, type ReactNode, type Ref } from 'react';
2
+ import { Slice, type Unstable_InferredPaths as InferredPaths } from 'waku/router/client';
2
3
  export { Slice };
3
4
  type Route = {
4
5
  path: string;
@@ -8,13 +9,43 @@ type Route = {
8
9
  type NavigationStatus = {
9
10
  pending?: boolean;
10
11
  };
11
- type NavStatusMatch = {
12
- href: string;
13
- dataNavKey?: string;
14
- } | {
15
- href?: string;
16
- dataNavKey: string;
17
- };
12
+ type TransitionFunction = () => void | Promise<void>;
13
+ /**
14
+ * Navigation status of the enclosing {@link Link}, like React's
15
+ * `useFormStatus`. `pending` is `true` while the link's navigation is in
16
+ * flight, until the destination route's async components resolve. Returns `{}`
17
+ * outside a `<Link>`.
18
+ */
19
+ export declare const useNavigationStatus_UNSTABLE: () => NavigationStatus;
20
+ /** Props for {@link Link}. Mirrors `waku/router`'s `<Link>`. */
21
+ export type LinkProps = {
22
+ /** Destination, type-checked against your app's generated routes. */
23
+ to: InferredPaths;
24
+ children: ReactNode;
25
+ /**
26
+ * Whether to scroll on navigation. `false` keeps the current scroll
27
+ * position; otherwise the browser's default after-navigation scroll applies.
28
+ */
29
+ scroll?: boolean;
30
+ /** Prefetch the route when the pointer enters the link. */
31
+ unstable_prefetchOnEnter?: boolean;
32
+ /** Prefetch the route when the link scrolls into view. */
33
+ unstable_prefetchOnView?: boolean;
34
+ /**
35
+ * Overrides how the route-commit transition is started, e.g. to integrate
36
+ * the browser View Transitions API. When set, the pending state is bypassed,
37
+ * so {@link useNavigationStatus_UNSTABLE} stays `{}` for this link.
38
+ */
39
+ unstable_startTransition?: ((fn: TransitionFunction) => void) | undefined;
40
+ ref?: Ref<HTMLAnchorElement> | undefined;
41
+ } & Omit<AnchorHTMLAttributes<HTMLAnchorElement>, 'href'>;
42
+ /**
43
+ * A type-safe, prefetching, status-aware link. A plain `<a>` already navigates
44
+ * client-side, so `<Link>` is an enhancement: it adds a type-checked `to`,
45
+ * prefetching, and per-link navigation status (read by descendants via
46
+ * {@link useNavigationStatus_UNSTABLE}). Mirrors `waku/router`'s `<Link>`.
47
+ */
48
+ export declare function Link({ to, children, scroll, unstable_prefetchOnEnter, unstable_prefetchOnView, unstable_startTransition, ref: refProp, ...props }: LinkProps): import("react").JSX.Element;
18
49
  type PushReplaceOptions = {
19
50
  scroll?: boolean;
20
51
  };
@@ -22,6 +53,11 @@ type RouteChangeEvents = {
22
53
  on: (name: 'start' | 'complete', handler: (route: Route) => void) => void;
23
54
  off: (name: 'start' | 'complete', handler: (route: Route) => void) => void;
24
55
  };
56
+ /**
57
+ * Imperative router handle: the current `path` / `query` / `hash`, plus
58
+ * `push` / `replace` / `reload` / `back` / `forward` / `prefetch` and
59
+ * `unstable_events`. Same shape as `waku/router/client`'s `useRouter`.
60
+ */
25
61
  export declare function useRouter(): {
26
62
  path: string;
27
63
  query: string;
@@ -34,6 +70,8 @@ export declare function useRouter(): {
34
70
  prefetch: (to: string) => void;
35
71
  unstable_events: RouteChangeEvents;
36
72
  };
37
- declare function useNavigationStatus({ href, dataNavKey, }: NavStatusMatch): NavigationStatus;
38
- export { useNavigationStatus as useNavigationStatus_UNSTABLE };
39
- export declare function Router(): import("react/jsx-runtime").JSX.Element;
73
+ /**
74
+ * The client router. Reads the initial route from `window.navigation`, listens
75
+ * for navigate events, and renders the current page. Takes no props.
76
+ */
77
+ export declare function Router(): import("react").JSX.Element;
package/dist/client.js CHANGED
@@ -4,27 +4,95 @@ import { jsx as _jsx } from "react/jsx-runtime";
4
4
  import { createContext, startTransition, use, useCallback, useContext, useEffect, useId, useLayoutEffect, useMemo, useOptimistic, useRef, useState, } from 'react';
5
5
  import { preloadModule } from 'react-dom';
6
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.
7
+ import { Slice, unstable_addBase as addBase, 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';
11
8
  export { Slice };
12
9
  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.
10
+ // Mirrors waku's unexported ETAG_ID_PREFIX (router/common.js).
22
11
  const ETAG_ID_PREFIX = 'ETAG:';
23
12
  const noopRegister = () => () => { };
24
13
  const NavStatusRegistryContext = createContext({
25
14
  register: noopRegister,
26
15
  });
16
+ const NavigationStatusContext = createContext({});
17
+ /**
18
+ * Navigation status of the enclosing {@link Link}, like React's
19
+ * `useFormStatus`. `pending` is `true` while the link's navigation is in
20
+ * flight, until the destination route's async components resolve. Returns `{}`
21
+ * outside a `<Link>`.
22
+ */
23
+ export const useNavigationStatus_UNSTABLE = () => useContext(NavigationStatusContext);
24
+ /**
25
+ * A type-safe, prefetching, status-aware link. A plain `<a>` already navigates
26
+ * client-side, so `<Link>` is an enhancement: it adds a type-checked `to`,
27
+ * prefetching, and per-link navigation status (read by descendants via
28
+ * {@link useNavigationStatus_UNSTABLE}). Mirrors `waku/router`'s `<Link>`.
29
+ */
30
+ export function Link({ to, children, scroll, unstable_prefetchOnEnter, unstable_prefetchOnView, unstable_startTransition, ref: refProp, ...props }) {
31
+ var _a;
32
+ const base = (_a = import.meta.env) === null || _a === void 0 ? void 0 : _a.WAKU_CONFIG_BASE_PATH;
33
+ const resolvedTo = base ? addBase(to, base) : to;
34
+ const ctx = useContext(RouterContext);
35
+ const { register } = useContext(NavStatusRegistryContext);
36
+ const [status, setOptimisticStatus] = useOptimistic({});
37
+ const elementRef = useRef(null);
38
+ const setRef = useCallback((node) => {
39
+ elementRef.current = node;
40
+ if (typeof refProp === 'function')
41
+ refProp(node);
42
+ else if (refProp)
43
+ refProp.current = node;
44
+ }, [refProp]);
45
+ const id = useId();
46
+ useLayoutEffect(() => register(id, {
47
+ getElement: () => elementRef.current,
48
+ href: resolvedTo,
49
+ scroll,
50
+ unstable_startTransition,
51
+ setOptimisticStatus,
52
+ }), [
53
+ id,
54
+ resolvedTo,
55
+ scroll,
56
+ unstable_startTransition,
57
+ register,
58
+ setOptimisticStatus,
59
+ ]);
60
+ useEffect(() => {
61
+ if (!unstable_prefetchOnView || !elementRef.current)
62
+ return;
63
+ const observer = new IntersectionObserver((entries) => {
64
+ for (const entry of entries) {
65
+ if (!entry.isIntersecting)
66
+ continue;
67
+ const url = new URL(resolvedTo, window.location.href);
68
+ if (url.href !== window.location.href) {
69
+ ctx === null || ctx === void 0 ? void 0 : ctx.prefetchRoute(parseRoute(url));
70
+ }
71
+ }
72
+ }, { threshold: 0.1 });
73
+ observer.observe(elementRef.current);
74
+ return () => observer.disconnect();
75
+ }, [unstable_prefetchOnView, resolvedTo, ctx]);
76
+ const onMouseEnter = unstable_prefetchOnEnter
77
+ ? (event) => {
78
+ var _a;
79
+ const url = new URL(resolvedTo, window.location.href);
80
+ if (url.href !== window.location.href) {
81
+ ctx === null || ctx === void 0 ? void 0 : ctx.prefetchRoute(parseRoute(url));
82
+ }
83
+ (_a = props.onMouseEnter) === null || _a === void 0 ? void 0 : _a.call(props, event);
84
+ }
85
+ : props.onMouseEnter;
86
+ // No onClick: the browser fires the navigate event for the plain <a>, and
87
+ // InnerRouter's handler correlates it back to this instance.
88
+ return (_jsx(NavigationStatusContext.Provider, { value: status, children: _jsx("a", { ...props, href: resolvedTo, ref: setRef, onMouseEnter: onMouseEnter, children: children }) }));
89
+ }
27
90
  const noopEvents = { on: () => { }, off: () => { } };
91
+ /**
92
+ * Imperative router handle: the current `path` / `query` / `hash`, plus
93
+ * `push` / `replace` / `reload` / `back` / `forward` / `prefetch` and
94
+ * `unstable_events`. Same shape as `waku/router/client`'s `useRouter`.
95
+ */
28
96
  export function useRouter() {
29
97
  var _a, _b;
30
98
  const ctx = useContext(RouterContext);
@@ -54,35 +122,8 @@ export function useRouter() {
54
122
  unstable_events: ((_b = ctx === null || ctx === void 0 ? void 0 : ctx.routeChangeEvents) !== null && _b !== void 0 ? _b : noopEvents),
55
123
  };
56
124
  }
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.
125
+ // Same origin + path + query (not pathname; fragment ignored). Malformed input
126
+ // returns false rather than throwing.
86
127
  const routeMatchesHref = (href, route) => {
87
128
  let url;
88
129
  try {
@@ -102,15 +143,10 @@ function InnerRouter({ fallbackRoute }) {
102
143
  const [routeState, setRoute] = useState();
103
144
  let route = routeState;
104
145
  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.
146
+ // First render only. ROUTE_ID records the route the server actually
147
+ // rendered, so an unknown URL served the /404 page resolves to '/404'. This
148
+ // must not suspend on later renders: suspending inside a navigation
149
+ // transition would keep it from ever committing.
114
150
  const elements = use(elementsPromise);
115
151
  const routeData = elements[ROUTE_ID];
116
152
  route =
@@ -119,26 +155,23 @@ function InnerRouter({ fallbackRoute }) {
119
155
  : { ...fallbackRoute, hash: '' };
120
156
  setRoute(route);
121
157
  }
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.
158
+ // Rethrow during render so the user's <ErrorBoundary> catches non-404
159
+ // failures; cleared by the next successful navigation.
125
160
  const [renderError, setRenderError] = useState(null);
126
161
  if (renderError)
127
162
  throw renderError;
128
163
  useEffect(() => {
164
+ // SSR sends no fragment, so the hash starts ''; upgrade it post-hydration.
129
165
  if (fallbackRoute.hash) {
130
166
  // eslint-disable-next-line react-hooks/set-state-in-effect
131
167
  setRoute((r) => ({ ...r, hash: fallbackRoute.hash }));
132
168
  }
133
- // Only on mount.
134
169
  // eslint-disable-next-line react-hooks/exhaustive-deps
135
170
  }, []);
136
171
  const registryRef = useRef(new Map());
137
172
  const staticPathSetRef = useRef(new Set());
138
173
  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.
174
+ // Stable instance: <Slice> mutates this Set across renders.
142
175
  const fetchingSlices = useMemo(() => new Set(), []);
143
176
  useEffect(() => {
144
177
  elementsPromise.then((elements) => {
@@ -148,10 +181,10 @@ function InnerRouter({ fallbackRoute }) {
148
181
  }
149
182
  const etags = {};
150
183
  for (const [key, value] of Object.entries(elements)) {
151
- // Drop empty (clear signal) and non-Latin1 (breaks fetch) tags.
184
+ // Skip empty (clear signal) and non-Latin1 (breaks the fetch header).
152
185
  if (key.startsWith(ETAG_ID_PREFIX) &&
153
186
  typeof value === 'string' &&
154
- /^[\u0020-\u00ff]+$/.test(value)) {
187
+ /^[ -ÿ]+$/.test(value)) {
155
188
  etags[key.slice(ETAG_ID_PREFIX.length)] = value;
156
189
  }
157
190
  }
@@ -164,17 +197,15 @@ function InnerRouter({ fallbackRoute }) {
164
197
  registryRef.current.delete(id);
165
198
  };
166
199
  }, []);
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.
200
+ // Send our cached etags via X-Waku-Router-Skip so the server can skip
201
+ // re-rendering slots whose etag still matches.
170
202
  const enhanceFetchWithSkip = useMemo(() => withEnhanceFetchFn((fetchFn) => (input, init) => {
171
203
  const headers = new Headers(init === null || init === void 0 ? void 0 : init.headers);
172
204
  headers.set(SKIP_HEADER, JSON.stringify(cachedEtagsRef.current));
173
205
  return fetchFn(input, { ...init, headers });
174
206
  }), []);
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.
207
+ // Waku's prefetch cache keys params by identity, so reuse one object per
208
+ // query string or prefetch entries get invalidated.
178
209
  const rscParamsByQueryRef = useRef(new Map());
179
210
  const getRscParams = useCallback((query) => {
180
211
  let params = rscParamsByQueryRef.current.get(query);
@@ -200,21 +231,17 @@ function InnerRouter({ fallbackRoute }) {
200
231
  routeChangeListeners[name].delete(handler);
201
232
  },
202
233
  }), [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
234
  const prefetchRoute = useCallback((next) => {
207
235
  var _a, _b;
208
236
  if (staticPathSetRef.current.has(next.path))
209
237
  return;
210
238
  prefetchRsc(encodeRoutePath(next.path), getRscParams(next.query), enhanceFetchWithSkip);
239
+ // When the build publishes it, __WAKU_ROUTER_PREFETCH__ yields the
240
+ // route's JS chunk ids to preload.
211
241
  (_b = (_a = globalThis).__WAKU_ROUTER_PREFETCH__) === null || _b === void 0 ? void 0 : _b.call(_a, next.path, (id) => preloadModule(id, { as: 'script' }));
212
242
  }, [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.
243
+ // Vite HMR (dev only): clear caches and refetch the current route when Waku's
244
+ // runtime fires __WAKU_RSC_RELOAD_LISTENERS__.
218
245
  useEffect(() => {
219
246
  var _a;
220
247
  if (!import.meta.hot)
@@ -234,25 +261,34 @@ function InnerRouter({ fallbackRoute }) {
234
261
  }, [route, refetch, getRscParams]);
235
262
  useEffect(() => {
236
263
  const callback = (event) => {
237
- var _a, _b, _c;
264
+ var _a, _b, _c, _d;
238
265
  if (!event.canIntercept)
239
266
  return;
240
267
  if (event.downloadRequest !== null || event.formData)
241
268
  return;
269
+ // React >=19.2's default transition indicator fires fake navigations.
270
+ if (event.info === 'react-transition')
271
+ return;
242
272
  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
273
  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.
274
+ const source = event
275
+ .sourceElement;
276
+ const clickedAnchor = (_b = (_a = source === null || source === void 0 ? void 0 : source.closest) === null || _a === void 0 ? void 0 : _a.call(source, 'a')) !== null && _b !== void 0 ? _b : null;
277
+ // Match the navigating <Link>(s): the clicked one by element identity (so
278
+ // two same-`to` links stay independent), or -- with no source element
279
+ // (programmatic / back-forward) -- every <Link> whose `to` hits the dest.
280
+ const matched = [];
281
+ for (const entry of registryRef.current.values()) {
282
+ const hit = clickedAnchor
283
+ ? entry.getElement() === clickedAnchor
284
+ : !source && routeMatchesHref(entry.href, nextRoute);
285
+ if (hit)
286
+ matched.push(entry);
287
+ }
288
+ const resolvedScroll = (_c = info === null || info === void 0 ? void 0 : info.scroll) !== null && _c !== void 0 ? _c : (matched.length ? matched[0].scroll : undefined);
289
+ const suppressScroll = resolvedScroll === false;
252
290
  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.
291
+ // Hash-only: no refetch; intercept only to suppress the browser scroll.
256
292
  emitRouteEvent('start', nextRoute);
257
293
  if (suppressScroll) {
258
294
  event.intercept({
@@ -271,35 +307,18 @@ function InnerRouter({ fallbackRoute }) {
271
307
  }
272
308
  emitRouteEvent('start', nextRoute);
273
309
  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
- }
310
+ // A clicked <Link>'s unstable_startTransition overrides the commit
311
+ // transition (View Transitions); its pending is then bypassed.
312
+ const customTransition = clickedAnchor
313
+ ? (_d = matched.find((e) => e.unstable_startTransition)) === null || _d === void 0 ? void 0 : _d.unstable_startTransition
314
+ : undefined;
315
+ const pendingSetters = matched
316
+ .filter((e) => !e.unstable_startTransition)
317
+ .map((e) => e.setOptimisticStatus);
299
318
  event.intercept({
300
319
  ...(suppressScroll ? { scroll: 'manual' } : {}),
301
320
  handler: () => new Promise((resolve, reject) => {
302
- // Always a transition: it keeps the previous page visible while the
321
+ // Run in a transition: keeps the previous page visible while the
303
322
  // next tree suspends, and scopes the optimistic pending updates so
304
323
  // React reverts them on commit/abort/error.
305
324
  startTransition(async () => {
@@ -331,15 +350,18 @@ function InnerRouter({ fallbackRoute }) {
331
350
  throw err;
332
351
  }
333
352
  }
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(() => {
353
+ // Updates after the first await lose the transition scope
354
+ // (https://react.dev/reference/react/startTransition#caveats),
355
+ // so re-wrap the commit; a <Link>'s unstable_startTransition
356
+ // takes over here.
357
+ const commitRoute = () => {
340
358
  setRenderError(null);
341
359
  setRoute(targetRoute);
342
- });
360
+ };
361
+ if (customTransition)
362
+ customTransition(commitRoute);
363
+ else
364
+ startTransition(commitRoute);
343
365
  emitRouteEvent('complete', targetRoute);
344
366
  resolve();
345
367
  }
@@ -355,9 +377,8 @@ function InnerRouter({ fallbackRoute }) {
355
377
  window.navigation.removeEventListener('navigate', callback);
356
378
  };
357
379
  }, [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.
380
+ // Mirror waku's INTERNAL_ServerRouter context shape; only route and
381
+ // prefetchRoute are used.
361
382
  const notAvailable = (name) => () => {
362
383
  throw new Error(`${name} is not available in waku-navigation`);
363
384
  };
@@ -370,6 +391,10 @@ function InnerRouter({ fallbackRoute }) {
370
391
  }), [route, prefetchRoute, routeChangeEvents, fetchingSlices]);
371
392
  return (_jsx(RouterContext.Provider, { value: routerCtxValue, children: _jsx(NavStatusRegistryContext.Provider, { value: { register }, children: _jsx(Slot, { id: "root", children: _jsx(Slot, { id: getRouteSlotId(route.path) }) }) }) }));
372
393
  }
394
+ /**
395
+ * The client router. Reads the initial route from `window.navigation`, listens
396
+ * for navigate events, and renders the current page. Takes no props.
397
+ */
373
398
  export function Router() {
374
399
  const initialRoute = parseRoute(new URL(window.navigation.currentEntry.url));
375
400
  return (_jsx(Root, { initialRscPath: encodeRoutePath(initialRoute.path), children: _jsx(InnerRouter, { fallbackRoute: initialRoute }) }));
package/dist/index.d.ts CHANGED
@@ -1 +1,2 @@
1
- export { Router, Slice, useNavigationStatus_UNSTABLE, useRouter, } from './client.js';
1
+ export { Link, Router, Slice, useNavigationStatus_UNSTABLE, useRouter, } from './client.js';
2
+ export type { LinkProps } from './client.js';
package/dist/index.js CHANGED
@@ -1 +1 @@
1
- export { Router, Slice, useNavigationStatus_UNSTABLE, useRouter, } from './client.js';
1
+ export { Link, Router, Slice, useNavigationStatus_UNSTABLE, useRouter, } from './client.js';
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "waku-navigation",
3
3
  "description": "Waku Router implementation with Navigation API",
4
- "version": "0.0.3",
4
+ "version": "0.0.4",
5
5
  "type": "module",
6
6
  "author": "Daishi Kato",
7
7
  "repository": {
@@ -30,30 +30,30 @@
30
30
  },
31
31
  "devDependencies": {
32
32
  "@eslint/js": "9.39.4",
33
- "@playwright/test": "^1.60.0",
33
+ "@playwright/test": "^1.61.0",
34
34
  "@testing-library/jest-dom": "^6.9.1",
35
35
  "@testing-library/react": "^16.3.2",
36
36
  "@testing-library/user-event": "^14.6.1",
37
37
  "@types/dom-navigation": "^1.0.7",
38
- "@types/node": "^25.7.0",
39
- "@types/react": "^19.2.14",
38
+ "@types/node": "^25.9.3",
39
+ "@types/react": "^19.2.17",
40
40
  "@types/react-dom": "^19.2.3",
41
41
  "eslint": "9.39.4",
42
- "eslint-import-resolver-typescript": "^4.4.4",
42
+ "eslint-import-resolver-typescript": "^4.4.5",
43
43
  "eslint-plugin-import": "^2.32.0",
44
44
  "eslint-plugin-jsx-a11y": "^6.10.2",
45
45
  "eslint-plugin-react": "^7.37.5",
46
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",
47
+ "happy-dom": "^20.10.4",
48
+ "prettier": "^3.8.4",
49
+ "react": "^19.2.7",
50
+ "react-dom": "^19.2.7",
51
+ "react-server-dom-webpack": "^19.2.7",
52
52
  "ts-expect": "^1.3.0",
53
53
  "typescript": "^6.0.3",
54
- "typescript-eslint": "^8.59.3",
55
- "vite": "^8.0.12",
56
- "vitest": "^4.1.6",
54
+ "typescript-eslint": "^8.61.1",
55
+ "vite": "^8.0.16",
56
+ "vitest": "^4.1.9",
57
57
  "waku": "1.0.0-beta.3",
58
58
  "waku-navigation": "link:"
59
59
  },