waku-navigation 0.0.1 → 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 +286 -5
- package/dist/client.d.ts +38 -0
- package/dist/client.js +362 -16
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/package.json +33 -31
package/README.md
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
# waku-navigation
|
|
2
2
|
|
|
3
|
-
|
|
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.
|
|
4
8
|
|
|
5
9
|
## Install
|
|
6
10
|
|
|
@@ -8,9 +12,9 @@ Experimental Waku Router implementation with Navigation API
|
|
|
8
12
|
npm install waku-navigation
|
|
9
13
|
```
|
|
10
14
|
|
|
11
|
-
##
|
|
15
|
+
## Quick start
|
|
12
16
|
|
|
13
|
-
Create
|
|
17
|
+
Create `./src/waku.client.tsx`:
|
|
14
18
|
|
|
15
19
|
```tsx
|
|
16
20
|
import { StrictMode } from 'react';
|
|
@@ -23,9 +27,286 @@ const rootElement = (
|
|
|
23
27
|
</StrictMode>
|
|
24
28
|
);
|
|
25
29
|
|
|
26
|
-
if ((globalThis as
|
|
30
|
+
if ((globalThis as Record<string, unknown>).__WAKU_HYDRATE__) {
|
|
27
31
|
hydrateRoot(document, rootElement);
|
|
28
32
|
} else {
|
|
29
|
-
createRoot(document
|
|
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` — `useNavigationStatus_UNSTABLE` pending indicators on plain `<a>` 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` (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
|
+
|
|
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)
|
|
30
78
|
}
|
|
31
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
|
+
### `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
|
+
```
|
|
107
|
+
|
|
108
|
+
```tsx
|
|
109
|
+
<a href="/slow">Slow <NavSpinner href="/slow" /></a>
|
|
110
|
+
|
|
111
|
+
<a href="/slow" data-nav-key="slow">Slow <IdSpinner dataNavKey="slow" /></a>
|
|
112
|
+
```
|
|
113
|
+
|
|
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:
|
|
117
|
+
|
|
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:
|
|
120
|
+
|
|
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.
|
|
138
|
+
|
|
139
|
+
### `<Slice>`
|
|
140
|
+
|
|
141
|
+
```tsx
|
|
142
|
+
import { Slice } from 'waku-navigation';
|
|
143
|
+
|
|
144
|
+
<Slice id="clock" />
|
|
145
|
+
<Slice id="banner" lazy fallback={<div>Loading…</div>} />
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
`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`).
|
|
149
|
+
|
|
150
|
+
---
|
|
151
|
+
|
|
152
|
+
## Migration from `waku/router/client`
|
|
153
|
+
|
|
154
|
+
### Drop-in: `<Router>` and `useRouter`
|
|
155
|
+
|
|
156
|
+
```diff
|
|
157
|
+
- import { Router, useRouter } from 'waku/router/client';
|
|
158
|
+
+ import { Router, useRouter } from 'waku-navigation';
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
`<Router>` takes no props in `waku-navigation` — there is no `initialRoute`, `unstable_fetchRscStore`, or `unstable_routeInterceptor`. The initial route comes from `window.navigation`. If you used `unstable_routeInterceptor` to rewrite a path before refetch, do it in your `useRouter().push` call site instead.
|
|
162
|
+
|
|
163
|
+
### `<Link>` → plain `<a>`
|
|
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
|
+
|
|
167
|
+
```diff
|
|
168
|
+
- import { Link } from 'waku/router/client';
|
|
169
|
+
- <Link to="/about">About</Link>
|
|
170
|
+
+ <a href="/about">About</a>
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
Specific `<Link>` props translate as follows:
|
|
174
|
+
|
|
175
|
+
| `<Link>` prop | `waku-navigation` equivalent |
|
|
176
|
+
| ---------------------------- | ------------------------------------------------------------------------------------------------------------- |
|
|
177
|
+
| `to="/x"` | `<a href="/x">` |
|
|
178
|
+
| `scroll={false}` | Click handler that calls `useRouter().push(href, { scroll: false })` |
|
|
179
|
+
| `unstable_pending={node}` | A consumer using `useNavigationStatus_UNSTABLE({ href })` (or `{ dataNavKey }`) to render `node` when pending |
|
|
180
|
+
| `unstable_notPending={node}` | Same, rendering `node` when `!pending` |
|
|
181
|
+
| `unstable_prefetchOnEnter` | `onMouseEnter={() => useRouter().prefetch(href)}` in a client component |
|
|
182
|
+
| `unstable_prefetchOnView` | `IntersectionObserver` + `useRouter().prefetch(href)` |
|
|
183
|
+
| `unstable_startTransition` | Not needed — the router runs every navigation in a transition internally |
|
|
184
|
+
|
|
185
|
+
Example for prefetch-on-hover:
|
|
186
|
+
|
|
187
|
+
```tsx
|
|
188
|
+
'use client';
|
|
189
|
+
import { useRouter } from 'waku-navigation';
|
|
190
|
+
|
|
191
|
+
export function PrefetchLink({
|
|
192
|
+
to,
|
|
193
|
+
children,
|
|
194
|
+
}: {
|
|
195
|
+
to: string;
|
|
196
|
+
children: ReactNode;
|
|
197
|
+
}) {
|
|
198
|
+
const { prefetch } = useRouter();
|
|
199
|
+
return (
|
|
200
|
+
<a href={to} onMouseEnter={() => prefetch(to)}>
|
|
201
|
+
{children}
|
|
202
|
+
</a>
|
|
203
|
+
);
|
|
204
|
+
}
|
|
205
|
+
```
|
|
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
|
+
|
|
228
|
+
### `<Slice>`
|
|
229
|
+
|
|
230
|
+
Same import path change as `useRouter`. All props (`id`, `lazy`, `fallback`, children) are unchanged.
|
|
231
|
+
|
|
232
|
+
### `ErrorBoundary` → your own
|
|
233
|
+
|
|
234
|
+
`waku-navigation` does not ship an error boundary; any standard React error boundary works. Place it around `<Router>`:
|
|
235
|
+
|
|
236
|
+
```tsx
|
|
237
|
+
<ErrorBoundary>
|
|
238
|
+
<Router />
|
|
239
|
+
</ErrorBoundary>
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
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.
|
|
243
|
+
|
|
244
|
+
### `unstable_events`
|
|
245
|
+
|
|
246
|
+
Same shape as in `waku/router/client`:
|
|
247
|
+
|
|
248
|
+
```tsx
|
|
249
|
+
const { unstable_events } = useRouter();
|
|
250
|
+
|
|
251
|
+
useEffect(() => {
|
|
252
|
+
const onStart = (route) => console.log('start', route.path);
|
|
253
|
+
const onComplete = (route) => console.log('complete', route.path);
|
|
254
|
+
unstable_events.on('start', onStart);
|
|
255
|
+
unstable_events.on('complete', onComplete);
|
|
256
|
+
return () => {
|
|
257
|
+
unstable_events.off('start', onStart);
|
|
258
|
+
unstable_events.off('complete', onComplete);
|
|
259
|
+
};
|
|
260
|
+
}, [unstable_events]);
|
|
261
|
+
```
|
|
262
|
+
|
|
263
|
+
`'start'` fires before the refetch; `'complete'` fires after `setRoute` inside the transition. Hash-only navigations fire both back-to-back.
|
|
264
|
+
|
|
265
|
+
### Lower-level `unstable_*` exports
|
|
266
|
+
|
|
267
|
+
These are unchanged primitives — keep importing them from `waku/router/client` directly:
|
|
268
|
+
|
|
269
|
+
```ts
|
|
270
|
+
import {
|
|
271
|
+
unstable_HAS404_ID,
|
|
272
|
+
unstable_IS_STATIC_ID,
|
|
273
|
+
unstable_ROUTE_ID,
|
|
274
|
+
unstable_SKIP_HEADER,
|
|
275
|
+
unstable_encodeRoutePath,
|
|
276
|
+
unstable_encodeSliceId,
|
|
277
|
+
unstable_getRouteSlotId,
|
|
278
|
+
unstable_getSliceSlotId,
|
|
279
|
+
unstable_getErrorInfo,
|
|
280
|
+
unstable_addBase,
|
|
281
|
+
unstable_removeBase,
|
|
282
|
+
unstable_RouterContext,
|
|
283
|
+
unstable_parseRoute,
|
|
284
|
+
} from 'waku/router/client';
|
|
285
|
+
```
|
|
286
|
+
|
|
287
|
+
Internally `waku-navigation` uses these to interop with Waku's RSC store, slot IDs, and error metadata.
|
|
288
|
+
|
|
289
|
+
---
|
|
290
|
+
|
|
291
|
+
## What the router does for you
|
|
292
|
+
|
|
293
|
+
These are all handled inside the navigate-event listener so apps usually don't need to think about them:
|
|
294
|
+
|
|
295
|
+
- **Same-origin guard** — cross-origin navigations have `canIntercept: false` and are passed through to the browser.
|
|
296
|
+
- **Download guard** — `<a download>` clicks (`event.downloadRequest !== null`) are passed through, so the browser issues the download instead of an RSC fetch.
|
|
297
|
+
- **Form submission guard** — `<form method="POST">` submissions (`event.formData != null`) are passed through to the server.
|
|
298
|
+
- **Hash-only navigations** — not intercepted by default (the browser scrolls to the anchor natively), but state is synced so `useRouter().hash` reflects the new fragment. If `useRouter().push('#x', { scroll: false })` is used, the handler intercepts with `scroll: 'manual'` to honor that.
|
|
299
|
+
- **Abort during transition** — `event.signal` is checked between async steps so a fast-clicked second navigation cleanly cancels the first without committing stale state.
|
|
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.
|
|
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).
|
|
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.
|
|
304
|
+
|
|
305
|
+
---
|
|
306
|
+
|
|
307
|
+
## Caveats / not yet implemented
|
|
308
|
+
|
|
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)`.
|
|
310
|
+
- `unstable_routeInterceptor` (server-side route rewrite hook) is not supported.
|
|
311
|
+
- `unstable_fetchRscStore` (custom RSC store) is not exposed on `<Router>`.
|
|
312
|
+
- Requires a browser with the Navigation API. There is currently no fallback for older browsers.
|
package/dist/client.d.ts
CHANGED
|
@@ -1 +1,39 @@
|
|
|
1
|
+
import { Slice } from 'waku/router/client';
|
|
2
|
+
export { Slice };
|
|
3
|
+
type Route = {
|
|
4
|
+
path: string;
|
|
5
|
+
query: string;
|
|
6
|
+
hash: string;
|
|
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
|
+
};
|
|
18
|
+
type PushReplaceOptions = {
|
|
19
|
+
scroll?: boolean;
|
|
20
|
+
};
|
|
21
|
+
type RouteChangeEvents = {
|
|
22
|
+
on: (name: 'start' | 'complete', handler: (route: Route) => void) => void;
|
|
23
|
+
off: (name: 'start' | 'complete', handler: (route: Route) => void) => void;
|
|
24
|
+
};
|
|
25
|
+
export declare function useRouter(): {
|
|
26
|
+
path: string;
|
|
27
|
+
query: string;
|
|
28
|
+
hash: string;
|
|
29
|
+
push: (to: string, options?: PushReplaceOptions) => Promise<NavigationHistoryEntry> | undefined;
|
|
30
|
+
replace: (to: string, options?: PushReplaceOptions) => Promise<NavigationHistoryEntry> | undefined;
|
|
31
|
+
reload: () => Promise<NavigationHistoryEntry> | undefined;
|
|
32
|
+
back: () => void;
|
|
33
|
+
forward: () => void;
|
|
34
|
+
prefetch: (to: string) => void;
|
|
35
|
+
unstable_events: RouteChangeEvents;
|
|
36
|
+
};
|
|
37
|
+
declare function useNavigationStatus({ href, dataNavKey, }: NavStatusMatch): NavigationStatus;
|
|
38
|
+
export { useNavigationStatus as useNavigationStatus_UNSTABLE };
|
|
1
39
|
export declare function Router(): import("react/jsx-runtime").JSX.Element;
|
package/dist/client.js
CHANGED
|
@@ -1,30 +1,376 @@
|
|
|
1
1
|
/// <reference types="dom-navigation" />
|
|
2
2
|
'use client';
|
|
3
|
-
import { jsx as _jsx
|
|
4
|
-
import { useEffect, useState } from 'react';
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
|
|
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
|
+
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_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
|
+
// 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:';
|
|
23
|
+
const noopRegister = () => () => { };
|
|
24
|
+
const NavStatusRegistryContext = createContext({
|
|
25
|
+
register: noopRegister,
|
|
26
|
+
});
|
|
27
|
+
const noopEvents = { on: () => { }, off: () => { } };
|
|
28
|
+
export function useRouter() {
|
|
29
|
+
var _a, _b;
|
|
30
|
+
const ctx = useContext(RouterContext);
|
|
31
|
+
const route = (_a = ctx === null || ctx === void 0 ? void 0 : ctx.route) !== null && _a !== void 0 ? _a : { path: '/', query: '', hash: '' };
|
|
32
|
+
return {
|
|
33
|
+
path: route.path,
|
|
34
|
+
query: route.query,
|
|
35
|
+
hash: route.hash,
|
|
36
|
+
push: (to, options) => window.navigation.navigate(to, {
|
|
37
|
+
history: 'push',
|
|
38
|
+
info: { scroll: options === null || options === void 0 ? void 0 : options.scroll },
|
|
39
|
+
}).finished,
|
|
40
|
+
replace: (to, options) => window.navigation.navigate(to, {
|
|
41
|
+
history: 'replace',
|
|
42
|
+
info: { scroll: options === null || options === void 0 ? void 0 : options.scroll },
|
|
43
|
+
}).finished,
|
|
44
|
+
reload: () => window.navigation.reload().finished,
|
|
45
|
+
back: () => {
|
|
46
|
+
window.navigation.back();
|
|
47
|
+
},
|
|
48
|
+
forward: () => {
|
|
49
|
+
window.navigation.forward();
|
|
50
|
+
},
|
|
51
|
+
prefetch: (to) => {
|
|
52
|
+
ctx === null || ctx === void 0 ? void 0 : ctx.prefetchRoute(parseRoute(new URL(to, window.location.href)));
|
|
53
|
+
},
|
|
54
|
+
unstable_events: ((_b = ctx === null || ctx === void 0 ? void 0 : ctx.routeChangeEvents) !== null && _b !== void 0 ? _b : noopEvents),
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
// Counterpart of waku/router/client's useNavigationStatus_UNSTABLE, adapted
|
|
58
|
+
// for plain <a>. Two ways to say which navigation you care about:
|
|
59
|
+
//
|
|
60
|
+
// { href: '/slow' } -- any navigation whose destination is /slow. Nothing
|
|
61
|
+
// extra on the <a>; matches by destination, so every
|
|
62
|
+
// anchor to /slow shares it (no independence).
|
|
63
|
+
// { dataNavKey: 'x' } -- the navigation from <a data-nav-key="x">. Tells two
|
|
64
|
+
// same-href anchors apart (give them different ids;
|
|
65
|
+
// useId() for list-rendered ones).
|
|
66
|
+
//
|
|
67
|
+
// Pass both to match either. The consumer can live anywhere -- inside the <a>,
|
|
68
|
+
// beside it, or far away. Returns { pending: undefined } until a matching
|
|
69
|
+
// navigation is in flight; pending clears when the new route commits (after
|
|
70
|
+
// client-side Suspense), or on abort/error.
|
|
71
|
+
function useNavigationStatus({ href, dataNavKey, }) {
|
|
72
|
+
const [status, setOptimisticStatus] = useOptimistic({});
|
|
73
|
+
const { register } = useContext(NavStatusRegistryContext);
|
|
74
|
+
const id = useId();
|
|
75
|
+
useLayoutEffect(() => register(id, { href, dataNavKey, setOptimisticStatus }), [id, href, dataNavKey, register, setOptimisticStatus]);
|
|
76
|
+
return status;
|
|
77
|
+
}
|
|
78
|
+
export { useNavigationStatus as useNavigationStatus_UNSTABLE };
|
|
79
|
+
// True when `href` (possibly relative) resolves to the same internal route as
|
|
80
|
+
// `route`. Compared on origin + path + query -- not just pathname -- so
|
|
81
|
+
// /search?q=a doesn't match /search?q=b, and a cross-origin href that happens
|
|
82
|
+
// to share a path doesn't match an internal navigation. The fragment is
|
|
83
|
+
// ignored (hash-only navigations never set pending). Malformed input returns
|
|
84
|
+
// false rather than throwing, so a bad consumer href or odd DOM anchor can't
|
|
85
|
+
// break the navigation handler.
|
|
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 }) {
|
|
8
100
|
const refetch = useRefetch();
|
|
9
|
-
const
|
|
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
|
+
}
|
|
122
|
+
// Non-404 refetch failures (network errors, server 500s, etc.) get surfaced
|
|
123
|
+
// by rethrowing during render so the user's <ErrorBoundary> can catch them.
|
|
124
|
+
// The state clears on the next successful navigation.
|
|
125
|
+
const [renderError, setRenderError] = useState(null);
|
|
126
|
+
if (renderError)
|
|
127
|
+
throw renderError;
|
|
128
|
+
useEffect(() => {
|
|
129
|
+
if (fallbackRoute.hash) {
|
|
130
|
+
// eslint-disable-next-line react-hooks/set-state-in-effect
|
|
131
|
+
setRoute((r) => ({ ...r, hash: fallbackRoute.hash }));
|
|
132
|
+
}
|
|
133
|
+
// Only on mount.
|
|
134
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
135
|
+
}, []);
|
|
136
|
+
const registryRef = useRef(new Map());
|
|
137
|
+
const staticPathSetRef = useRef(new Set());
|
|
138
|
+
const cachedEtagsRef = useRef({});
|
|
139
|
+
// Stable Set so Waku's <Slice> can mutate it (add on fetch start, delete on
|
|
140
|
+
// fetch end) without losing state across re-renders. useMemo with [] keeps
|
|
141
|
+
// the same instance and avoids reading ref.current during render.
|
|
142
|
+
const fetchingSlices = useMemo(() => new Set(), []);
|
|
143
|
+
useEffect(() => {
|
|
144
|
+
elementsPromise.then((elements) => {
|
|
145
|
+
const routeData = elements[ROUTE_ID];
|
|
146
|
+
if (routeData && elements[IS_STATIC_ID]) {
|
|
147
|
+
staticPathSetRef.current.add(routeData[0]);
|
|
148
|
+
}
|
|
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;
|
|
159
|
+
}, () => { });
|
|
160
|
+
}, [elementsPromise]);
|
|
161
|
+
const register = useCallback((id, entry) => {
|
|
162
|
+
registryRef.current.set(id, entry);
|
|
163
|
+
return () => {
|
|
164
|
+
registryRef.current.delete(id);
|
|
165
|
+
};
|
|
166
|
+
}, []);
|
|
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.
|
|
170
|
+
const enhanceFetchWithSkip = useMemo(() => withEnhanceFetchFn((fetchFn) => (input, init) => {
|
|
171
|
+
const headers = new Headers(init === null || init === void 0 ? void 0 : init.headers);
|
|
172
|
+
headers.set(SKIP_HEADER, JSON.stringify(cachedEtagsRef.current));
|
|
173
|
+
return fetchFn(input, { ...init, headers });
|
|
174
|
+
}), []);
|
|
175
|
+
// Waku's prefetch cache keys the URLSearchParams by identity, so a fresh
|
|
176
|
+
// `new URLSearchParams(...)` on every call would invalidate the prefetch
|
|
177
|
+
// entry. We memoize by query string so the same params object is reused.
|
|
178
|
+
const rscParamsByQueryRef = useRef(new Map());
|
|
179
|
+
const getRscParams = useCallback((query) => {
|
|
180
|
+
let params = rscParamsByQueryRef.current.get(query);
|
|
181
|
+
if (!params) {
|
|
182
|
+
params = new URLSearchParams({ query });
|
|
183
|
+
rscParamsByQueryRef.current.set(query, params);
|
|
184
|
+
}
|
|
185
|
+
return params;
|
|
186
|
+
}, []);
|
|
187
|
+
const routeChangeListeners = useMemo(() => ({
|
|
188
|
+
start: new Set(),
|
|
189
|
+
complete: new Set(),
|
|
190
|
+
}), []);
|
|
191
|
+
const emitRouteEvent = useCallback((name, r) => {
|
|
192
|
+
for (const listener of routeChangeListeners[name])
|
|
193
|
+
listener(r);
|
|
194
|
+
}, [routeChangeListeners]);
|
|
195
|
+
const routeChangeEvents = useMemo(() => ({
|
|
196
|
+
on: (name, handler) => {
|
|
197
|
+
routeChangeListeners[name].add(handler);
|
|
198
|
+
},
|
|
199
|
+
off: (name, handler) => {
|
|
200
|
+
routeChangeListeners[name].delete(handler);
|
|
201
|
+
},
|
|
202
|
+
}), [routeChangeListeners]);
|
|
203
|
+
// Eagerly fetch the RSC for a route (used by useRouter().prefetch). Build
|
|
204
|
+
// output may also publish a __WAKU_ROUTER_PREFETCH__ helper that returns the
|
|
205
|
+
// JS chunk ids for a path; if present, we preload them too.
|
|
206
|
+
const prefetchRoute = useCallback((next) => {
|
|
207
|
+
var _a, _b;
|
|
208
|
+
if (staticPathSetRef.current.has(next.path))
|
|
209
|
+
return;
|
|
210
|
+
prefetchRsc(encodeRoutePath(next.path), getRscParams(next.query), enhanceFetchWithSkip);
|
|
211
|
+
(_b = (_a = globalThis).__WAKU_ROUTER_PREFETCH__) === null || _b === void 0 ? void 0 : _b.call(_a, next.path, (id) => preloadModule(id, { as: 'script' }));
|
|
212
|
+
}, [enhanceFetchWithSkip, getRscParams]);
|
|
213
|
+
// Vite HMR: when a server file changes, Waku's dev runtime invokes any
|
|
214
|
+
// callbacks in __WAKU_RSC_RELOAD_LISTENERS__. We register one that drops
|
|
215
|
+
// our path/id caches (so a "static" route picks up the new content) and
|
|
216
|
+
// refetches the current route. In production import.meta.hot is undefined
|
|
217
|
+
// and the effect body returns early.
|
|
218
|
+
useEffect(() => {
|
|
219
|
+
var _a;
|
|
220
|
+
if (!import.meta.hot)
|
|
221
|
+
return;
|
|
222
|
+
const refetchRoute = () => {
|
|
223
|
+
staticPathSetRef.current.clear();
|
|
224
|
+
cachedEtagsRef.current = {};
|
|
225
|
+
refetch(encodeRoutePath(route.path), getRscParams(route.query));
|
|
226
|
+
};
|
|
227
|
+
const listeners = ((_a = globalThis).__WAKU_RSC_RELOAD_LISTENERS__ || (_a.__WAKU_RSC_RELOAD_LISTENERS__ = []));
|
|
228
|
+
listeners.unshift(refetchRoute);
|
|
229
|
+
return () => {
|
|
230
|
+
const i = listeners.indexOf(refetchRoute);
|
|
231
|
+
if (i !== -1)
|
|
232
|
+
listeners.splice(i, 1);
|
|
233
|
+
};
|
|
234
|
+
}, [route, refetch, getRscParams]);
|
|
10
235
|
useEffect(() => {
|
|
11
236
|
const callback = (event) => {
|
|
12
|
-
|
|
13
|
-
event.
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
237
|
+
var _a, _b, _c;
|
|
238
|
+
if (!event.canIntercept)
|
|
239
|
+
return;
|
|
240
|
+
if (event.downloadRequest !== null || event.formData)
|
|
241
|
+
return;
|
|
242
|
+
const nextRoute = parseRoute(new URL(event.destination.url));
|
|
243
|
+
// useRouter().push/replace forward { scroll } via `info`. The Navigation
|
|
244
|
+
// API itself doesn't persist `info` in history, so it only applies to
|
|
245
|
+
// this single navigation -- exactly what we want.
|
|
246
|
+
const info = event.info;
|
|
247
|
+
const suppressScroll = (info === null || info === void 0 ? void 0 : info.scroll) === false;
|
|
248
|
+
// Hash-only navigations: by default we don't intercept (the browser
|
|
249
|
+
// handles URL + scroll natively), but if the caller explicitly asked
|
|
250
|
+
// to suppress scrolling we still need to intercept so we can pass
|
|
251
|
+
// scroll: 'manual' and skip the browser's anchor scroll.
|
|
252
|
+
if (event.hashChange) {
|
|
253
|
+
// Hash-only navigations don't refetch, so 'start' and 'complete'
|
|
254
|
+
// both fire effectively together; emit both so subscribers don't
|
|
255
|
+
// have to special-case them.
|
|
256
|
+
emitRouteEvent('start', nextRoute);
|
|
257
|
+
if (suppressScroll) {
|
|
258
|
+
event.intercept({
|
|
259
|
+
scroll: 'manual',
|
|
260
|
+
handler: async () => {
|
|
261
|
+
setRoute(nextRoute);
|
|
262
|
+
emitRouteEvent('complete', nextRoute);
|
|
263
|
+
},
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
else {
|
|
267
|
+
setRoute(nextRoute);
|
|
268
|
+
emitRouteEvent('complete', nextRoute);
|
|
269
|
+
}
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
emitRouteEvent('start', nextRoute);
|
|
273
|
+
const signal = event.signal;
|
|
274
|
+
const source = event
|
|
275
|
+
.sourceElement;
|
|
276
|
+
// Resolve the navigating <a>'s data-nav-key (for dataNavKey matching):
|
|
277
|
+
// the clicked <a> (most precise, so two same-href anchors stay
|
|
278
|
+
// independent), or -- for programmatic push/replace and browser
|
|
279
|
+
// back/forward, which have no sourceElement -- the first nav-key anchor in
|
|
280
|
+
// the live DOM whose href resolves to the destination. href matching
|
|
281
|
+
// needs none of this; it keys off the destination route directly.
|
|
282
|
+
let navDataKey = (_c = (_b = (_a = source === null || source === void 0 ? void 0 : source.closest) === null || _a === void 0 ? void 0 : _a.call(source, `a[${NAV_KEY_ATTR}]`)) === null || _b === void 0 ? void 0 : _b.getAttribute(NAV_KEY_ATTR)) !== null && _c !== void 0 ? _c : null;
|
|
283
|
+
if (navDataKey === null && !source) {
|
|
284
|
+
for (const anchor of document.querySelectorAll(`a[${NAV_KEY_ATTR}]`)) {
|
|
285
|
+
const href = anchor.getAttribute('href');
|
|
286
|
+
if (href !== null && routeMatchesHref(href, nextRoute)) {
|
|
287
|
+
navDataKey = anchor.getAttribute(NAV_KEY_ATTR);
|
|
288
|
+
break;
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
const pendingSetters = [];
|
|
293
|
+
for (const entry of registryRef.current.values()) {
|
|
294
|
+
const byKey = entry.dataNavKey !== undefined && entry.dataNavKey === navDataKey;
|
|
295
|
+
const byHref = entry.href !== undefined && routeMatchesHref(entry.href, nextRoute);
|
|
296
|
+
if (byKey || byHref)
|
|
297
|
+
pendingSetters.push(entry.setOptimisticStatus);
|
|
298
|
+
}
|
|
299
|
+
event.intercept({
|
|
300
|
+
...(suppressScroll ? { scroll: 'manual' } : {}),
|
|
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.
|
|
305
|
+
startTransition(async () => {
|
|
306
|
+
var _a;
|
|
307
|
+
try {
|
|
308
|
+
for (const set of pendingSetters)
|
|
309
|
+
set({ pending: true });
|
|
310
|
+
let targetRoute = nextRoute;
|
|
311
|
+
try {
|
|
312
|
+
if (!staticPathSetRef.current.has(nextRoute.path)) {
|
|
313
|
+
await refetch(encodeRoutePath(nextRoute.path), getRscParams(nextRoute.query), enhanceFetchWithSkip);
|
|
314
|
+
}
|
|
315
|
+
if (signal.aborted)
|
|
316
|
+
return resolve();
|
|
317
|
+
}
|
|
318
|
+
catch (err) {
|
|
319
|
+
if (signal.aborted)
|
|
320
|
+
return resolve();
|
|
321
|
+
if (((_a = getErrorInfo(err)) === null || _a === void 0 ? void 0 : _a.status) === 404) {
|
|
322
|
+
if (!staticPathSetRef.current.has(NOT_FOUND_PATH)) {
|
|
323
|
+
await refetch(encodeRoutePath(NOT_FOUND_PATH), getRscParams(''), enhanceFetchWithSkip);
|
|
324
|
+
}
|
|
325
|
+
if (signal.aborted)
|
|
326
|
+
return resolve();
|
|
327
|
+
targetRoute = { path: NOT_FOUND_PATH, query: '', hash: '' };
|
|
328
|
+
}
|
|
329
|
+
else {
|
|
330
|
+
setRenderError(err);
|
|
331
|
+
throw err;
|
|
332
|
+
}
|
|
333
|
+
}
|
|
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
|
+
});
|
|
343
|
+
emitRouteEvent('complete', targetRoute);
|
|
344
|
+
resolve();
|
|
345
|
+
}
|
|
346
|
+
catch (err) {
|
|
347
|
+
reject(err);
|
|
348
|
+
}
|
|
349
|
+
});
|
|
350
|
+
}),
|
|
351
|
+
});
|
|
18
352
|
};
|
|
19
353
|
window.navigation.addEventListener('navigate', callback);
|
|
20
354
|
return () => {
|
|
21
355
|
window.navigation.removeEventListener('navigate', callback);
|
|
22
356
|
};
|
|
23
|
-
}, [refetch]);
|
|
24
|
-
|
|
357
|
+
}, [refetch, enhanceFetchWithSkip, getRscParams, emitRouteEvent]);
|
|
358
|
+
// Mirror the shape Waku's INTERNAL_ServerRouter provides. We only care about
|
|
359
|
+
// `route` and `prefetchRoute`; the other fields are no-ops so the context
|
|
360
|
+
// value is type-compatible.
|
|
361
|
+
const notAvailable = (name) => () => {
|
|
362
|
+
throw new Error(`${name} is not available in waku-navigation`);
|
|
363
|
+
};
|
|
364
|
+
const routerCtxValue = useMemo(() => ({
|
|
365
|
+
route,
|
|
366
|
+
changeRoute: notAvailable('changeRoute'),
|
|
367
|
+
prefetchRoute,
|
|
368
|
+
routeChangeEvents,
|
|
369
|
+
fetchingSlices,
|
|
370
|
+
}), [route, prefetchRoute, routeChangeEvents, fetchingSlices]);
|
|
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) }) }) }) }));
|
|
25
372
|
}
|
|
26
373
|
export function Router() {
|
|
27
374
|
const initialRoute = parseRoute(new URL(window.navigation.currentEntry.url));
|
|
28
|
-
|
|
29
|
-
return (_jsx(Root, { initialRscPath: encodeRoutePath(initialRoute.path), children: _jsx(InnerRouter, { initialRoute: initialRoute, httpStatus: httpStatus }) }));
|
|
375
|
+
return (_jsx(Root, { initialRscPath: encodeRoutePath(initialRoute.path), children: _jsx(InnerRouter, { fallbackRoute: initialRoute }) }));
|
|
30
376
|
}
|
package/dist/index.d.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export { Router } from './client.js';
|
|
1
|
+
export { Router, Slice, useNavigationStatus_UNSTABLE, useRouter, } from './client.js';
|
package/dist/index.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export { Router } from './client.js';
|
|
1
|
+
export { Router, Slice, useNavigationStatus_UNSTABLE, useRouter, } from './client.js';
|
package/package.json
CHANGED
|
@@ -1,9 +1,8 @@
|
|
|
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
|
-
"packageManager": "pnpm@10.28.0",
|
|
7
6
|
"author": "Daishi Kato",
|
|
8
7
|
"repository": {
|
|
9
8
|
"type": "git",
|
|
@@ -20,16 +19,6 @@
|
|
|
20
19
|
"files": [
|
|
21
20
|
"dist"
|
|
22
21
|
],
|
|
23
|
-
"scripts": {
|
|
24
|
-
"compile": "rm -rf dist && tsc -p .",
|
|
25
|
-
"test": "pnpm run '/^test:.*/'",
|
|
26
|
-
"test:format": "prettier -c .",
|
|
27
|
-
"test:lint": "eslint .",
|
|
28
|
-
"test:types": "tsc -p . --noEmit",
|
|
29
|
-
"test:types:examples": "tsc -p examples --noEmit",
|
|
30
|
-
"test:spec": "vitest run",
|
|
31
|
-
"examples:01_minimal": "(cd examples/01_minimal; waku dev)"
|
|
32
|
-
},
|
|
33
22
|
"keywords": [
|
|
34
23
|
"react",
|
|
35
24
|
"waku",
|
|
@@ -40,35 +29,48 @@
|
|
|
40
29
|
"singleQuote": true
|
|
41
30
|
},
|
|
42
31
|
"devDependencies": {
|
|
43
|
-
"@eslint/js": "
|
|
32
|
+
"@eslint/js": "9.39.4",
|
|
33
|
+
"@playwright/test": "^1.60.0",
|
|
44
34
|
"@testing-library/jest-dom": "^6.9.1",
|
|
45
|
-
"@testing-library/react": "^16.3.
|
|
35
|
+
"@testing-library/react": "^16.3.2",
|
|
46
36
|
"@testing-library/user-event": "^14.6.1",
|
|
47
|
-
"@types/dom-navigation": "^1.0.
|
|
48
|
-
"@types/node": "^25.0
|
|
49
|
-
"@types/react": "^19.2.
|
|
37
|
+
"@types/dom-navigation": "^1.0.7",
|
|
38
|
+
"@types/node": "^25.7.0",
|
|
39
|
+
"@types/react": "^19.2.14",
|
|
50
40
|
"@types/react-dom": "^19.2.3",
|
|
51
|
-
"eslint": "
|
|
41
|
+
"eslint": "9.39.4",
|
|
52
42
|
"eslint-import-resolver-typescript": "^4.4.4",
|
|
53
43
|
"eslint-plugin-import": "^2.32.0",
|
|
54
44
|
"eslint-plugin-jsx-a11y": "^6.10.2",
|
|
55
45
|
"eslint-plugin-react": "^7.37.5",
|
|
56
|
-
"eslint-plugin-react-hooks": "^7.
|
|
57
|
-
"happy-dom": "^20.
|
|
58
|
-
"prettier": "^3.
|
|
59
|
-
"react": "^19.2.
|
|
60
|
-
"react-dom": "^19.2.
|
|
61
|
-
"react-server-dom-webpack": "^19.2.
|
|
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",
|
|
62
52
|
"ts-expect": "^1.3.0",
|
|
63
|
-
"typescript": "^
|
|
64
|
-
"typescript-eslint": "^8.
|
|
65
|
-
"vite": "^
|
|
66
|
-
"vitest": "^4.
|
|
67
|
-
"waku": "
|
|
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.3",
|
|
68
58
|
"waku-navigation": "link:"
|
|
69
59
|
},
|
|
70
60
|
"peerDependencies": {
|
|
71
61
|
"react": ">=19.0.0",
|
|
72
|
-
"waku": ">=1.0.0-
|
|
62
|
+
"waku": ">=1.0.0-beta.3"
|
|
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)"
|
|
73
75
|
}
|
|
74
|
-
}
|
|
76
|
+
}
|