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 +82 -24
- package/dist/client.d.ts +12 -5
- package/dist/client.js +131 -70
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/package.json +3 -3
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` — `<
|
|
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
|
|
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
|
-
###
|
|
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
|
-
|
|
109
|
+
<a href="/slow">Slow <NavSpinner href="/slow" /></a>
|
|
91
110
|
|
|
92
|
-
<
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
173
|
+
Specific `<Link>` props translate as follows:
|
|
136
174
|
|
|
137
|
-
| `<Link>` prop |
|
|
138
|
-
| ---------------------------- |
|
|
139
|
-
| `to="/x"` |
|
|
140
|
-
| `scroll={false}` | Click handler that calls `useRouter().push(href, { scroll: false })`
|
|
141
|
-
| `unstable_pending={node}` |
|
|
142
|
-
| `unstable_notPending={node}` |
|
|
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
|
|
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
|
|
245
|
-
- **HMR cache invalidation** — when Waku's dev runtime fires `globalThis.__WAKU_RSC_RELOAD_LISTENERS__` (Vite HMR update), the router clears `staticPathSet` and `
|
|
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
|
|
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
|
-
|
|
29
|
-
|
|
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 {
|
|
4
|
-
import {
|
|
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,
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
-
|
|
53
|
-
|
|
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
|
-
|
|
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
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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 (
|
|
129
|
+
if (fallbackRoute.hash) {
|
|
92
130
|
// eslint-disable-next-line react-hooks/set-state-in-effect
|
|
93
|
-
setRoute((r) => ({ ...r, 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
|
|
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
|
-
|
|
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
|
|
122
|
-
// so the server can skip re-rendering
|
|
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(
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
230
|
-
//
|
|
231
|
-
//
|
|
232
|
-
//
|
|
233
|
-
//
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
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
|
|
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
|
-
|
|
277
|
-
|
|
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(
|
|
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
|
|
310
|
-
|
|
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 {
|
|
1
|
+
export { Router, Slice, useNavigationStatus_UNSTABLE, useRouter, } from './client.js';
|
package/dist/index.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export {
|
|
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.
|
|
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.
|
|
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-
|
|
62
|
+
"waku": ">=1.0.0-beta.3"
|
|
63
63
|
},
|
|
64
64
|
"scripts": {
|
|
65
65
|
"compile": "rm -rf dist && tsc -p .",
|