);
expect(entries.length).toBeGreaterThan(0);
- // Each etag is either a string (dynamic slot) or waku's numeric static
+ // Each etag is either a string (dynamic slot) or waku's numeric immutable
// sentinel 1 (static slot); /'s slots are static, so they come back as 1.
for (const [, etag] of entries) {
expect(['string', 'number']).toContain(typeof etag);
@@ -495,3 +495,71 @@ test('client-side 404: navigating to an unknown route renders the 404 page', asy
await expect(page).toHaveURL('/');
await expect(page.locator('h1')).toHaveText('Welcome to the Home Page');
});
+
+test(' object form navigates to a parameterized route', async ({
+ page,
+}) => {
+ await page.goto('/');
+ await waitForHydration(page);
+
+ // to={{ to: '/user/[id]', params: { id: 'alice' } }} builds /user/alice.
+ await page.getByTestId('user-link').click();
+ await expect(page).toHaveURL('/user/alice');
+ await expect(page.getByTestId('user-heading')).toHaveText('User: alice');
+});
+
+test('useRouter().push object form navigates to a parameterized route', async ({
+ page,
+}) => {
+ await page.goto('/');
+ await waitForHydration(page);
+
+ await page.getByTestId('user-push').click();
+ await expect(page).toHaveURL('/user/bob');
+ await expect(page.getByTestId('user-heading')).toHaveText('User: bob');
+});
+
+test('object form URL-encodes param values', async ({ page }) => {
+ await page.goto('/');
+ await waitForHydration(page);
+
+ // params: { id: 'a b' } -> buildRouteHref percent-encodes the space into the
+ // URL. (waku passes the raw path segment to the page, so it renders encoded.)
+ await page.getByTestId('user-push-encoded').click();
+ await expect(page).toHaveURL('/user/a%20b');
+ await expect(page.getByTestId('user-heading')).toHaveText('User: a%20b');
+});
+
+test('useParams_UNSTABLE reads the typed, decoded route params', async ({
+ page,
+}) => {
+ await page.goto('/user/alice');
+ await waitForHydration(page);
+ await expect(page.getByTestId('user-params-id')).toHaveText(
+ 'params.id: alice',
+ );
+
+ // Unlike the raw page prop, useParams_UNSTABLE decodes the segment: %20 -> ' '.
+ await page.goto('/user/a%20b');
+ await waitForHydration(page);
+ await expect(page.getByTestId('user-params-id')).toHaveText('params.id: a b');
+});
+
+test('useSearch_UNSTABLE / useSetSearch_UNSTABLE read and write typed search', async ({
+ page,
+}) => {
+ await page.goto('/search');
+ await waitForHydration(page);
+ // codec parses an empty query to the default tab.
+ await expect(page.getByTestId('search-tab')).toHaveText('tab: home');
+
+ // setSearch serializes via the codec and navigates to the same path.
+ await page.getByTestId('set-tab-faq').click();
+ await expect(page).toHaveURL('/search?tab=faq');
+ await expect(page.getByTestId('search-tab')).toHaveText('tab: faq');
+
+ // The updater form receives the current parsed search.
+ await page.getByTestId('set-tab-updater').click();
+ await expect(page).toHaveURL('/search?tab=faq-x');
+ await expect(page.getByTestId('search-tab')).toHaveText('tab: faq-x');
+});
diff --git a/examples/01_minimal/package.json b/examples/01_minimal/package.json
index 8c16076..7b53622 100644
--- a/examples/01_minimal/package.json
+++ b/examples/01_minimal/package.json
@@ -12,7 +12,7 @@
"react": "^19.2.3",
"react-dom": "^19.2.3",
"react-server-dom-webpack": "^19.2.3",
- "waku": "1.0.0-beta.6",
+ "waku": "1.0.0-beta.7",
"waku-navigation": "^0.0.1"
},
"devDependencies": {
diff --git a/examples/01_minimal/src/components/search-codecs-provider.tsx b/examples/01_minimal/src/components/search-codecs-provider.tsx
new file mode 100644
index 0000000..912647e
--- /dev/null
+++ b/examples/01_minimal/src/components/search-codecs-provider.tsx
@@ -0,0 +1,13 @@
+'use client';
+
+import type { ReactNode } from 'react';
+import { Unstable_SearchCodecsProvider } from 'waku-navigation';
+import { tabCodec } from '../search-codecs.js';
+
+export function SearchCodecs({ children }: { children: ReactNode }) {
+ return (
+
+ {children}
+
+ );
+}
diff --git a/examples/01_minimal/src/components/search-controls.tsx b/examples/01_minimal/src/components/search-controls.tsx
new file mode 100644
index 0000000..0f02edf
--- /dev/null
+++ b/examples/01_minimal/src/components/search-controls.tsx
@@ -0,0 +1,28 @@
+'use client';
+
+import { useSearch_UNSTABLE, useSetSearch_UNSTABLE } from 'waku-navigation';
+
+export function SearchControls() {
+ const search = useSearch_UNSTABLE({ from: '/search' });
+ const setSearch = useSetSearch_UNSTABLE({ from: '/search' });
+ return (
+ <>
+ Search
+ tab: {search?.tab ?? '(none)'}
+
+
+ >
+ );
+}
diff --git a/examples/01_minimal/src/components/user-nav.tsx b/examples/01_minimal/src/components/user-nav.tsx
new file mode 100644
index 0000000..00c19af
--- /dev/null
+++ b/examples/01_minimal/src/components/user-nav.tsx
@@ -0,0 +1,31 @@
+'use client';
+
+import { Link, useRouter } from 'waku-navigation';
+
+export function UserNav() {
+ const { push } = useRouter();
+ return (
+ <>
+
+ User alice
+
+
+
+ >
+ );
+}
diff --git a/examples/01_minimal/src/components/user-params.tsx b/examples/01_minimal/src/components/user-params.tsx
new file mode 100644
index 0000000..d7f902e
--- /dev/null
+++ b/examples/01_minimal/src/components/user-params.tsx
@@ -0,0 +1,10 @@
+'use client';
+
+import { useParams_UNSTABLE } from 'waku-navigation';
+
+export function UserParams() {
+ const params = useParams_UNSTABLE({ from: '/user/[id]' });
+ return (
+ params.id: {params?.id ?? '(none)'}
+ );
+}
diff --git a/examples/01_minimal/src/pages.gen.ts b/examples/01_minimal/src/pages.gen.ts
index 973c45d..7814d8c 100644
--- a/examples/01_minimal/src/pages.gen.ts
+++ b/examples/01_minimal/src/pages.gen.ts
@@ -9,13 +9,19 @@ import type { getConfig as File_SlicesClock_getConfig } from './pages/_slices/cl
import type { getConfig as File_About_getConfig } from './pages/about';
// prettier-ignore
import type { getConfig as File_Index_getConfig } from './pages/index';
+// prettier-ignore
+import type { getConfig as File_Search_getConfig } from './pages/search';
+// prettier-ignore
+import type { getConfig as File_UserId_getConfig } from './pages/user/[id]';
// prettier-ignore
type Page =
| { path: '/404'; render: 'static' }
| ({ path: '/_slices/clock' } & GetConfigResponse)
| ({ path: '/about' } & GetConfigResponse)
-| ({ path: '/' } & GetConfigResponse);
+| ({ path: '/' } & GetConfigResponse)
+| ({ path: '/search' } & GetConfigResponse)
+| ({ path: '/user/[id]' } & GetConfigResponse);
// prettier-ignore
type Layout =
diff --git a/examples/01_minimal/src/pages/_layout.tsx b/examples/01_minimal/src/pages/_layout.tsx
index 6bea7ac..b7e2bc1 100644
--- a/examples/01_minimal/src/pages/_layout.tsx
+++ b/examples/01_minimal/src/pages/_layout.tsx
@@ -1,10 +1,11 @@
import type { ReactNode } from 'react';
import { EventLog } from '../components/event-log.js';
import { RouteInfo } from '../components/route-info.js';
+import { SearchCodecs } from '../components/search-codecs-provider.js';
export default function HomeLayout({ children }: { children: ReactNode }) {
return (
- <>
+
-
Home
@@ -16,6 +17,6 @@ export default function HomeLayout({ children }: { children: ReactNode }) {
{children}
- >
+
);
}
diff --git a/examples/01_minimal/src/pages/index.tsx b/examples/01_minimal/src/pages/index.tsx
index c1c7f2a..97f7399 100644
--- a/examples/01_minimal/src/pages/index.tsx
+++ b/examples/01_minimal/src/pages/index.tsx
@@ -1,5 +1,6 @@
import { PrefetchButton } from '../components/prefetch-button.js';
import { ScrollButton } from '../components/scroll-button.js';
+import { UserNav } from '../components/user-nav.js';
export default function HomePage() {
return (
@@ -7,6 +8,7 @@ export default function HomePage() {
Welcome to the Home Page
+
>
);
diff --git a/examples/01_minimal/src/pages/search.tsx b/examples/01_minimal/src/pages/search.tsx
new file mode 100644
index 0000000..8c25886
--- /dev/null
+++ b/examples/01_minimal/src/pages/search.tsx
@@ -0,0 +1,13 @@
+import { SearchControls } from '../components/search-controls.js';
+import { tabCodec } from '../search-codecs.js';
+
+export default function SearchPage() {
+ return ;
+}
+
+export const getConfig = async () => {
+ return {
+ render: 'dynamic',
+ unstable_searchCodec: tabCodec,
+ } as const;
+};
diff --git a/examples/01_minimal/src/pages/user/[id].tsx b/examples/01_minimal/src/pages/user/[id].tsx
new file mode 100644
index 0000000..25372c7
--- /dev/null
+++ b/examples/01_minimal/src/pages/user/[id].tsx
@@ -0,0 +1,16 @@
+import { UserParams } from '../../components/user-params.js';
+
+export default function UserPage({ id }: { id: string }) {
+ return (
+ <>
+ User: {id}
+
+ >
+ );
+}
+
+export const getConfig = async () => {
+ return {
+ render: 'dynamic',
+ } as const;
+};
diff --git a/examples/01_minimal/src/search-codecs.ts b/examples/01_minimal/src/search-codecs.ts
new file mode 100644
index 0000000..bd0f822
--- /dev/null
+++ b/examples/01_minimal/src/search-codecs.ts
@@ -0,0 +1,9 @@
+import type { Unstable_SearchCodec } from 'waku/router';
+
+export const tabCodec: Unstable_SearchCodec<{ tab: string }> = {
+ id: 'tab',
+ parse: (query) => ({
+ tab: new URLSearchParams(query).get('tab') ?? 'home',
+ }),
+ serialize: (search) => new URLSearchParams({ tab: search.tab }).toString(),
+};
diff --git a/examples/02_pending/package.json b/examples/02_pending/package.json
index 4159702..bad2e0d 100644
--- a/examples/02_pending/package.json
+++ b/examples/02_pending/package.json
@@ -12,7 +12,7 @@
"react": "^19.2.3",
"react-dom": "^19.2.3",
"react-server-dom-webpack": "^19.2.3",
- "waku": "1.0.0-beta.6",
+ "waku": "1.0.0-beta.7",
"waku-navigation": "^0.0.1"
},
"devDependencies": {
diff --git a/package.json b/package.json
index f5b61eb..2b31c10 100644
--- a/package.json
+++ b/package.json
@@ -66,12 +66,12 @@
"typescript-eslint": "^8.61.1",
"vite": "^8.0.16",
"vitest": "^4.1.9",
- "waku": "1.0.0-beta.6",
+ "waku": "1.0.0-beta.7",
"waku-navigation": "link:"
},
"peerDependencies": {
"react": ">=19.0.0",
- "waku": ">=1.0.0-beta.6"
+ "waku": ">=1.0.0-beta.7"
},
"packageManager": "pnpm@11.3.0+sha512.2c403d6594527287672b1f7056343a1f7c3634036a67ffabfcc2b3d7595d843768f8787148d1b57cf7956c90606bbd192857c363af19e96d2d0ec9ec5741d215"
}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index c823c22..7b6daf3 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -84,8 +84,8 @@ importers:
specifier: ^4.1.9
version: 4.1.9(@types/node@25.9.3)(happy-dom@20.10.4)(vite@8.0.16(@types/node@25.9.3)(terser@5.48.0))
waku:
- specifier: 1.0.0-beta.6
- version: 1.0.0-beta.6(@types/node@25.9.3)(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(webpack@5.104.1))(react@19.2.7)(terser@5.48.0)
+ specifier: 1.0.0-beta.7
+ version: 1.0.0-beta.7(@types/node@25.9.3)(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(webpack@5.104.1))(react@19.2.7)(terser@5.48.0)
waku-navigation:
specifier: 'link:'
version: 'link:'
@@ -631,8 +631,8 @@ packages:
cpu: [x64]
os: [win32]
- '@vitejs/plugin-react@6.0.2':
- resolution: {integrity: sha512-DlSMqo4WhThw4vB8Mpn0Woe9J+Jfq1geJ61AKW0QEgLzGMNwtIMdxbDUzLxcun8W7NbJO0e2Jg/Nxm3cCSVzzg==}
+ '@vitejs/plugin-react@6.0.3':
+ resolution: {integrity: sha512-vmFvco5/QuC2f9Oj+wTk0+9XeDFkHxSamwZKYc7MxYwKICfvUvlMhqKI0VuICPltGqh1neqBKDvO4kes1ya8vg==}
engines: {node: ^20.19.0 || >=22.12.0}
peerDependencies:
'@rolldown/plugin-babel': ^0.1.7 || ^0.2.0
@@ -1355,8 +1355,8 @@ packages:
hermes-parser@0.25.1:
resolution: {integrity: sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==}
- hono@4.12.25:
- resolution: {integrity: sha512-2NFaIyNVgJmBs/ecmtGzlmluTFs5cHEWGTdu0t1HBwYzoGXOL5nUQBRMXsXWla5i4KkG//QMzVP88m1+I3fdAQ==}
+ hono@4.12.27:
+ resolution: {integrity: sha512-1yrb/+w6HWQJrUCLkJ2IF5jNIPvvFkblV5RNOYl6bV+OA6p9GLcMpHFFGTosSvHvcAUibuUukRqhlYI4z32C7Q==}
engines: {node: '>=16.9.0'}
ignore@5.3.2:
@@ -1964,8 +1964,8 @@ packages:
resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==}
engines: {node: '>=0.10.0'}
- srvx@0.11.16:
- resolution: {integrity: sha512-bp07zRuycfTY43IjAvvTFnmnJi8ikW0VFiHwOhhYcVW/L4xQ1XY4PAd4Nuum1rsA17C39zL7x+CDhrn5AL32Rw==}
+ srvx@0.11.17:
+ resolution: {integrity: sha512-43yM4luKfCJamyCMhrUeHUPOrf8TdZe7kN8s5zayZCH5OeprYqi49Aso5ZvHXR4aB+DHaRNO/diNFgZSMNG8Xw==}
engines: {node: '>=20.16.0'}
hasBin: true
@@ -2261,8 +2261,8 @@ packages:
jsdom:
optional: true
- waku@1.0.0-beta.6:
- resolution: {integrity: sha512-IM5RCLgq9pvImyAu1clH3zAGAU3h5acgMcXbzUjnOihfAKAX71j68+bcJS2T3Zr4Am3oeRFSq5i689kT+zf/3w==}
+ waku@1.0.0-beta.7:
+ resolution: {integrity: sha512-ZGTuyEX5LFytKfqa0xNRKKT603XN5pOz9n9GLUICKE0i4Z8/oYgq6soQizyArqZFLBN5NvieA3yE7XBzxfcc5Q==}
engines: {node: ^26.0.0 || ^24.0.0 || ^22.15.0}
hasBin: true
peerDependencies:
@@ -2518,9 +2518,9 @@ snapshots:
'@eslint/core': 0.17.0
levn: 0.4.1
- '@hono/node-server@2.0.6(hono@4.12.25)':
+ '@hono/node-server@2.0.6(hono@4.12.27)':
dependencies:
- hono: 4.12.25
+ hono: 4.12.27
'@humanfs/core@0.19.2':
dependencies:
@@ -2875,7 +2875,7 @@ snapshots:
'@unrs/resolver-binding-win32-x64-msvc@1.12.2':
optional: true
- '@vitejs/plugin-react@6.0.2(vite@8.0.16(@types/node@25.9.3)(terser@5.48.0))':
+ '@vitejs/plugin-react@6.0.3(vite@8.0.16(@types/node@25.9.3)(terser@5.48.0))':
dependencies:
'@rolldown/pluginutils': 1.0.1
vite: 8.0.16(@types/node@25.9.3)(terser@5.48.0)
@@ -2888,7 +2888,7 @@ snapshots:
magic-string: 0.30.21
react: 19.2.7
react-dom: 19.2.7(react@19.2.7)
- srvx: 0.11.16
+ srvx: 0.11.17
strip-literal: 3.1.0
turbo-stream: 3.2.0
vite: 8.0.16(@types/node@25.9.3)(terser@5.48.0)
@@ -3772,7 +3772,7 @@ snapshots:
dependencies:
hermes-estree: 0.25.1
- hono@4.12.25: {}
+ hono@4.12.27: {}
ignore@5.3.2: {}
@@ -4385,7 +4385,7 @@ snapshots:
source-map@0.6.1: {}
- srvx@0.11.16: {}
+ srvx@0.11.17: {}
stable-hash-x@0.2.0: {}
@@ -4657,13 +4657,13 @@ snapshots:
transitivePeerDependencies:
- msw
- waku@1.0.0-beta.6(@types/node@25.9.3)(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(webpack@5.104.1))(react@19.2.7)(terser@5.48.0):
+ waku@1.0.0-beta.7(@types/node@25.9.3)(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(webpack@5.104.1))(react@19.2.7)(terser@5.48.0):
dependencies:
- '@hono/node-server': 2.0.6(hono@4.12.25)
- '@vitejs/plugin-react': 6.0.2(vite@8.0.16(@types/node@25.9.3)(terser@5.48.0))
+ '@hono/node-server': 2.0.6(hono@4.12.27)
+ '@vitejs/plugin-react': 6.0.3(vite@8.0.16(@types/node@25.9.3)(terser@5.48.0))
'@vitejs/plugin-rsc': 0.5.27(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(webpack@5.104.1))(react@19.2.7)(vite@8.0.16(@types/node@25.9.3)(terser@5.48.0))
dotenv: 17.4.2
- hono: 4.12.25
+ hono: 4.12.27
magic-string: 0.30.21
picocolors: 1.1.1
react: 19.2.7
diff --git a/src/client.tsx b/src/client.tsx
index a37793f..40c3bab 100644
--- a/src/client.tsx
+++ b/src/client.tsx
@@ -24,34 +24,49 @@ import {
Root,
Slot,
unstable_prefetchRsc as prefetchRsc,
- unstable_registerFetchEnhancer as registerFetchEnhancer,
useElementsPromise_UNSTABLE as useElementsPromise,
useRefetch,
} from 'waku/minimal/client';
import {
Slice,
+ Unstable_SearchCodecsProvider,
unstable_addBase as addBase,
+ unstable_buildRouteHref as buildRouteHref,
unstable_encodeRoutePath as encodeRoutePath,
unstable_getErrorInfo as getErrorInfo,
+ unstable_matchRouteParams as matchRouteParams,
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,
+ unstable_useResolveSearchCodec as useResolveSearchCodec,
+ type Unstable_BuildRouteHrefTarget as BuildRouteHrefTarget,
type Unstable_RouteHref as RouteHref,
+ type Unstable_RouteParams as RouteParams,
+ type Unstable_RoutePath as RoutePath,
+ type Unstable_RouteSearch as RouteSearch,
} from 'waku/router/client';
-export { Slice };
+export { Slice, Unstable_SearchCodecsProvider };
type Elements = Record;
type Route = { path: string; query: string; hash: string };
const NOT_FOUND_PATH = '/404';
-// Mirror waku's unexported etag constants (router/isomorphic-utils/route-path).
-// STATIC_ETAG is a numeric sentinel a static slot carries instead of a string.
-const ETAG_ID_PREFIX = 'ETAG:';
-const STATIC_ETAG = 1;
+
+// Router-scoped prefetch cache mirroring waku/router's. A prefetched route tree
+// is held here keyed by (rscPath, query) and threaded back into refetch via
+// `unstable_prefetched`, since waku's `prefetchRsc` now returns the tree for the
+// caller to hold rather than populating a store that refetch reads implicitly.
+type PrefetchEntry = {
+ promise: ReturnType;
+ expireAt: number;
+};
+const PREFETCH_TTL = 60_000;
+const PREFETCH_LIMIT = 100;
+const prefetchCacheKey = (rscPath: string, query: string) =>
+ rscPath + '\0' + query;
type NavigationStatus = { pending?: boolean };
type TransitionFunction = () => void | Promise;
@@ -82,9 +97,12 @@ export const useNavigationStatus_UNSTABLE = (): NavigationStatus =>
useContext(NavigationStatusContext);
/** Props for {@link Link}. Mirrors `waku/router`'s ``. */
-export type LinkProps = {
- /** Destination, type-checked against your app's generated routes. */
- to: RouteHref;
+export type LinkProps = {
+ /**
+ * Destination, type-checked against your app's generated routes. Either an
+ * href string or, for a parameterized route, `{ to, params, hash }`.
+ */
+ to: RouteHref | BuildRouteHrefTarget;
children: ReactNode;
/**
* Whether to scroll on navigation. `false` keeps the current scroll
@@ -110,7 +128,7 @@ export type LinkProps = {
* prefetching, and per-link navigation status (read by descendants via
* {@link useNavigationStatus_UNSTABLE}). Mirrors `waku/router`'s ``.
*/
-export function Link({
+export function Link({
to,
children,
scroll,
@@ -119,10 +137,11 @@ export function Link({
unstable_startTransition,
ref: refProp,
...props
-}: LinkProps) {
+}: LinkProps) {
const base = (import.meta as { env?: { WAKU_CONFIG_BASE_PATH?: string } }).env
?.WAKU_CONFIG_BASE_PATH;
- const resolvedTo = base ? addBase(to, base) : to;
+ const href = typeof to === 'string' ? to : buildRouteHref(to);
+ const resolvedTo = base ? addBase(href, base) : href;
const ctx = useContext(RouterContext);
const { register } = useContext(NavStatusRegistryContext);
const [status, setOptimisticStatus] = useOptimistic({});
@@ -193,7 +212,18 @@ export function Link({
);
}
-type PushReplaceOptions = { scroll?: boolean };
+type NavigateOptions = { scroll?: boolean };
+type Navigate = {
+ (to: RouteHref, options?: NavigateOptions): Promise;
+ (
+ target: BuildRouteHrefTarget,
+ options?: NavigateOptions,
+ ): Promise;
+};
+type Prefetch = {
+ (to: RouteHref): void;
+ (target: BuildRouteHrefTarget): void;
+};
type RouteChangeEvents = {
on: (name: 'start' | 'complete', handler: (route: Route) => void) => void;
off: (name: 'start' | 'complete', handler: (route: Route) => void) => void;
@@ -211,16 +241,26 @@ export function useRouter() {
path: route.path,
query: route.query,
hash: route.hash,
- push: (to: string, options?: PushReplaceOptions) =>
- window.navigation.navigate(to, {
+ push: (async (
+ to: RouteHref | BuildRouteHrefTarget,
+ options?: NavigateOptions,
+ ) => {
+ const href = typeof to === 'string' ? to : buildRouteHref(to);
+ await window.navigation.navigate(href, {
history: 'push',
info: { scroll: options?.scroll },
- }).finished,
- replace: (to: string, options?: PushReplaceOptions) =>
- window.navigation.navigate(to, {
+ }).finished;
+ }) as Navigate,
+ replace: (async (
+ to: RouteHref | BuildRouteHrefTarget,
+ options?: NavigateOptions,
+ ) => {
+ const href = typeof to === 'string' ? to : buildRouteHref(to);
+ await window.navigation.navigate(href, {
history: 'replace',
info: { scroll: options?.scroll },
- }).finished,
+ }).finished;
+ }) as Navigate,
reload: () => window.navigation.reload().finished,
back: () => {
window.navigation.back();
@@ -228,14 +268,88 @@ export function useRouter() {
forward: () => {
window.navigation.forward();
},
- prefetch: (to: string) => {
- ctx?.prefetchRoute(parseRoute(new URL(to, window.location.href)));
- },
+ prefetch: ((to: RouteHref | BuildRouteHrefTarget) => {
+ const href = typeof to === 'string' ? to : buildRouteHref(to);
+ ctx?.prefetchRoute(parseRoute(new URL(href, window.location.href)));
+ }) as Prefetch,
unstable_events: (ctx?.routeChangeEvents ??
noopEvents) as RouteChangeEvents,
};
}
+/**
+ * Read the current route's params, typed from the `from` path, or `null` when
+ * the current path does not match it. Mirrors `waku/router`'s
+ * `useParams_UNSTABLE`.
+ */
+export function useParams_UNSTABLE({
+ from,
+}: {
+ from: Path;
+}): RouteParams | null {
+ const { path } = useRouter();
+ return useMemo(() => matchRouteParams(from, path), [from, path]);
+}
+
+/**
+ * Read the current route's typed `search`, parsed with the route's codec
+ * (provided via {@link Unstable_SearchCodecsProvider}), or `null` when the
+ * current path does not match `from` or the route has no codec. Mirrors
+ * `waku/router`'s `useSearch_UNSTABLE`.
+ */
+export function useSearch_UNSTABLE({
+ from,
+}: {
+ from: Path;
+}): RouteSearch | null {
+ const { path, query } = useRouter();
+ const resolveCodec = useResolveSearchCodec();
+ return useMemo(() => {
+ if (matchRouteParams(from, path) === null) return null;
+ const codec = resolveCodec(from);
+ return codec ? (codec.parse(query) as RouteSearch) : null;
+ }, [from, path, query, resolveCodec]);
+}
+
+type SetSearch = (
+ update:
+ | Partial>
+ | ((prev: RouteSearch) => Partial>),
+ options?: { history?: 'push' | 'replace'; scroll?: boolean },
+) => Promise;
+
+/**
+ * Returns a setter for the current route's typed `search`, serialized with the
+ * route's codec (provided via {@link Unstable_SearchCodecsProvider}). It
+ * navigates to the same path with the new query (push by default). A no-op when
+ * the current path does not match `from` or has no codec. Mirrors `waku/router`'s
+ * `useSetSearch_UNSTABLE`.
+ */
+export function useSetSearch_UNSTABLE({
+ from,
+}: {
+ from: Path;
+}): SetSearch {
+ const { path, query } = useRouter();
+ const resolveCodec = useResolveSearchCodec();
+ return useCallback>(
+ async (update, options) => {
+ if (matchRouteParams(from, path) === null) return;
+ const codec = resolveCodec(from);
+ if (!codec) return;
+ const prev = codec.parse(query) as RouteSearch;
+ const partial = typeof update === 'function' ? update(prev) : update;
+ const url = new URL(window.location.href);
+ url.search = codec.serialize({ ...prev, ...partial });
+ await window.navigation.navigate(url.href, {
+ history: options?.history ?? 'push',
+ info: { scroll: options?.scroll },
+ }).finished;
+ },
+ [from, path, query, resolveCodec],
+ );
+}
+
// Same origin + path + query (not pathname; fragment ignored). Malformed input
// returns false rather than throwing.
const routeMatchesHref = (href: string, route: Route): boolean => {
@@ -284,7 +398,6 @@ function InnerRouter({ fallbackRoute }: { fallbackRoute: Route }) {
}, []);
const registryRef = useRef(new Map());
const staticPathSetRef = useRef(new Set());
- const cachedEtagsRef = useRef>({});
// Stable instance: mutates this Set across renders.
const fetchingSlices = useMemo(() => new Set(), []);
useEffect(() => {
@@ -296,19 +409,6 @@ function InnerRouter({ fallbackRoute }: { fallbackRoute: Route }) {
if (routeData && elements[IS_STATIC_ID]) {
staticPathSetRef.current.add(routeData[0]);
}
- const etags: Record = {};
- for (const [key, value] of Object.entries(elements)) {
- // Keep the static sentinel; for string tags drop empty (clear signal)
- // and non-Latin1 (breaks the fetch header).
- if (
- key.startsWith(ETAG_ID_PREFIX) &&
- (value === STATIC_ETAG ||
- (typeof value === 'string' && /^[ -ÿ]+$/.test(value)))
- ) {
- etags[key.slice(ETAG_ID_PREFIX.length)] = value;
- }
- }
- cachedEtagsRef.current = etags;
},
() => {},
);
@@ -319,17 +419,6 @@ function InnerRouter({ fallbackRoute }: { fallbackRoute: Route }) {
registryRef.current.delete(id);
};
}, []);
- // Send our cached etags via X-Waku-Router-Skip so the server can skip
- // re-rendering slots whose etag still matches.
- useEffect(
- () =>
- registerFetchEnhancer((fetchFn) => (input, init) => {
- const headers = new Headers(init?.headers);
- headers.set(SKIP_HEADER, JSON.stringify(cachedEtagsRef.current));
- return fetchFn(input, { ...init, headers });
- }),
- [],
- );
// Waku's prefetch cache keys params by identity, so reuse one object per
// query string or prefetch entries get invalidated.
const rscParamsByQueryRef = useRef(new Map());
@@ -367,10 +456,25 @@ function InnerRouter({ fallbackRoute }: { fallbackRoute: Route }) {
}),
[routeChangeListeners],
);
+ const prefetchCacheRef = useRef(new Map());
const prefetchRoute = useCallback(
(next: Route) => {
if (staticPathSetRef.current.has(next.path)) return;
- prefetchRsc(encodeRoutePath(next.path), getRscParams(next.query));
+ const rscPath = encodeRoutePath(next.path);
+ const key = prefetchCacheKey(rscPath, next.query);
+ const cache = prefetchCacheRef.current;
+ const now = Date.now();
+ const existing = cache.get(key);
+ if (!existing || existing.expireAt <= now) {
+ if (cache.size >= PREFETCH_LIMIT) {
+ const oldest = cache.keys().next().value;
+ if (oldest !== undefined) cache.delete(oldest);
+ }
+ cache.set(key, {
+ promise: prefetchRsc(rscPath, getRscParams(next.query)),
+ expireAt: now + PREFETCH_TTL,
+ });
+ }
// When the build publishes it, __WAKU_ROUTER_PREFETCH__ yields the
// route's JS chunk ids to preload.
(
@@ -392,7 +496,7 @@ function InnerRouter({ fallbackRoute }: { fallbackRoute: Route }) {
if (!(import.meta as { hot?: unknown }).hot) return;
const refetchRoute = () => {
staticPathSetRef.current.clear();
- cachedEtagsRef.current = {};
+ prefetchCacheRef.current.clear();
refetch(encodeRoutePath(route.path), getRscParams(route.query));
};
const listeners = ((
@@ -469,9 +573,20 @@ function InnerRouter({ fallbackRoute }: { fallbackRoute: Route }) {
let targetRoute = nextRoute;
try {
if (!staticPathSetRef.current.has(nextRoute.path)) {
+ const rscPath = encodeRoutePath(nextRoute.path);
+ const cached = prefetchCacheRef.current.get(
+ prefetchCacheKey(rscPath, nextRoute.query),
+ );
+ const prefetched =
+ cached && cached.expireAt > Date.now()
+ ? cached.promise
+ : undefined;
await refetch(
- encodeRoutePath(nextRoute.path),
+ rscPath,
getRscParams(nextRoute.query),
+ prefetched
+ ? { unstable_prefetched: prefetched }
+ : undefined,
);
}
if (signal.aborted) return resolve();
diff --git a/src/index.ts b/src/index.ts
index acb25aa..ac69e32 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -2,7 +2,11 @@ export {
Link,
Router,
Slice,
+ Unstable_SearchCodecsProvider,
useNavigationStatus_UNSTABLE,
+ useParams_UNSTABLE,
useRouter,
+ useSearch_UNSTABLE,
+ useSetSearch_UNSTABLE,
} from './client.js';
export type { LinkProps } from './client.js';