waku-navigation 0.0.2 → 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
@@ -39,7 +39,7 @@ Pages and `pages/_slices/*` work exactly as in any Waku app — `waku-navigation
39
39
  ## Examples
40
40
 
41
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` — `<Pending>` for slow routes, client-suspense settling
42
+ - `examples/02_pending` — `useNavigationStatus_UNSTABLE` pending indicators on plain `<a>` for slow routes, client-suspense settling
43
43
 
44
44
  ---
45
45
 
@@ -53,7 +53,7 @@ import { Router } from 'waku-navigation';
53
53
  <Router />;
54
54
  ```
55
55
 
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.
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
57
 
58
58
  ### `useRouter()`
59
59
 
@@ -84,21 +84,57 @@ Notes:
84
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
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
86
 
87
- ### `<Pending>`
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
+ ```
88
107
 
89
108
  ```tsx
90
- import { Pending } from 'waku-navigation';
109
+ <a href="/slow">Slow <NavSpinner href="/slow" /></a>
91
110
 
92
- <Pending fallback={<Spinner />}>
93
- <a href="/slow">Go slow</a>
94
- </Pending>;
111
+ <a href="/slow" data-nav-key="slow">Slow <IdSpinner dataNavKey="slow" /></a>
95
112
  ```
96
113
 
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.
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:
98
117
 
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.
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:
100
120
 
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.
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.
102
138
 
103
139
  ### `<Slice>`
104
140
 
@@ -126,23 +162,25 @@ import { Slice } from 'waku-navigation';
126
162
 
127
163
  ### `<Link>` → plain `<a>`
128
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
+
129
167
  ```diff
130
168
  - import { Link } from 'waku/router/client';
131
169
  - <Link to="/about">About</Link>
132
170
  + <a href="/about">About</a>
133
171
  ```
134
172
 
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:
173
+ Specific `<Link>` props translate as follows:
136
174
 
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 |
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 |
146
184
 
147
185
  Example for prefetch-on-hover:
148
186
 
@@ -166,6 +204,27 @@ export function PrefetchLink({
166
204
  }
167
205
  ```
168
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
+
169
228
  ### `<Slice>`
170
229
 
171
230
  Same import path change as `useRouter`. All props (`id`, `lazy`, `fallback`, children) are unchanged.
@@ -222,7 +281,6 @@ import {
222
281
  unstable_removeBase,
223
282
  unstable_RouterContext,
224
283
  unstable_parseRoute,
225
- unstable_getHttpStatusFromMeta,
226
284
  } from 'waku/router/client';
227
285
  ```
228
286
 
@@ -241,14 +299,14 @@ These are all handled inside the navigate-event listener so apps usually don't n
241
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.
242
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.
243
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).
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.
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.
246
304
 
247
305
  ---
248
306
 
249
307
  ## Caveats / not yet implemented
250
308
 
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.
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)`.
252
310
  - `unstable_routeInterceptor` (server-side route rewrite hook) is not supported.
253
311
  - `unstable_fetchRscStore` (custom RSC store) is not exposed on `<Router>`.
254
312
  - Requires a browser with the Navigation API. There is currently no fallback for older browsers.
package/dist/client.d.ts CHANGED
@@ -1,4 +1,3 @@
1
- import { type ReactNode } from 'react';
2
1
  import { Slice } from 'waku/router/client';
3
2
  export { Slice };
4
3
  type Route = {
@@ -6,6 +5,16 @@ type Route = {
6
5
  query: string;
7
6
  hash: string;
8
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
+ };
9
18
  type PushReplaceOptions = {
10
19
  scroll?: boolean;
11
20
  };
@@ -25,8 +34,6 @@ export declare function useRouter(): {
25
34
  prefetch: (to: string) => void;
26
35
  unstable_events: RouteChangeEvents;
27
36
  };
28
- export declare function Pending({ fallback, children, }: {
29
- fallback: ReactNode;
30
- children: ReactNode;
31
- }): import("react/jsx-runtime").JSX.Element;
37
+ declare function useNavigationStatus({ href, dataNavKey, }: NavStatusMatch): NavigationStatus;
38
+ export { useNavigationStatus as useNavigationStatus_UNSTABLE };
32
39
  export declare function Router(): import("react/jsx-runtime").JSX.Element;
package/dist/client.js CHANGED
@@ -1,18 +1,27 @@
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';
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
8
  // Slice is re-exported from waku/router/client unchanged. It only needs the
9
9
  // router context (fetchingSlices + the elements promise) -- both of which our
10
10
  // <Router> already provides -- so the component works as-is.
11
11
  export { Slice };
12
12
  const NOT_FOUND_PATH = '/404';
13
- const PENDING_ATTR = 'data-waku-pending';
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:';
14
23
  const noopRegister = () => () => { };
15
- const PendingRegistryContext = createContext({
24
+ const NavStatusRegistryContext = createContext({
16
25
  register: noopRegister,
17
26
  });
18
27
  const noopEvents = { on: () => { }, off: () => { } };
@@ -45,42 +54,71 @@ export function useRouter() {
45
54
  unstable_events: ((_b = ctx === null || ctx === void 0 ? void 0 : ctx.routeChangeEvents) !== null && _b !== void 0 ? _b : noopEvents),
46
55
  };
47
56
  }
48
- export function Pending({ fallback, children, }) {
49
- const [isPending, startTransition] = useTransition();
50
- const { register } = useContext(PendingRegistryContext);
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);
51
74
  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] }));
75
+ useLayoutEffect(() => register(id, { href, dataNavKey, setOptimisticStatus }), [id, href, dataNavKey, register, setOptimisticStatus]);
76
+ return status;
74
77
  }
75
- function InnerRouter({ initialRoute, httpStatus, }) {
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 }) {
76
100
  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
- }));
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
+ }
84
122
  // Non-404 refetch failures (network errors, server 500s, etc.) get surfaced
85
123
  // by rethrowing during render so the user's <ErrorBoundary> can catch them.
86
124
  // The state clears on the next successful navigation.
@@ -88,28 +126,36 @@ function InnerRouter({ initialRoute, httpStatus, }) {
88
126
  if (renderError)
89
127
  throw renderError;
90
128
  useEffect(() => {
91
- if (initialRoute.hash) {
129
+ if (fallbackRoute.hash) {
92
130
  // eslint-disable-next-line react-hooks/set-state-in-effect
93
- setRoute((r) => ({ ...r, hash: initialRoute.hash }));
131
+ setRoute((r) => ({ ...r, hash: fallbackRoute.hash }));
94
132
  }
95
133
  // Only on mount.
96
134
  // eslint-disable-next-line react-hooks/exhaustive-deps
97
135
  }, []);
98
136
  const registryRef = useRef(new Map());
99
137
  const staticPathSetRef = useRef(new Set());
100
- const cachedIdSetRef = useRef(new Set());
138
+ const cachedEtagsRef = useRef({});
101
139
  // Stable Set so Waku's <Slice> can mutate it (add on fetch start, delete on
102
140
  // fetch end) without losing state across re-renders. useMemo with [] keeps
103
141
  // the same instance and avoids reading ref.current during render.
104
142
  const fetchingSlices = useMemo(() => new Set(), []);
105
- const elementsPromise = useElementsPromise();
106
143
  useEffect(() => {
107
144
  elementsPromise.then((elements) => {
108
145
  const routeData = elements[ROUTE_ID];
109
146
  if (routeData && elements[IS_STATIC_ID]) {
110
147
  staticPathSetRef.current.add(routeData[0]);
111
148
  }
112
- cachedIdSetRef.current = new Set(Object.keys(elements).filter((k) => !k.startsWith('_') && k !== ROUTE_ID && k !== IS_STATIC_ID));
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;
113
159
  }, () => { });
114
160
  }, [elementsPromise]);
115
161
  const register = useCallback((id, entry) => {
@@ -118,11 +164,12 @@ function InnerRouter({ initialRoute, httpStatus, }) {
118
164
  registryRef.current.delete(id);
119
165
  };
120
166
  }, []);
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.
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.
123
170
  const enhanceFetchWithSkip = useMemo(() => withEnhanceFetchFn((fetchFn) => (input, init) => {
124
171
  const headers = new Headers(init === null || init === void 0 ? void 0 : init.headers);
125
- headers.set(SKIP_HEADER, JSON.stringify([...cachedIdSetRef.current]));
172
+ headers.set(SKIP_HEADER, JSON.stringify(cachedEtagsRef.current));
126
173
  return fetchFn(input, { ...init, headers });
127
174
  }), []);
128
175
  // Waku's prefetch cache keys the URLSearchParams by identity, so a fresh
@@ -174,7 +221,7 @@ function InnerRouter({ initialRoute, httpStatus, }) {
174
221
  return;
175
222
  const refetchRoute = () => {
176
223
  staticPathSetRef.current.clear();
177
- cachedIdSetRef.current.clear();
224
+ cachedEtagsRef.current = {};
178
225
  refetch(encodeRoutePath(route.path), getRscParams(route.query));
179
226
  };
180
227
  const listeners = ((_a = globalThis).__WAKU_RSC_RELOAD_LISTENERS__ || (_a.__WAKU_RSC_RELOAD_LISTENERS__ = []));
@@ -187,7 +234,7 @@ function InnerRouter({ initialRoute, httpStatus, }) {
187
234
  }, [route, refetch, getRscParams]);
188
235
  useEffect(() => {
189
236
  const callback = (event) => {
190
- var _a, _b, _c, _d;
237
+ var _a, _b, _c;
191
238
  if (!event.canIntercept)
192
239
  return;
193
240
  if (event.downloadRequest !== null || event.formData)
@@ -226,29 +273,40 @@ function InnerRouter({ initialRoute, httpStatus, }) {
226
273
  const signal = event.signal;
227
274
  const source = event
228
275
  .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;
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);
241
288
  break;
242
289
  }
243
290
  }
244
291
  }
245
- const startTransition = (_d = registered === null || registered === void 0 ? void 0 : registered.startTransition) !== null && _d !== void 0 ? _d : ((fn) => fn());
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
+ }
246
299
  event.intercept({
247
300
  ...(suppressScroll ? { scroll: 'manual' } : {}),
248
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.
249
305
  startTransition(async () => {
250
306
  var _a;
251
307
  try {
308
+ for (const set of pendingSetters)
309
+ set({ pending: true });
252
310
  let targetRoute = nextRoute;
253
311
  try {
254
312
  if (!staticPathSetRef.current.has(nextRoute.path)) {
@@ -273,8 +331,15 @@ function InnerRouter({ initialRoute, httpStatus, }) {
273
331
  throw err;
274
332
  }
275
333
  }
276
- setRenderError(null);
277
- setRoute(targetRoute);
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
+ });
278
343
  emitRouteEvent('complete', targetRoute);
279
344
  resolve();
280
345
  }
@@ -303,13 +368,9 @@ function InnerRouter({ initialRoute, httpStatus, }) {
303
368
  routeChangeEvents,
304
369
  fetchingSlices,
305
370
  }), [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) })] }) }) }));
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) }) }) }) }));
307
372
  }
308
373
  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 }) }));
374
+ const initialRoute = parseRoute(new URL(window.navigation.currentEntry.url));
375
+ return (_jsx(Root, { initialRscPath: encodeRoutePath(initialRoute.path), children: _jsx(InnerRouter, { fallbackRoute: initialRoute }) }));
315
376
  }
package/dist/index.d.ts CHANGED
@@ -1 +1 @@
1
- export { Pending, Router, Slice, useRouter } from './client.js';
1
+ export { Router, Slice, useNavigationStatus_UNSTABLE, useRouter, } from './client.js';
package/dist/index.js CHANGED
@@ -1 +1 @@
1
- export { Pending, Router, Slice, useRouter } from './client.js';
1
+ export { 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.3",
5
5
  "type": "module",
6
6
  "author": "Daishi Kato",
7
7
  "repository": {
@@ -54,12 +54,12 @@
54
54
  "typescript-eslint": "^8.59.3",
55
55
  "vite": "^8.0.12",
56
56
  "vitest": "^4.1.6",
57
- "waku": "1.0.0-beta.0",
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 .",