waku-navigation 0.0.2 → 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` — `<Pending>` 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
 
@@ -53,7 +55,7 @@ import { Router } from 'waku-navigation';
53
55
  <Router />;
54
56
  ```
55
57
 
56
- No props. It reads the initial route from `window.navigation.currentEntry.url`, 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.
58
+ 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
59
 
58
60
  ### `useRouter()`
59
61
 
@@ -84,21 +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
- ### `<Pending>`
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:
92
+
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()`.
88
96
 
89
97
  ```tsx
90
- import { Pending } from 'waku-navigation';
98
+ import { Link } from 'waku-navigation';
99
+
100
+ <Link to="/slow">
101
+ Slow <NavSpinner />
102
+ </Link>;
103
+ ```
104
+
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'>;
114
+ ```
115
+
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.
117
+
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`.
119
+
120
+ ### `useNavigationStatus_UNSTABLE()`
121
+
122
+ Returns the navigation status of the enclosing `<Link>`, like React's `useFormStatus`. No arguments — it reads the `<Link>` by context.
123
+
124
+ ```tsx
125
+ 'use client';
126
+ import { useNavigationStatus_UNSTABLE } from 'waku-navigation';
91
127
 
92
- <Pending fallback={<Spinner />}>
93
- <a href="/slow">Go slow</a>
94
- </Pending>;
128
+ function NavSpinner() {
129
+ const { pending } = useNavigationStatus_UNSTABLE();
130
+ return pending ? <span>…</span> : null;
131
+ }
132
+ ```
133
+
134
+ ```tsx
135
+ <Link to="/slow">
136
+ Slow <NavSpinner />
137
+ </Link>
95
138
  ```
96
139
 
97
- `<Pending>` wraps an `<a>` and shows `fallback` while a navigation to that `<a>`'s href is in flight. Each `<Pending>` gets a unique id (via `useId`) that's stamped on the wrapped `<a>`; the router reads `event.sourceElement` to know which Pending fired so two Pendings pointing at the same href stay independent.
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 `{}`.
98
141
 
99
- For navigations that have no `sourceElement` `useRouter().push('/slow')`, `navigation.navigate(...)`, browser back/forward — the router falls back to the first `<Pending>` whose wrapped `<a>`'s href resolves to the destination path. So a Pending around a `<a href="/slow">` lights up for `useRouter().push('/slow')` too.
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.
100
143
 
101
- `<Pending>` only shows its fallback for the actual route change; React's transition keeps the previous page visible until the new tree (including any client-side `<Suspense>` boundaries) is ready to commit.
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.
102
145
 
103
146
  ### `<Slice>`
104
147
 
@@ -124,46 +167,39 @@ import { Slice } from 'waku-navigation';
124
167
 
125
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.
126
169
 
127
- ### `<Link>` plain `<a>`
170
+ ### `<Link>` (drop-in) or plain `<a>`
171
+
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):
128
173
 
129
174
  ```diff
130
175
  - import { Link } from 'waku/router/client';
176
+ + import { Link } from 'waku-navigation';
177
+ <Link to="/about">About</Link>
178
+ ```
179
+
180
+ Or drop `<Link>` entirely where you don't need type-safety, prefetching, or status — a plain `<a>` navigates client-side on its own:
181
+
182
+ ```diff
131
183
  - <Link to="/about">About</Link>
132
184
  + <a href="/about">About</a>
133
185
  ```
134
186
 
135
- The Navigation API intercepts same-origin `<a>` clicks for you. Cross-origin links, hash-only links, download links, and modifier-keyed clicks all behave correctly without `<Link>`. Specific `<Link>` props translate as follows:
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.
136
188
 
137
- | `<Link>` prop | `<a>` / `waku-navigation` equivalent |
138
- | ---------------------------- | -------------------------------------------------------------------------------------- |
139
- | `to="/x"` | `href="/x"` |
140
- | `scroll={false}` | Click handler that calls `useRouter().push(href, { scroll: false })` |
141
- | `unstable_pending={node}` | Wrap the `<a>` in `<Pending fallback={node}>` |
142
- | `unstable_notPending={node}` | No direct equivalent yet — render conditionally based on `useRouter().unstable_events` |
143
- | `unstable_prefetchOnEnter` | `onMouseEnter={() => useRouter().prefetch(href)}` in a client component |
144
- | `unstable_prefetchOnView` | `IntersectionObserver` + `useRouter().prefetch(href)` |
145
- | `unstable_startTransition` | Not needed — the router uses `useTransition` internally |
189
+ ### `<Link>…<Consumer/></Link>` (navigation status)
146
190
 
147
- Example for prefetch-on-hover:
191
+ Unchanged a descendant reads the enclosing `<Link>`'s status via the no-arg hook, exactly as in `waku/router`:
148
192
 
149
- ```tsx
150
- 'use client';
151
- import { useRouter } from 'waku-navigation';
193
+ ```diff
194
+ - import { Link, useNavigationStatus_UNSTABLE } from 'waku/router/client';
195
+ + import { Link, useNavigationStatus_UNSTABLE } from 'waku-navigation';
152
196
 
153
- export function PrefetchLink({
154
- to,
155
- children,
156
- }: {
157
- to: string;
158
- children: ReactNode;
159
- }) {
160
- const { prefetch } = useRouter();
161
- return (
162
- <a href={to} onMouseEnter={() => prefetch(to)}>
163
- {children}
164
- </a>
165
- );
166
- }
197
+ function NavSpinner() {
198
+ const { pending } = useNavigationStatus_UNSTABLE();
199
+ return pending ? <span>…</span> : null;
200
+ }
201
+
202
+ <Link to="/slow">Slow <NavSpinner /></Link>
167
203
  ```
168
204
 
169
205
  ### `<Slice>`
@@ -222,7 +258,6 @@ import {
222
258
  unstable_removeBase,
223
259
  unstable_RouterContext,
224
260
  unstable_parseRoute,
225
- unstable_getHttpStatusFromMeta,
226
261
  } from 'waku/router/client';
227
262
  ```
228
263
 
@@ -239,16 +274,17 @@ These are all handled inside the navigate-event listener so apps usually don't n
239
274
  - **Form submission guard** — `<form method="POST">` submissions (`event.formData != null`) are passed through to the server.
240
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.
241
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.
242
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.
243
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).
244
- - **`X-Waku-Router-Skip` header** — every refetch lists the element IDs we already have so the server can skip re-rendering shared layouts/slices.
245
- - **HMR cache invalidation** — when Waku's dev runtime fires `globalThis.__WAKU_RSC_RELOAD_LISTENERS__` (Vite HMR update), the router clears `staticPathSet` and `cachedIdSet` and refetches the current route. Guarded by `import.meta.hot` so it's stripped in production.
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.
281
+ - **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.
246
282
 
247
283
  ---
248
284
 
249
285
  ## Caveats / not yet implemented
250
286
 
251
- - `<Link>` is not provided. Plain `<a>` covers the same default behavior; the `unstable_*` Link niceties (`unstable_notPending`, custom `unstable_startTransition`) need a small client component if you want them.
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()`.
252
288
  - `unstable_routeInterceptor` (server-side route rewrite hook) is not supported.
253
289
  - `unstable_fetchRscStore` (custom RSC store) is not exposed on `<Router>`.
254
290
  - Requires a browser with the Navigation API. There is currently no fallback for older browsers.
package/dist/client.d.ts CHANGED
@@ -1,11 +1,51 @@
1
- import { type ReactNode } from 'react';
2
- 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';
3
3
  export { Slice };
4
4
  type Route = {
5
5
  path: string;
6
6
  query: string;
7
7
  hash: string;
8
8
  };
9
+ type NavigationStatus = {
10
+ pending?: boolean;
11
+ };
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;
9
49
  type PushReplaceOptions = {
10
50
  scroll?: boolean;
11
51
  };
@@ -13,6 +53,11 @@ type RouteChangeEvents = {
13
53
  on: (name: 'start' | 'complete', handler: (route: Route) => void) => void;
14
54
  off: (name: 'start' | 'complete', handler: (route: Route) => void) => void;
15
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
+ */
16
61
  export declare function useRouter(): {
17
62
  path: string;
18
63
  query: string;
@@ -25,8 +70,8 @@ export declare function useRouter(): {
25
70
  prefetch: (to: string) => void;
26
71
  unstable_events: RouteChangeEvents;
27
72
  };
28
- export declare function Pending({ fallback, children, }: {
29
- fallback: ReactNode;
30
- children: ReactNode;
31
- }): import("react/jsx-runtime").JSX.Element;
32
- 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
@@ -1,21 +1,98 @@
1
1
  /// <reference types="dom-navigation" />
2
2
  'use client';
3
- import { Fragment as _Fragment, jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
4
- import { Children, cloneElement, createContext, isValidElement, useCallback, useContext, useEffect, useId, useLayoutEffect, useMemo, useRef, useState, useTransition, } from 'react';
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
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_getHttpStatusFromMeta as getHttpStatusFromMeta, 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
- const PENDING_ATTR = 'data-waku-pending';
10
+ // Mirrors waku's unexported ETAG_ID_PREFIX (router/common.js).
11
+ const ETAG_ID_PREFIX = 'ETAG:';
14
12
  const noopRegister = () => () => { };
15
- const PendingRegistryContext = createContext({
13
+ const NavStatusRegistryContext = createContext({
16
14
  register: noopRegister,
17
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
+ }
18
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
+ */
19
96
  export function useRouter() {
20
97
  var _a, _b;
21
98
  const ctx = useContext(RouterContext);
@@ -45,71 +122,73 @@ export function useRouter() {
45
122
  unstable_events: ((_b = ctx === null || ctx === void 0 ? void 0 : ctx.routeChangeEvents) !== null && _b !== void 0 ? _b : noopEvents),
46
123
  };
47
124
  }
48
- export function Pending({ fallback, children, }) {
49
- const [isPending, startTransition] = useTransition();
50
- const { register } = useContext(PendingRegistryContext);
51
- const id = useId();
52
- const stamped = Children.map(children, (child) => {
53
- if (isValidElement(child) && child.type === 'a') {
54
- return cloneElement(child, {
55
- [PENDING_ATTR]: id,
56
- });
57
- }
58
- return child;
59
- });
60
- // Capture the wrapped <a>'s href so that programmatic / back-forward
61
- // navigations (which have no event.sourceElement) can still find their
62
- // Pending by matching the destination path.
63
- const href = Children.toArray(children)
64
- .map((child) => {
65
- if (isValidElement(child) && child.type === 'a') {
66
- const { href: h } = child.props;
67
- return typeof h === 'string' ? h : undefined;
68
- }
69
- return undefined;
70
- })
71
- .find((h) => h !== undefined);
72
- useLayoutEffect(() => register(id, { href, startTransition }), [id, href, register, startTransition]);
73
- return (_jsxs(_Fragment, { children: [stamped, isPending ? fallback : null] }));
74
- }
75
- function InnerRouter({ initialRoute, httpStatus, }) {
125
+ // Same origin + path + query (not pathname; fragment ignored). Malformed input
126
+ // returns false rather than throwing.
127
+ const routeMatchesHref = (href, route) => {
128
+ let url;
129
+ try {
130
+ url = new URL(href, window.location.href);
131
+ }
132
+ catch (_a) {
133
+ return false;
134
+ }
135
+ if (url.origin !== window.location.origin)
136
+ return false;
137
+ const parsed = parseRoute(url);
138
+ return parsed.path === route.path && parsed.query === route.query;
139
+ };
140
+ function InnerRouter({ fallbackRoute }) {
76
141
  const refetch = useRefetch();
77
- // Waku's INTERNAL_ServerRouter renders the SSR tree with hash: '' (URL
78
- // fragments aren't sent to the server), so we mirror that to keep the
79
- // first client render in sync, then upgrade to the real hash post-hydration.
80
- const [route, setRoute] = useState(() => ({
81
- ...initialRoute,
82
- hash: '',
83
- }));
84
- // Non-404 refetch failures (network errors, server 500s, etc.) get surfaced
85
- // by rethrowing during render so the user's <ErrorBoundary> can catch them.
86
- // The state clears on the next successful navigation.
142
+ const elementsPromise = useElementsPromise();
143
+ const [routeState, setRoute] = useState();
144
+ let route = routeState;
145
+ if (route === undefined) {
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.
150
+ const elements = use(elementsPromise);
151
+ const routeData = elements[ROUTE_ID];
152
+ route =
153
+ routeData && routeData[0] !== fallbackRoute.path
154
+ ? { path: routeData[0], query: routeData[1], hash: '' }
155
+ : { ...fallbackRoute, hash: '' };
156
+ setRoute(route);
157
+ }
158
+ // Rethrow during render so the user's <ErrorBoundary> catches non-404
159
+ // failures; cleared by the next successful navigation.
87
160
  const [renderError, setRenderError] = useState(null);
88
161
  if (renderError)
89
162
  throw renderError;
90
163
  useEffect(() => {
91
- if (initialRoute.hash) {
164
+ // SSR sends no fragment, so the hash starts ''; upgrade it post-hydration.
165
+ if (fallbackRoute.hash) {
92
166
  // eslint-disable-next-line react-hooks/set-state-in-effect
93
- setRoute((r) => ({ ...r, hash: initialRoute.hash }));
167
+ setRoute((r) => ({ ...r, hash: fallbackRoute.hash }));
94
168
  }
95
- // Only on mount.
96
169
  // eslint-disable-next-line react-hooks/exhaustive-deps
97
170
  }, []);
98
171
  const registryRef = useRef(new Map());
99
172
  const staticPathSetRef = useRef(new Set());
100
- const cachedIdSetRef = useRef(new Set());
101
- // Stable Set so Waku's <Slice> can mutate it (add on fetch start, delete on
102
- // fetch end) without losing state across re-renders. useMemo with [] keeps
103
- // the same instance and avoids reading ref.current during render.
173
+ const cachedEtagsRef = useRef({});
174
+ // Stable instance: <Slice> mutates this Set across renders.
104
175
  const fetchingSlices = useMemo(() => new Set(), []);
105
- const elementsPromise = useElementsPromise();
106
176
  useEffect(() => {
107
177
  elementsPromise.then((elements) => {
108
178
  const routeData = elements[ROUTE_ID];
109
179
  if (routeData && elements[IS_STATIC_ID]) {
110
180
  staticPathSetRef.current.add(routeData[0]);
111
181
  }
112
- cachedIdSetRef.current = new Set(Object.keys(elements).filter((k) => !k.startsWith('_') && k !== ROUTE_ID && k !== IS_STATIC_ID));
182
+ const etags = {};
183
+ for (const [key, value] of Object.entries(elements)) {
184
+ // Skip empty (clear signal) and non-Latin1 (breaks the fetch header).
185
+ if (key.startsWith(ETAG_ID_PREFIX) &&
186
+ typeof value === 'string' &&
187
+ /^[ -ÿ]+$/.test(value)) {
188
+ etags[key.slice(ETAG_ID_PREFIX.length)] = value;
189
+ }
190
+ }
191
+ cachedEtagsRef.current = etags;
113
192
  }, () => { });
114
193
  }, [elementsPromise]);
115
194
  const register = useCallback((id, entry) => {
@@ -118,16 +197,15 @@ function InnerRouter({ initialRoute, httpStatus, }) {
118
197
  registryRef.current.delete(id);
119
198
  };
120
199
  }, []);
121
- // Adds the X-Waku-Router-Skip header listing element ids we already have,
122
- // so the server can skip re-rendering them. 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.
123
202
  const enhanceFetchWithSkip = useMemo(() => withEnhanceFetchFn((fetchFn) => (input, init) => {
124
203
  const headers = new Headers(init === null || init === void 0 ? void 0 : init.headers);
125
- headers.set(SKIP_HEADER, JSON.stringify([...cachedIdSetRef.current]));
204
+ headers.set(SKIP_HEADER, JSON.stringify(cachedEtagsRef.current));
126
205
  return fetchFn(input, { ...init, headers });
127
206
  }), []);
128
- // Waku's prefetch cache keys the URLSearchParams by identity, so a fresh
129
- // `new URLSearchParams(...)` on every call would invalidate the prefetch
130
- // 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.
131
209
  const rscParamsByQueryRef = useRef(new Map());
132
210
  const getRscParams = useCallback((query) => {
133
211
  let params = rscParamsByQueryRef.current.get(query);
@@ -153,28 +231,24 @@ function InnerRouter({ initialRoute, httpStatus, }) {
153
231
  routeChangeListeners[name].delete(handler);
154
232
  },
155
233
  }), [routeChangeListeners]);
156
- // Eagerly fetch the RSC for a route (used by useRouter().prefetch). Build
157
- // output may also publish a __WAKU_ROUTER_PREFETCH__ helper that returns the
158
- // JS chunk ids for a path; if present, we preload them too.
159
234
  const prefetchRoute = useCallback((next) => {
160
235
  var _a, _b;
161
236
  if (staticPathSetRef.current.has(next.path))
162
237
  return;
163
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.
164
241
  (_b = (_a = globalThis).__WAKU_ROUTER_PREFETCH__) === null || _b === void 0 ? void 0 : _b.call(_a, next.path, (id) => preloadModule(id, { as: 'script' }));
165
242
  }, [enhanceFetchWithSkip, getRscParams]);
166
- // Vite HMR: when a server file changes, Waku's dev runtime invokes any
167
- // callbacks in __WAKU_RSC_RELOAD_LISTENERS__. We register one that drops
168
- // our path/id caches (so a "static" route picks up the new content) and
169
- // refetches the current route. In production import.meta.hot is undefined
170
- // 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__.
171
245
  useEffect(() => {
172
246
  var _a;
173
247
  if (!import.meta.hot)
174
248
  return;
175
249
  const refetchRoute = () => {
176
250
  staticPathSetRef.current.clear();
177
- cachedIdSetRef.current.clear();
251
+ cachedEtagsRef.current = {};
178
252
  refetch(encodeRoutePath(route.path), getRscParams(route.query));
179
253
  };
180
254
  const listeners = ((_a = globalThis).__WAKU_RSC_RELOAD_LISTENERS__ || (_a.__WAKU_RSC_RELOAD_LISTENERS__ = []));
@@ -192,20 +266,29 @@ function InnerRouter({ initialRoute, httpStatus, }) {
192
266
  return;
193
267
  if (event.downloadRequest !== null || event.formData)
194
268
  return;
269
+ // React >=19.2's default transition indicator fires fake navigations.
270
+ if (event.info === 'react-transition')
271
+ return;
195
272
  const nextRoute = parseRoute(new URL(event.destination.url));
196
- // useRouter().push/replace forward { scroll } via `info`. The Navigation
197
- // API itself doesn't persist `info` in history, so it only applies to
198
- // this single navigation -- exactly what we want.
199
273
  const info = event.info;
200
- const suppressScroll = (info === null || info === void 0 ? void 0 : info.scroll) === false;
201
- // Hash-only navigations: by default we don't intercept (the browser
202
- // handles URL + scroll natively), but if the caller explicitly asked
203
- // to suppress scrolling we still need to intercept so we can pass
204
- // 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;
205
290
  if (event.hashChange) {
206
- // Hash-only navigations don't refetch, so 'start' and 'complete'
207
- // both fire effectively together; emit both so subscribers don't
208
- // have to special-case them.
291
+ // Hash-only: no refetch; intercept only to suppress the browser scroll.
209
292
  emitRouteEvent('start', nextRoute);
210
293
  if (suppressScroll) {
211
294
  event.intercept({
@@ -224,31 +307,25 @@ function InnerRouter({ initialRoute, httpStatus, }) {
224
307
  }
225
308
  emitRouteEvent('start', nextRoute);
226
309
  const signal = event.signal;
227
- const source = event
228
- .sourceElement;
229
- const id = (_c = (_b = (_a = source === null || source === void 0 ? void 0 : source.closest) === null || _a === void 0 ? void 0 : _a.call(source, `a[${PENDING_ATTR}]`)) === null || _b === void 0 ? void 0 : _b.getAttribute(PENDING_ATTR)) !== null && _c !== void 0 ? _c : null;
230
- // Prefer the source-element match (most precise: tied to the actual
231
- // clicked <a>). For programmatic push/replace and browser back/forward
232
- // there's no sourceElement, so fall back to any <Pending> whose
233
- // wrapped <a>'s href resolves to this destination path.
234
- let registered = id ? registryRef.current.get(id) : undefined;
235
- if (!registered) {
236
- for (const entry of registryRef.current.values()) {
237
- if (entry.href !== undefined &&
238
- new URL(entry.href, window.location.href).pathname ===
239
- nextRoute.path) {
240
- registered = entry;
241
- break;
242
- }
243
- }
244
- }
245
- const startTransition = (_d = registered === null || registered === void 0 ? void 0 : registered.startTransition) !== null && _d !== void 0 ? _d : ((fn) => fn());
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);
246
318
  event.intercept({
247
319
  ...(suppressScroll ? { scroll: 'manual' } : {}),
248
320
  handler: () => new Promise((resolve, reject) => {
321
+ // Run in a transition: keeps the previous page visible while the
322
+ // next tree suspends, and scopes the optimistic pending updates so
323
+ // React reverts them on commit/abort/error.
249
324
  startTransition(async () => {
250
325
  var _a;
251
326
  try {
327
+ for (const set of pendingSetters)
328
+ set({ pending: true });
252
329
  let targetRoute = nextRoute;
253
330
  try {
254
331
  if (!staticPathSetRef.current.has(nextRoute.path)) {
@@ -273,8 +350,18 @@ function InnerRouter({ initialRoute, httpStatus, }) {
273
350
  throw err;
274
351
  }
275
352
  }
276
- setRenderError(null);
277
- setRoute(targetRoute);
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 = () => {
358
+ setRenderError(null);
359
+ setRoute(targetRoute);
360
+ };
361
+ if (customTransition)
362
+ customTransition(commitRoute);
363
+ else
364
+ startTransition(commitRoute);
278
365
  emitRouteEvent('complete', targetRoute);
279
366
  resolve();
280
367
  }
@@ -290,9 +377,8 @@ function InnerRouter({ initialRoute, httpStatus, }) {
290
377
  window.navigation.removeEventListener('navigate', callback);
291
378
  };
292
379
  }, [refetch, enhanceFetchWithSkip, getRscParams, emitRouteEvent]);
293
- // Mirror the shape Waku's INTERNAL_ServerRouter provides. We only care about
294
- // `route` and `prefetchRoute`; the other fields are no-ops so the context
295
- // value is type-compatible.
380
+ // Mirror waku's INTERNAL_ServerRouter context shape; only route and
381
+ // prefetchRoute are used.
296
382
  const notAvailable = (name) => () => {
297
383
  throw new Error(`${name} is not available in waku-navigation`);
298
384
  };
@@ -303,13 +389,13 @@ function InnerRouter({ initialRoute, httpStatus, }) {
303
389
  routeChangeEvents,
304
390
  fetchingSlices,
305
391
  }), [route, prefetchRoute, routeChangeEvents, fetchingSlices]);
306
- return (_jsx(RouterContext.Provider, { value: routerCtxValue, children: _jsx(PendingRegistryContext.Provider, { value: { register }, children: _jsxs(Slot, { id: "root", children: [_jsx("meta", { name: "httpstatus", content: httpStatus }), _jsx(Slot, { id: getRouteSlotId(route.path) })] }) }) }));
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) }) }) }) }));
307
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
+ */
308
398
  export function Router() {
309
- const httpStatus = getHttpStatusFromMeta();
310
- const parsed = parseRoute(new URL(window.navigation.currentEntry.url));
311
- const initialRoute = httpStatus === '404'
312
- ? { path: NOT_FOUND_PATH, query: '', hash: '' }
313
- : parsed;
314
- return (_jsx(Root, { initialRscPath: encodeRoutePath(initialRoute.path), children: _jsx(InnerRouter, { initialRoute: initialRoute, httpStatus: httpStatus }) }));
399
+ const initialRoute = parseRoute(new URL(window.navigation.currentEntry.url));
400
+ return (_jsx(Root, { initialRscPath: encodeRoutePath(initialRoute.path), children: _jsx(InnerRouter, { fallbackRoute: initialRoute }) }));
315
401
  }
package/dist/index.d.ts CHANGED
@@ -1 +1,2 @@
1
- export { Pending, Router, Slice, 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 { Pending, Router, Slice, 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.2",
4
+ "version": "0.0.4",
5
5
  "type": "module",
6
6
  "author": "Daishi Kato",
7
7
  "repository": {
@@ -30,36 +30,36 @@
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",
57
- "waku": "1.0.0-beta.0",
54
+ "typescript-eslint": "^8.61.1",
55
+ "vite": "^8.0.16",
56
+ "vitest": "^4.1.9",
57
+ "waku": "1.0.0-beta.3",
58
58
  "waku-navigation": "link:"
59
59
  },
60
60
  "peerDependencies": {
61
61
  "react": ">=19.0.0",
62
- "waku": ">=1.0.0-alpha.10"
62
+ "waku": ">=1.0.0-beta.3"
63
63
  },
64
64
  "scripts": {
65
65
  "compile": "rm -rf dist && tsc -p .",