diff --git a/.gitignore b/.gitignore index b536100..bf6367b 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ node_modules dist /test-results +/tmp diff --git a/.prettierignore b/.prettierignore index 29880e5..fd0421a 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1,3 +1,4 @@ /pnpm-lock.yaml dist /test-results +/tmp diff --git a/README.md b/README.md index 2011088..0f47875 100644 --- a/README.md +++ b/README.md @@ -69,12 +69,12 @@ function Nav() { // router.path -- current pathname (no leading base) // router.query -- query string (no leading '?') // router.hash -- '#section' or '' - // router.push(to, { scroll? }) - // router.replace(to, { scroll? }) + // router.push(to, { scroll? }) -- to: RouteHref | { to, params, hash } + // router.replace(to, { scroll? }) -- to: RouteHref | { to, params, hash } // router.reload() // router.back() // router.forward() - // router.prefetch(to) + // router.prefetch(to) -- to: RouteHref | { to, params, hash } // router.unstable_events.on('start' | 'complete', handler) // router.unstable_events.off('start' | 'complete', handler) } @@ -82,7 +82,14 @@ function Nav() { Notes: -- `push`/`replace` return `navigation.navigate(...).finished` (a promise that resolves when the navigation commits or rejects on abort). +- `push` / `replace` / `prefetch` take a type-safe `to` — either an href string (`RouteHref`, checked against your generated routes) or the object form `{ to, params, hash }` for a parameterized route, exactly like `waku/router`. The object form is built and URL-encoded with waku's `unstable_buildRouteHref`: + + ```tsx + push('/about'); // type-checked href + push({ to: '/user/[id]', params: { id: 'alice' } }); // -> /user/alice + ``` + +- `push`/`replace` resolve when the navigation commits (and reject on abort). - `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. - `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`. @@ -100,11 +107,15 @@ import { Link } from 'waku-navigation'; Slow ; + +// Object form for a parameterized route (built with unstable_buildRouteHref): +User alice; ``` ```ts -export type LinkProps = { - to: RouteHref; // type-safe, from your generated routes +export type LinkProps = { + // an href string or, for a parameterized route, { to, params, hash } + to: RouteHref | BuildRouteHrefTarget; scroll?: boolean; // false keeps scroll position; otherwise browser default unstable_prefetchOnEnter?: boolean; // prefetch on pointer enter unstable_prefetchOnView?: boolean; // prefetch when scrolled into view @@ -143,6 +154,47 @@ Internally each `` holds a `useOptimistic` state that the router flips ins > **You often don't need a custom spinner at all.** React already drives the browser's native spinner for transitions — during a navigation the Navigation API holds the request pending until the new route commits, and for non-navigation transitions React (≥19.2) fires a fake navigation via `onDefaultTransitionIndicator` to spin that same native indicator. Reach for `useNavigationStatus_UNSTABLE` when you want an _in-page_, per-link indicator on top of the browser-level one. `examples/02_pending` includes a transition (the home page's "Load batch" button) that relies on the native spinner alone. +### Typed params & search + +Mirrors `waku/router`'s typed hooks. `useParams_UNSTABLE` reads the current route's path params (decoded), typed from the `from` route: + +```tsx +'use client'; +import { useParams_UNSTABLE } from 'waku-navigation'; + +function UserId() { + const params = useParams_UNSTABLE({ from: '/user/[id]' }); + return {params?.id}; // null when the path doesn't match `from` +} +``` + +`useSearch_UNSTABLE` / `useSetSearch_UNSTABLE` read and write typed `?search`, parsed and serialized by the route's **search codec**. Provide codecs once via `Unstable_SearchCodecsProvider` (render it in a client component in your root layout — codecs hold functions, so they can't be passed from a server component), and declare the codec on the route's `getConfig` as `unstable_searchCodec`: + +```tsx +'use client'; +import { + Unstable_SearchCodecsProvider, + useSearch_UNSTABLE, + useSetSearch_UNSTABLE, +} from 'waku-navigation'; +import { tabCodec } from '../search-codecs.js'; + +// in your root layout's client wrapper: + + {children} +; + +function Tabs() { + const search = useSearch_UNSTABLE({ from: '/search' }); // { tab: string } | null + const setSearch = useSetSearch_UNSTABLE({ from: '/search' }); + return ( + + ); +} +``` + +`setSearch` accepts a partial or an updater of the current search and navigates (push by default, or `{ history: 'replace' }`) to the same path. Both are a no-op / `null` when the current path doesn't match `from` or the route has no codec. The codec contract (`Unstable_SearchCodec`) and the typed wiring come from `waku/router`; `examples/01_minimal` has a full `/search` example. + ### `` ```tsx @@ -169,7 +221,7 @@ import { Slice } from 'waku-navigation'; ### `` (drop-in) or plain `` -`` is a drop-in — same import path swap, same props (`to`, `scroll`, `unstable_prefetchOnEnter`, `unstable_prefetchOnView`, `unstable_startTransition`, `ref`, and any `` attributes): +`` is a drop-in — same import path swap, same props (`to` as an href string or `{ to, params, hash }`, `scroll`, `unstable_prefetchOnEnter`, `unstable_prefetchOnView`, `unstable_startTransition`, `ref`, and any `` attributes): ```diff - import { Link } from 'waku/router/client'; @@ -177,6 +229,8 @@ import { Slice } from 'waku-navigation'; About ``` +The one prop not carried over is `unstable_instant` (waku's instant navigation): `waku-navigation` doesn't implement it, so it's absent from `` and `useRouter().push`/`replace` options. + Or drop `` entirely where you don't need type-safety, prefetching, or status — a plain `` navigates client-side on its own: ```diff @@ -206,6 +260,15 @@ Unchanged — a descendant reads the enclosing ``'s status via the no-arg Same import path change as `useRouter`. All props (`id`, `lazy`, `fallback`, children) are unchanged. +### Typed params & search (drop-in) + +`useParams_UNSTABLE`, `useSearch_UNSTABLE`, `useSetSearch_UNSTABLE`, and `Unstable_SearchCodecsProvider` are the same import-path swap, with the same signatures as `waku/router/client`: + +```diff +- import { useParams_UNSTABLE, useSearch_UNSTABLE } from 'waku/router/client'; ++ import { useParams_UNSTABLE, useSearch_UNSTABLE } from 'waku-navigation'; +``` + ### `ErrorBoundary` → your own `waku-navigation` does not ship an error boundary; any standard React error boundary works. Place it around ``: @@ -248,7 +311,6 @@ import { unstable_HAS404_ID, unstable_IS_STATIC_ID, unstable_ROUTE_ID, - unstable_SKIP_HEADER, unstable_encodeRoutePath, unstable_encodeSliceId, unstable_getRouteSlotId, @@ -277,8 +339,8 @@ These are all handled inside the navigate-event listener so apps usually don't n - **React's default transition indicator** — React (≥19.2) fires a fake same-URL navigation tagged `info: 'react-transition'` for every transition, intercepting it to show the browser's native spinner. The router skips these (they aren't route changes), so an unrelated `useTransition` anywhere in your app never triggers a refetch. - **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. - **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). -- **`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. -- **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. +- **`X-Waku-Etags` header** — every refetch sends the etags of elements already in the store so the server can skip re-rendering shared layouts/slices whose etag still matches. Waku's `useRefetch` (from `waku/minimal/client`) tracks these etags and sets the header itself, so the router gets this for free. +- **HMR cache invalidation** — when Waku's dev runtime fires `globalThis.__WAKU_RSC_RELOAD_LISTENERS__` (Vite HMR update), the router clears `staticPathSet` and refetches the current route (Waku's `minimal` client clears its own etag cache via the reload listener it registers first). Guarded by `import.meta.hot` so it's stripped in production. --- diff --git a/e2e/01_minimal.spec.ts b/e2e/01_minimal.spec.ts index 1a5d718..1ee1b22 100644 --- a/e2e/01_minimal.spec.ts +++ b/e2e/01_minimal.spec.ts @@ -210,14 +210,14 @@ test('static routes skip the refetch on revisit', async ({ page }) => { expect(rscRequests.length).toBe(before); }); -test('refetch sends X-Waku-Router-Skip mapping cached element ids to etags', async ({ +test('refetch sends X-Waku-Etags mapping cached element ids to etags', async ({ page, }) => { - const skipHeaders: string[] = []; + const etagHeaders: string[] = []; page.on('request', (req) => { if (req.url().includes('/RSC/')) { - const h = req.headers()['x-waku-router-skip']; - if (h !== undefined) skipHeaders.push(h); + const h = req.headers()['x-waku-etags']; + if (h !== undefined) etagHeaders.push(h); } }); await page.goto('/'); @@ -226,15 +226,15 @@ test('refetch sends X-Waku-Router-Skip mapping cached element ids to etags', asy await expect(page.locator('h1')).toHaveText('Welcome to the About Page'); await page.waitForTimeout(100); - expect(skipHeaders.length).toBeGreaterThan(0); + expect(etagHeaders.length).toBeGreaterThan(0); // The header should be a JSON object mapping slot ids to etags; not just // "{}" (we had initial elements from SSR before this navigation). - const parsed: unknown = JSON.parse(skipHeaders[0]!); + const parsed: unknown = JSON.parse(etagHeaders[0]!); expect(Array.isArray(parsed)).toBe(false); expect(typeof parsed).toBe('object'); const entries = Object.entries(parsed as Record); 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';