Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,4 @@
node_modules
dist
/test-results
/tmp
1 change: 1 addition & 0 deletions .prettierignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
/pnpm-lock.yaml
dist
/test-results
/tmp
82 changes: 72 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,20 +69,27 @@ 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)
}
```

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`.

Expand All @@ -100,11 +107,15 @@ import { Link } from 'waku-navigation';
<Link to="/slow">
Slow <NavSpinner />
</Link>;

// Object form for a parameterized route (built with unstable_buildRouteHref):
<Link to={{ to: '/user/[id]', params: { id: 'alice' } }}>User alice</Link>;
```

```ts
export type LinkProps = {
to: RouteHref; // type-safe, from your generated routes
export type LinkProps<Path extends RoutePath> = {
// an href string or, for a parameterized route, { to, params, hash }
to: RouteHref | BuildRouteHrefTarget<Path>;
scroll?: boolean; // false keeps scroll position; otherwise browser default
unstable_prefetchOnEnter?: boolean; // prefetch on pointer enter
unstable_prefetchOnView?: boolean; // prefetch when scrolled into view
Expand Down Expand Up @@ -143,6 +154,47 @@ Internally each `<Link>` 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 <span>{params?.id}</span>; // 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:
<Unstable_SearchCodecsProvider searchCodecs={[tabCodec]}>
{children}
</Unstable_SearchCodecsProvider>;

function Tabs() {
const search = useSearch_UNSTABLE({ from: '/search' }); // { tab: string } | null
const setSearch = useSetSearch_UNSTABLE({ from: '/search' });
return (
<button onClick={() => setSearch({ tab: 'faq' })}>{search?.tab}</button>
);
}
```

`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.

### `<Slice>`

```tsx
Expand All @@ -169,14 +221,16 @@ import { Slice } from 'waku-navigation';

### `<Link>` (drop-in) or plain `<a>`

`<Link>` is a drop-in — same import path swap, same props (`to`, `scroll`, `unstable_prefetchOnEnter`, `unstable_prefetchOnView`, `unstable_startTransition`, `ref`, and any `<a>` attributes):
`<Link>` 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 `<a>` attributes):

```diff
- import { Link } from 'waku/router/client';
+ import { Link } from 'waku-navigation';
<Link to="/about">About</Link>
```

The one prop not carried over is `unstable_instant` (waku's instant navigation): `waku-navigation` doesn't implement it, so it's absent from `<Link>` and `useRouter().push`/`replace` options.

Or drop `<Link>` entirely where you don't need type-safety, prefetching, or status — a plain `<a>` navigates client-side on its own:

```diff
Expand Down Expand Up @@ -206,6 +260,15 @@ Unchanged — a descendant reads the enclosing `<Link>`'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 `<Router>`:
Expand Down Expand Up @@ -248,7 +311,6 @@ import {
unstable_HAS404_ID,
unstable_IS_STATIC_ID,
unstable_ROUTE_ID,
unstable_SKIP_HEADER,
unstable_encodeRoutePath,
unstable_encodeSliceId,
unstable_getRouteSlotId,
Expand Down Expand Up @@ -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.

---

Expand Down
82 changes: 75 additions & 7 deletions e2e/01_minimal.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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('/');
Expand All @@ -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<string, unknown>);
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);
Expand Down Expand Up @@ -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('<Link> 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');
});
2 changes: 1 addition & 1 deletion examples/01_minimal/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
13 changes: 13 additions & 0 deletions examples/01_minimal/src/components/search-codecs-provider.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Unstable_SearchCodecsProvider searchCodecs={[tabCodec]}>
{children}
</Unstable_SearchCodecsProvider>
);
}
28 changes: 28 additions & 0 deletions examples/01_minimal/src/components/search-controls.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<>
<h1>Search</h1>
<p data-testid="search-tab">tab: {search?.tab ?? '(none)'}</p>
<button
type="button"
data-testid="set-tab-faq"
onClick={() => setSearch({ tab: 'faq' })}
>
Show FAQ
</button>
<button
type="button"
data-testid="set-tab-updater"
onClick={() => setSearch((prev) => ({ tab: `${prev.tab}-x` }))}
>
Append -x
</button>
</>
);
}
31 changes: 31 additions & 0 deletions examples/01_minimal/src/components/user-nav.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
'use client';

import { Link, useRouter } from 'waku-navigation';

export function UserNav() {
const { push } = useRouter();
return (
<>
<Link
to={{ to: '/user/[id]', params: { id: 'alice' } }}
data-testid="user-link"
>
User alice
</Link>
<button
type="button"
data-testid="user-push"
onClick={() => push({ to: '/user/[id]', params: { id: 'bob' } })}
>
Push user bob
</button>
<button
type="button"
data-testid="user-push-encoded"
onClick={() => push({ to: '/user/[id]', params: { id: 'a b' } })}
>
Push user a b
</button>
</>
);
}
10 changes: 10 additions & 0 deletions examples/01_minimal/src/components/user-params.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
'use client';

import { useParams_UNSTABLE } from 'waku-navigation';

export function UserParams() {
const params = useParams_UNSTABLE({ from: '/user/[id]' });
return (
<p data-testid="user-params-id">params.id: {params?.id ?? '(none)'}</p>
);
}
8 changes: 7 additions & 1 deletion examples/01_minimal/src/pages.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof File_SlicesClock_getConfig>)
| ({ path: '/about' } & GetConfigResponse<typeof File_About_getConfig>)
| ({ path: '/' } & GetConfigResponse<typeof File_Index_getConfig>);
| ({ path: '/' } & GetConfigResponse<typeof File_Index_getConfig>)
| ({ path: '/search' } & GetConfigResponse<typeof File_Search_getConfig>)
| ({ path: '/user/[id]' } & GetConfigResponse<typeof File_UserId_getConfig>);

// prettier-ignore
type Layout =
Expand Down
Loading
Loading