waku-navigation 0.0.0 → 0.0.2
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/LICENSE +21 -0
- package/README.md +254 -0
- package/dist/client.d.ts +32 -0
- package/dist/client.js +315 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/package.json +74 -2
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Daishi Kato
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
|
13
|
+
all copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
21
|
+
THE SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
# waku-navigation
|
|
2
|
+
|
|
3
|
+
A drop-in replacement for `waku/router/client` built on the [Navigation API](https://developer.mozilla.org/docs/Web/API/Navigation_API) instead of the History API.
|
|
4
|
+
|
|
5
|
+
The entire public surface of `waku/router/client` — including every `unstable_*` feature — has a path to the same behavior with `waku-navigation`. This README walks through every feature and shows what the migration looks like.
|
|
6
|
+
|
|
7
|
+
> **Browser support**: the Navigation API ships in Chromium 102+ and Safari 26 / Firefox 145 (behind/with caveats on some older versions). Check [caniuse](https://caniuse.com/mdn-api_navigation) for current coverage.
|
|
8
|
+
|
|
9
|
+
## Install
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
npm install waku-navigation
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## Quick start
|
|
16
|
+
|
|
17
|
+
Create `./src/waku.client.tsx`:
|
|
18
|
+
|
|
19
|
+
```tsx
|
|
20
|
+
import { StrictMode } from 'react';
|
|
21
|
+
import { createRoot, hydrateRoot } from 'react-dom/client';
|
|
22
|
+
import { Router } from 'waku-navigation';
|
|
23
|
+
|
|
24
|
+
const rootElement = (
|
|
25
|
+
<StrictMode>
|
|
26
|
+
<Router />
|
|
27
|
+
</StrictMode>
|
|
28
|
+
);
|
|
29
|
+
|
|
30
|
+
if ((globalThis as Record<string, unknown>).__WAKU_HYDRATE__) {
|
|
31
|
+
hydrateRoot(document, rootElement);
|
|
32
|
+
} else {
|
|
33
|
+
createRoot(document).render(rootElement);
|
|
34
|
+
}
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Pages and `pages/_slices/*` work exactly as in any Waku app — `waku-navigation` only replaces the client-side router.
|
|
38
|
+
|
|
39
|
+
## Examples
|
|
40
|
+
|
|
41
|
+
- `examples/01_minimal` — `useRouter`, `<Slice>`, 404, prefetch, scroll option, events, HMR ([StackBlitz](https://stackblitz.com/github/wakujs/waku-navigation/tree/main/examples/01_minimal))
|
|
42
|
+
- `examples/02_pending` — `<Pending>` for slow routes, client-suspense settling
|
|
43
|
+
|
|
44
|
+
---
|
|
45
|
+
|
|
46
|
+
## API reference
|
|
47
|
+
|
|
48
|
+
### `<Router>`
|
|
49
|
+
|
|
50
|
+
```tsx
|
|
51
|
+
import { Router } from 'waku-navigation';
|
|
52
|
+
|
|
53
|
+
<Router />;
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
No props. It reads the initial route from `window.navigation.currentEntry.url`, sets up the navigate-event listener, and renders the page slot. It mirrors the shape Waku's `INTERNAL_ServerRouter` provides during SSR, so server-rendered markup hydrates without a flicker.
|
|
57
|
+
|
|
58
|
+
### `useRouter()`
|
|
59
|
+
|
|
60
|
+
Same shape as `waku/router/client`'s `useRouter`:
|
|
61
|
+
|
|
62
|
+
```tsx
|
|
63
|
+
import { useRouter } from 'waku-navigation';
|
|
64
|
+
|
|
65
|
+
function Nav() {
|
|
66
|
+
const router = useRouter();
|
|
67
|
+
// router.path -- current pathname (no leading base)
|
|
68
|
+
// router.query -- query string (no leading '?')
|
|
69
|
+
// router.hash -- '#section' or ''
|
|
70
|
+
// router.push(to, { scroll? })
|
|
71
|
+
// router.replace(to, { scroll? })
|
|
72
|
+
// router.reload()
|
|
73
|
+
// router.back()
|
|
74
|
+
// router.forward()
|
|
75
|
+
// router.prefetch(to)
|
|
76
|
+
// router.unstable_events.on('start' | 'complete', handler)
|
|
77
|
+
// router.unstable_events.off('start' | 'complete', handler)
|
|
78
|
+
}
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
Notes:
|
|
82
|
+
|
|
83
|
+
- `push`/`replace` return `navigation.navigate(...).finished` (a promise that resolves when the navigation commits or rejects on abort).
|
|
84
|
+
- `scroll: false` is forwarded to the navigate event via the Navigation API's `info` channel, which is not persisted in history. The internal handler then intercepts with `scroll: 'manual'` so the browser skips its default after-transition scroll.
|
|
85
|
+
- `prefetch(to)` calls `unstable_prefetchRsc` and, if the build publishes a `__WAKU_ROUTER_PREFETCH__` helper, preloads the route's JS chunks via `react-dom`'s `preloadModule`.
|
|
86
|
+
|
|
87
|
+
### `<Pending>`
|
|
88
|
+
|
|
89
|
+
```tsx
|
|
90
|
+
import { Pending } from 'waku-navigation';
|
|
91
|
+
|
|
92
|
+
<Pending fallback={<Spinner />}>
|
|
93
|
+
<a href="/slow">Go slow</a>
|
|
94
|
+
</Pending>;
|
|
95
|
+
```
|
|
96
|
+
|
|
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.
|
|
98
|
+
|
|
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.
|
|
100
|
+
|
|
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.
|
|
102
|
+
|
|
103
|
+
### `<Slice>`
|
|
104
|
+
|
|
105
|
+
```tsx
|
|
106
|
+
import { Slice } from 'waku-navigation';
|
|
107
|
+
|
|
108
|
+
<Slice id="clock" />
|
|
109
|
+
<Slice id="banner" lazy fallback={<div>Loading…</div>} />
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
`Slice` is re-exported from `waku/router/client` unchanged. It works because our `<Router>` provides the same `unstable_RouterContext` shape Waku's `<Slice>` expects (the `fetchingSlices` set and `useElementsPromise`).
|
|
113
|
+
|
|
114
|
+
---
|
|
115
|
+
|
|
116
|
+
## Migration from `waku/router/client`
|
|
117
|
+
|
|
118
|
+
### Drop-in: `<Router>` and `useRouter`
|
|
119
|
+
|
|
120
|
+
```diff
|
|
121
|
+
- import { Router, useRouter } from 'waku/router/client';
|
|
122
|
+
+ import { Router, useRouter } from 'waku-navigation';
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
`<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
|
+
|
|
127
|
+
### `<Link>` → plain `<a>`
|
|
128
|
+
|
|
129
|
+
```diff
|
|
130
|
+
- import { Link } from 'waku/router/client';
|
|
131
|
+
- <Link to="/about">About</Link>
|
|
132
|
+
+ <a href="/about">About</a>
|
|
133
|
+
```
|
|
134
|
+
|
|
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:
|
|
136
|
+
|
|
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 |
|
|
146
|
+
|
|
147
|
+
Example for prefetch-on-hover:
|
|
148
|
+
|
|
149
|
+
```tsx
|
|
150
|
+
'use client';
|
|
151
|
+
import { useRouter } from 'waku-navigation';
|
|
152
|
+
|
|
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
|
+
}
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
### `<Slice>`
|
|
170
|
+
|
|
171
|
+
Same import path change as `useRouter`. All props (`id`, `lazy`, `fallback`, children) are unchanged.
|
|
172
|
+
|
|
173
|
+
### `ErrorBoundary` → your own
|
|
174
|
+
|
|
175
|
+
`waku-navigation` does not ship an error boundary; any standard React error boundary works. Place it around `<Router>`:
|
|
176
|
+
|
|
177
|
+
```tsx
|
|
178
|
+
<ErrorBoundary>
|
|
179
|
+
<Router />
|
|
180
|
+
</ErrorBoundary>
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
Non-404 refetch failures (network errors, server 5xx) are rethrown during render and bubble to the nearest boundary. 404s are handled internally — the router refetches `/404` and renders that route's tree, so you keep using your `pages/404.tsx` (with `getConfig` returning a `404` http status) the same as before.
|
|
184
|
+
|
|
185
|
+
### `unstable_events`
|
|
186
|
+
|
|
187
|
+
Same shape as in `waku/router/client`:
|
|
188
|
+
|
|
189
|
+
```tsx
|
|
190
|
+
const { unstable_events } = useRouter();
|
|
191
|
+
|
|
192
|
+
useEffect(() => {
|
|
193
|
+
const onStart = (route) => console.log('start', route.path);
|
|
194
|
+
const onComplete = (route) => console.log('complete', route.path);
|
|
195
|
+
unstable_events.on('start', onStart);
|
|
196
|
+
unstable_events.on('complete', onComplete);
|
|
197
|
+
return () => {
|
|
198
|
+
unstable_events.off('start', onStart);
|
|
199
|
+
unstable_events.off('complete', onComplete);
|
|
200
|
+
};
|
|
201
|
+
}, [unstable_events]);
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
`'start'` fires before the refetch; `'complete'` fires after `setRoute` inside the transition. Hash-only navigations fire both back-to-back.
|
|
205
|
+
|
|
206
|
+
### Lower-level `unstable_*` exports
|
|
207
|
+
|
|
208
|
+
These are unchanged primitives — keep importing them from `waku/router/client` directly:
|
|
209
|
+
|
|
210
|
+
```ts
|
|
211
|
+
import {
|
|
212
|
+
unstable_HAS404_ID,
|
|
213
|
+
unstable_IS_STATIC_ID,
|
|
214
|
+
unstable_ROUTE_ID,
|
|
215
|
+
unstable_SKIP_HEADER,
|
|
216
|
+
unstable_encodeRoutePath,
|
|
217
|
+
unstable_encodeSliceId,
|
|
218
|
+
unstable_getRouteSlotId,
|
|
219
|
+
unstable_getSliceSlotId,
|
|
220
|
+
unstable_getErrorInfo,
|
|
221
|
+
unstable_addBase,
|
|
222
|
+
unstable_removeBase,
|
|
223
|
+
unstable_RouterContext,
|
|
224
|
+
unstable_parseRoute,
|
|
225
|
+
unstable_getHttpStatusFromMeta,
|
|
226
|
+
} from 'waku/router/client';
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
Internally `waku-navigation` uses these to interop with Waku's RSC store, slot IDs, and error metadata.
|
|
230
|
+
|
|
231
|
+
---
|
|
232
|
+
|
|
233
|
+
## What the router does for you
|
|
234
|
+
|
|
235
|
+
These are all handled inside the navigate-event listener so apps usually don't need to think about them:
|
|
236
|
+
|
|
237
|
+
- **Same-origin guard** — cross-origin navigations have `canIntercept: false` and are passed through to the browser.
|
|
238
|
+
- **Download guard** — `<a download>` clicks (`event.downloadRequest !== null`) are passed through, so the browser issues the download instead of an RSC fetch.
|
|
239
|
+
- **Form submission guard** — `<form method="POST">` submissions (`event.formData != null`) are passed through to the server.
|
|
240
|
+
- **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
|
+
- **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
|
+
- **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
|
+
- **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.
|
|
246
|
+
|
|
247
|
+
---
|
|
248
|
+
|
|
249
|
+
## Caveats / not yet implemented
|
|
250
|
+
|
|
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.
|
|
252
|
+
- `unstable_routeInterceptor` (server-side route rewrite hook) is not supported.
|
|
253
|
+
- `unstable_fetchRscStore` (custom RSC store) is not exposed on `<Router>`.
|
|
254
|
+
- Requires a browser with the Navigation API. There is currently no fallback for older browsers.
|
package/dist/client.d.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { type ReactNode } from 'react';
|
|
2
|
+
import { Slice } from 'waku/router/client';
|
|
3
|
+
export { Slice };
|
|
4
|
+
type Route = {
|
|
5
|
+
path: string;
|
|
6
|
+
query: string;
|
|
7
|
+
hash: string;
|
|
8
|
+
};
|
|
9
|
+
type PushReplaceOptions = {
|
|
10
|
+
scroll?: boolean;
|
|
11
|
+
};
|
|
12
|
+
type RouteChangeEvents = {
|
|
13
|
+
on: (name: 'start' | 'complete', handler: (route: Route) => void) => void;
|
|
14
|
+
off: (name: 'start' | 'complete', handler: (route: Route) => void) => void;
|
|
15
|
+
};
|
|
16
|
+
export declare function useRouter(): {
|
|
17
|
+
path: string;
|
|
18
|
+
query: string;
|
|
19
|
+
hash: string;
|
|
20
|
+
push: (to: string, options?: PushReplaceOptions) => Promise<NavigationHistoryEntry> | undefined;
|
|
21
|
+
replace: (to: string, options?: PushReplaceOptions) => Promise<NavigationHistoryEntry> | undefined;
|
|
22
|
+
reload: () => Promise<NavigationHistoryEntry> | undefined;
|
|
23
|
+
back: () => void;
|
|
24
|
+
forward: () => void;
|
|
25
|
+
prefetch: (to: string) => void;
|
|
26
|
+
unstable_events: RouteChangeEvents;
|
|
27
|
+
};
|
|
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;
|
package/dist/client.js
ADDED
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
/// <reference types="dom-navigation" />
|
|
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';
|
|
5
|
+
import { preloadModule } from 'react-dom';
|
|
6
|
+
import { Root, Slot, unstable_prefetchRsc as prefetchRsc, unstable_withEnhanceFetchFn as withEnhanceFetchFn, useElementsPromise_UNSTABLE as useElementsPromise, useRefetch, } from 'waku/minimal/client';
|
|
7
|
+
import { Slice, unstable_encodeRoutePath as encodeRoutePath, unstable_getErrorInfo as getErrorInfo, unstable_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.
|
|
11
|
+
export { Slice };
|
|
12
|
+
const NOT_FOUND_PATH = '/404';
|
|
13
|
+
const PENDING_ATTR = 'data-waku-pending';
|
|
14
|
+
const noopRegister = () => () => { };
|
|
15
|
+
const PendingRegistryContext = createContext({
|
|
16
|
+
register: noopRegister,
|
|
17
|
+
});
|
|
18
|
+
const noopEvents = { on: () => { }, off: () => { } };
|
|
19
|
+
export function useRouter() {
|
|
20
|
+
var _a, _b;
|
|
21
|
+
const ctx = useContext(RouterContext);
|
|
22
|
+
const route = (_a = ctx === null || ctx === void 0 ? void 0 : ctx.route) !== null && _a !== void 0 ? _a : { path: '/', query: '', hash: '' };
|
|
23
|
+
return {
|
|
24
|
+
path: route.path,
|
|
25
|
+
query: route.query,
|
|
26
|
+
hash: route.hash,
|
|
27
|
+
push: (to, options) => window.navigation.navigate(to, {
|
|
28
|
+
history: 'push',
|
|
29
|
+
info: { scroll: options === null || options === void 0 ? void 0 : options.scroll },
|
|
30
|
+
}).finished,
|
|
31
|
+
replace: (to, options) => window.navigation.navigate(to, {
|
|
32
|
+
history: 'replace',
|
|
33
|
+
info: { scroll: options === null || options === void 0 ? void 0 : options.scroll },
|
|
34
|
+
}).finished,
|
|
35
|
+
reload: () => window.navigation.reload().finished,
|
|
36
|
+
back: () => {
|
|
37
|
+
window.navigation.back();
|
|
38
|
+
},
|
|
39
|
+
forward: () => {
|
|
40
|
+
window.navigation.forward();
|
|
41
|
+
},
|
|
42
|
+
prefetch: (to) => {
|
|
43
|
+
ctx === null || ctx === void 0 ? void 0 : ctx.prefetchRoute(parseRoute(new URL(to, window.location.href)));
|
|
44
|
+
},
|
|
45
|
+
unstable_events: ((_b = ctx === null || ctx === void 0 ? void 0 : ctx.routeChangeEvents) !== null && _b !== void 0 ? _b : noopEvents),
|
|
46
|
+
};
|
|
47
|
+
}
|
|
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, }) {
|
|
76
|
+
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.
|
|
87
|
+
const [renderError, setRenderError] = useState(null);
|
|
88
|
+
if (renderError)
|
|
89
|
+
throw renderError;
|
|
90
|
+
useEffect(() => {
|
|
91
|
+
if (initialRoute.hash) {
|
|
92
|
+
// eslint-disable-next-line react-hooks/set-state-in-effect
|
|
93
|
+
setRoute((r) => ({ ...r, hash: initialRoute.hash }));
|
|
94
|
+
}
|
|
95
|
+
// Only on mount.
|
|
96
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
97
|
+
}, []);
|
|
98
|
+
const registryRef = useRef(new Map());
|
|
99
|
+
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.
|
|
104
|
+
const fetchingSlices = useMemo(() => new Set(), []);
|
|
105
|
+
const elementsPromise = useElementsPromise();
|
|
106
|
+
useEffect(() => {
|
|
107
|
+
elementsPromise.then((elements) => {
|
|
108
|
+
const routeData = elements[ROUTE_ID];
|
|
109
|
+
if (routeData && elements[IS_STATIC_ID]) {
|
|
110
|
+
staticPathSetRef.current.add(routeData[0]);
|
|
111
|
+
}
|
|
112
|
+
cachedIdSetRef.current = new Set(Object.keys(elements).filter((k) => !k.startsWith('_') && k !== ROUTE_ID && k !== IS_STATIC_ID));
|
|
113
|
+
}, () => { });
|
|
114
|
+
}, [elementsPromise]);
|
|
115
|
+
const register = useCallback((id, entry) => {
|
|
116
|
+
registryRef.current.set(id, entry);
|
|
117
|
+
return () => {
|
|
118
|
+
registryRef.current.delete(id);
|
|
119
|
+
};
|
|
120
|
+
}, []);
|
|
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.
|
|
123
|
+
const enhanceFetchWithSkip = useMemo(() => withEnhanceFetchFn((fetchFn) => (input, init) => {
|
|
124
|
+
const headers = new Headers(init === null || init === void 0 ? void 0 : init.headers);
|
|
125
|
+
headers.set(SKIP_HEADER, JSON.stringify([...cachedIdSetRef.current]));
|
|
126
|
+
return fetchFn(input, { ...init, headers });
|
|
127
|
+
}), []);
|
|
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.
|
|
131
|
+
const rscParamsByQueryRef = useRef(new Map());
|
|
132
|
+
const getRscParams = useCallback((query) => {
|
|
133
|
+
let params = rscParamsByQueryRef.current.get(query);
|
|
134
|
+
if (!params) {
|
|
135
|
+
params = new URLSearchParams({ query });
|
|
136
|
+
rscParamsByQueryRef.current.set(query, params);
|
|
137
|
+
}
|
|
138
|
+
return params;
|
|
139
|
+
}, []);
|
|
140
|
+
const routeChangeListeners = useMemo(() => ({
|
|
141
|
+
start: new Set(),
|
|
142
|
+
complete: new Set(),
|
|
143
|
+
}), []);
|
|
144
|
+
const emitRouteEvent = useCallback((name, r) => {
|
|
145
|
+
for (const listener of routeChangeListeners[name])
|
|
146
|
+
listener(r);
|
|
147
|
+
}, [routeChangeListeners]);
|
|
148
|
+
const routeChangeEvents = useMemo(() => ({
|
|
149
|
+
on: (name, handler) => {
|
|
150
|
+
routeChangeListeners[name].add(handler);
|
|
151
|
+
},
|
|
152
|
+
off: (name, handler) => {
|
|
153
|
+
routeChangeListeners[name].delete(handler);
|
|
154
|
+
},
|
|
155
|
+
}), [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
|
+
const prefetchRoute = useCallback((next) => {
|
|
160
|
+
var _a, _b;
|
|
161
|
+
if (staticPathSetRef.current.has(next.path))
|
|
162
|
+
return;
|
|
163
|
+
prefetchRsc(encodeRoutePath(next.path), getRscParams(next.query), enhanceFetchWithSkip);
|
|
164
|
+
(_b = (_a = globalThis).__WAKU_ROUTER_PREFETCH__) === null || _b === void 0 ? void 0 : _b.call(_a, next.path, (id) => preloadModule(id, { as: 'script' }));
|
|
165
|
+
}, [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.
|
|
171
|
+
useEffect(() => {
|
|
172
|
+
var _a;
|
|
173
|
+
if (!import.meta.hot)
|
|
174
|
+
return;
|
|
175
|
+
const refetchRoute = () => {
|
|
176
|
+
staticPathSetRef.current.clear();
|
|
177
|
+
cachedIdSetRef.current.clear();
|
|
178
|
+
refetch(encodeRoutePath(route.path), getRscParams(route.query));
|
|
179
|
+
};
|
|
180
|
+
const listeners = ((_a = globalThis).__WAKU_RSC_RELOAD_LISTENERS__ || (_a.__WAKU_RSC_RELOAD_LISTENERS__ = []));
|
|
181
|
+
listeners.unshift(refetchRoute);
|
|
182
|
+
return () => {
|
|
183
|
+
const i = listeners.indexOf(refetchRoute);
|
|
184
|
+
if (i !== -1)
|
|
185
|
+
listeners.splice(i, 1);
|
|
186
|
+
};
|
|
187
|
+
}, [route, refetch, getRscParams]);
|
|
188
|
+
useEffect(() => {
|
|
189
|
+
const callback = (event) => {
|
|
190
|
+
var _a, _b, _c, _d;
|
|
191
|
+
if (!event.canIntercept)
|
|
192
|
+
return;
|
|
193
|
+
if (event.downloadRequest !== null || event.formData)
|
|
194
|
+
return;
|
|
195
|
+
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
|
+
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.
|
|
205
|
+
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.
|
|
209
|
+
emitRouteEvent('start', nextRoute);
|
|
210
|
+
if (suppressScroll) {
|
|
211
|
+
event.intercept({
|
|
212
|
+
scroll: 'manual',
|
|
213
|
+
handler: async () => {
|
|
214
|
+
setRoute(nextRoute);
|
|
215
|
+
emitRouteEvent('complete', nextRoute);
|
|
216
|
+
},
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
else {
|
|
220
|
+
setRoute(nextRoute);
|
|
221
|
+
emitRouteEvent('complete', nextRoute);
|
|
222
|
+
}
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
emitRouteEvent('start', nextRoute);
|
|
226
|
+
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());
|
|
246
|
+
event.intercept({
|
|
247
|
+
...(suppressScroll ? { scroll: 'manual' } : {}),
|
|
248
|
+
handler: () => new Promise((resolve, reject) => {
|
|
249
|
+
startTransition(async () => {
|
|
250
|
+
var _a;
|
|
251
|
+
try {
|
|
252
|
+
let targetRoute = nextRoute;
|
|
253
|
+
try {
|
|
254
|
+
if (!staticPathSetRef.current.has(nextRoute.path)) {
|
|
255
|
+
await refetch(encodeRoutePath(nextRoute.path), getRscParams(nextRoute.query), enhanceFetchWithSkip);
|
|
256
|
+
}
|
|
257
|
+
if (signal.aborted)
|
|
258
|
+
return resolve();
|
|
259
|
+
}
|
|
260
|
+
catch (err) {
|
|
261
|
+
if (signal.aborted)
|
|
262
|
+
return resolve();
|
|
263
|
+
if (((_a = getErrorInfo(err)) === null || _a === void 0 ? void 0 : _a.status) === 404) {
|
|
264
|
+
if (!staticPathSetRef.current.has(NOT_FOUND_PATH)) {
|
|
265
|
+
await refetch(encodeRoutePath(NOT_FOUND_PATH), getRscParams(''), enhanceFetchWithSkip);
|
|
266
|
+
}
|
|
267
|
+
if (signal.aborted)
|
|
268
|
+
return resolve();
|
|
269
|
+
targetRoute = { path: NOT_FOUND_PATH, query: '', hash: '' };
|
|
270
|
+
}
|
|
271
|
+
else {
|
|
272
|
+
setRenderError(err);
|
|
273
|
+
throw err;
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
setRenderError(null);
|
|
277
|
+
setRoute(targetRoute);
|
|
278
|
+
emitRouteEvent('complete', targetRoute);
|
|
279
|
+
resolve();
|
|
280
|
+
}
|
|
281
|
+
catch (err) {
|
|
282
|
+
reject(err);
|
|
283
|
+
}
|
|
284
|
+
});
|
|
285
|
+
}),
|
|
286
|
+
});
|
|
287
|
+
};
|
|
288
|
+
window.navigation.addEventListener('navigate', callback);
|
|
289
|
+
return () => {
|
|
290
|
+
window.navigation.removeEventListener('navigate', callback);
|
|
291
|
+
};
|
|
292
|
+
}, [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.
|
|
296
|
+
const notAvailable = (name) => () => {
|
|
297
|
+
throw new Error(`${name} is not available in waku-navigation`);
|
|
298
|
+
};
|
|
299
|
+
const routerCtxValue = useMemo(() => ({
|
|
300
|
+
route,
|
|
301
|
+
changeRoute: notAvailable('changeRoute'),
|
|
302
|
+
prefetchRoute,
|
|
303
|
+
routeChangeEvents,
|
|
304
|
+
fetchingSlices,
|
|
305
|
+
}), [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) })] }) }) }));
|
|
307
|
+
}
|
|
308
|
+
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 }) }));
|
|
315
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { Pending, Router, Slice, useRouter } from './client.js';
|
package/dist/index.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { Pending, Router, Slice, useRouter } from './client.js';
|
package/package.json
CHANGED
|
@@ -1,4 +1,76 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "waku-navigation",
|
|
3
|
-
"
|
|
4
|
-
|
|
3
|
+
"description": "Waku Router implementation with Navigation API",
|
|
4
|
+
"version": "0.0.2",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"author": "Daishi Kato",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "https://github.com/wakujs/waku-navigation.git"
|
|
10
|
+
},
|
|
11
|
+
"exports": {
|
|
12
|
+
"./package.json": "./package.json",
|
|
13
|
+
".": {
|
|
14
|
+
"types": "./dist/index.d.ts",
|
|
15
|
+
"default": "./dist/index.js"
|
|
16
|
+
}
|
|
17
|
+
},
|
|
18
|
+
"sideEffects": false,
|
|
19
|
+
"files": [
|
|
20
|
+
"dist"
|
|
21
|
+
],
|
|
22
|
+
"keywords": [
|
|
23
|
+
"react",
|
|
24
|
+
"waku",
|
|
25
|
+
"navigation"
|
|
26
|
+
],
|
|
27
|
+
"license": "MIT",
|
|
28
|
+
"prettier": {
|
|
29
|
+
"singleQuote": true
|
|
30
|
+
},
|
|
31
|
+
"devDependencies": {
|
|
32
|
+
"@eslint/js": "9.39.4",
|
|
33
|
+
"@playwright/test": "^1.60.0",
|
|
34
|
+
"@testing-library/jest-dom": "^6.9.1",
|
|
35
|
+
"@testing-library/react": "^16.3.2",
|
|
36
|
+
"@testing-library/user-event": "^14.6.1",
|
|
37
|
+
"@types/dom-navigation": "^1.0.7",
|
|
38
|
+
"@types/node": "^25.7.0",
|
|
39
|
+
"@types/react": "^19.2.14",
|
|
40
|
+
"@types/react-dom": "^19.2.3",
|
|
41
|
+
"eslint": "9.39.4",
|
|
42
|
+
"eslint-import-resolver-typescript": "^4.4.4",
|
|
43
|
+
"eslint-plugin-import": "^2.32.0",
|
|
44
|
+
"eslint-plugin-jsx-a11y": "^6.10.2",
|
|
45
|
+
"eslint-plugin-react": "^7.37.5",
|
|
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",
|
|
52
|
+
"ts-expect": "^1.3.0",
|
|
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",
|
|
58
|
+
"waku-navigation": "link:"
|
|
59
|
+
},
|
|
60
|
+
"peerDependencies": {
|
|
61
|
+
"react": ">=19.0.0",
|
|
62
|
+
"waku": ">=1.0.0-alpha.10"
|
|
63
|
+
},
|
|
64
|
+
"scripts": {
|
|
65
|
+
"compile": "rm -rf dist && tsc -p .",
|
|
66
|
+
"test": "pnpm run '/^test:.*/'",
|
|
67
|
+
"test:format": "prettier -c .",
|
|
68
|
+
"test:lint": "eslint .",
|
|
69
|
+
"test:types": "tsc -p . --noEmit",
|
|
70
|
+
"test:types:examples": "tsc -p examples --noEmit",
|
|
71
|
+
"test:types:e2e": "tsc -p tsconfig.e2e.json",
|
|
72
|
+
"test:spec": "vitest run",
|
|
73
|
+
"e2e": "pnpm compile && playwright test",
|
|
74
|
+
"examples:01_minimal": "(cd examples/01_minimal; waku dev)"
|
|
75
|
+
}
|
|
76
|
+
}
|