waku-navigation 0.0.1 → 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/README.md CHANGED
@@ -1,6 +1,10 @@
1
1
  # waku-navigation
2
2
 
3
- Experimental Waku Router implementation with Navigation API
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
- ## Usage
15
+ ## Quick start
12
16
 
13
- Create this file as `./src/waku.client.tsx`:
17
+ Create `./src/waku.client.tsx`:
14
18
 
15
19
  ```tsx
16
20
  import { StrictMode } from 'react';
@@ -23,9 +27,228 @@ const rootElement = (
23
27
  </StrictMode>
24
28
  );
25
29
 
26
- if ((globalThis as any).__WAKU_HYDRATE__) {
30
+ if ((globalThis as Record<string, unknown>).__WAKU_HYDRATE__) {
27
31
  hydrateRoot(document, rootElement);
28
32
  } else {
29
- createRoot(document as any).render(rootElement);
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)
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
+ ### `<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 CHANGED
@@ -1 +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;
1
32
  export declare function Router(): import("react/jsx-runtime").JSX.Element;
package/dist/client.js CHANGED
@@ -1,30 +1,315 @@
1
1
  /// <reference types="dom-navigation" />
2
2
  'use client';
3
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
4
- import { useEffect, useState } from 'react';
5
- import { Root, Slot, useRefetch } from 'waku/minimal/client';
6
- import { unstable_encodeRoutePath as encodeRoutePath, unstable_getHttpStatusFromMeta as getHttpStatusFromMeta, unstable_parseRoute as parseRoute, unstable_getRouteSlotId as getRouteSlotId, } from 'waku/router/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
+ }
7
75
  function InnerRouter({ initialRoute, httpStatus, }) {
8
76
  const refetch = useRefetch();
9
- const [routePath, setRoutePath] = useState(initialRoute.path);
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]);
10
188
  useEffect(() => {
11
189
  const callback = (event) => {
12
- // TODO: check if it's an external navigation
13
- event.intercept();
14
- const route = parseRoute(new URL(event.destination.url));
15
- const rscPath = encodeRoutePath(route.path);
16
- refetch(rscPath);
17
- setRoutePath(route.path);
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
+ });
18
287
  };
19
288
  window.navigation.addEventListener('navigate', callback);
20
289
  return () => {
21
290
  window.navigation.removeEventListener('navigate', callback);
22
291
  };
23
- }, [refetch]);
24
- return (_jsxs(Slot, { id: "root", children: [_jsx("meta", { name: "httpstatus", content: httpStatus }), _jsx(Slot, { id: getRouteSlotId(routePath) })] }));
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) })] }) }) }));
25
307
  }
26
308
  export function Router() {
27
- const initialRoute = parseRoute(new URL(window.navigation.currentEntry.url));
28
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;
29
314
  return (_jsx(Root, { initialRscPath: encodeRoutePath(initialRoute.path), children: _jsx(InnerRouter, { initialRoute: initialRoute, httpStatus: httpStatus }) }));
30
315
  }
package/dist/index.d.ts CHANGED
@@ -1 +1 @@
1
- export { Router } from './client.js';
1
+ export { Pending, Router, Slice, useRouter } from './client.js';
package/dist/index.js CHANGED
@@ -1 +1 @@
1
- export { Router } from './client.js';
1
+ export { Pending, Router, Slice, 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.1",
4
+ "version": "0.0.2",
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": "^9.39.2",
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.1",
35
+ "@testing-library/react": "^16.3.2",
46
36
  "@testing-library/user-event": "^14.6.1",
47
- "@types/dom-navigation": "^1.0.6",
48
- "@types/node": "^25.0.6",
49
- "@types/react": "^19.2.8",
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": "^9.39.2",
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.0.1",
57
- "happy-dom": "^20.1.0",
58
- "prettier": "^3.7.4",
59
- "react": "^19.2.3",
60
- "react-dom": "^19.2.3",
61
- "react-server-dom-webpack": "^19.2.3",
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": "^5.9.3",
64
- "typescript-eslint": "^8.52.0",
65
- "vite": "^7.3.1",
66
- "vitest": "^4.0.16",
67
- "waku": "https://pkg.pr.new/wakujs/waku@fc78f84",
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",
68
58
  "waku-navigation": "link:"
69
59
  },
70
60
  "peerDependencies": {
71
61
  "react": ">=19.0.0",
72
- "waku": ">=1.0.0-alpha.1"
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)"
73
75
  }
74
- }
76
+ }