@vielzeug/codex 3.1.1 → 26.10.0
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/data/catalog.json +124 -36
- package/data/llms-full.txt +2335 -679
- package/data/llms.txt +5 -3
- package/data/manifest.json +1 -1
- package/data/packages/arsenal.json +30 -6
- package/data/packages/assay.json +11 -19
- package/data/packages/clockwork.json +30 -27
- package/data/packages/codex.json +4 -4
- package/data/packages/conduit.json +33 -34
- package/data/packages/courier.json +15 -14
- package/data/packages/dnd.json +2 -2
- package/data/packages/familiar.json +24 -3
- package/data/packages/flux.json +9 -6
- package/data/packages/focus.json +16 -6
- package/data/packages/gesture.json +10 -4
- package/data/packages/herald.json +7 -8
- package/data/packages/illusionist.json +1 -1
- package/data/packages/keymap.json +11 -7
- package/data/packages/ledger.json +25 -25
- package/data/packages/lingua.json +2 -2
- package/data/packages/mesh.json +29 -31
- package/data/packages/necromancer.json +3 -2
- package/data/packages/orbit.json +4 -3
- package/data/packages/ore.json +4 -3
- package/data/packages/postmaster.json +24 -24
- package/data/packages/pulse.json +28 -28
- package/data/packages/refine.json +6 -4
- package/data/packages/ripple.json +2 -2
- package/data/packages/sandbox.json +2 -2
- package/data/packages/scout.json +13 -7
- package/data/packages/scroll.json +7 -7
- package/data/packages/sentinel.json +26 -15
- package/data/packages/sigil.json +3 -3
- package/data/packages/spell.json +25 -30
- package/data/packages/tandem.json +29 -0
- package/data/packages/tavern.json +23 -0
- package/data/packages/tempo.json +44 -42
- package/data/packages/vault.json +9 -19
- package/data/packages/ward.json +26 -29
- package/data/packages/wayfinder.json +20 -13
- package/data/refine.json +746 -65
- package/data/search.json +198 -163
- package/dist/index.d.ts +2 -3
- package/dist/index.js +2 -3
- package/dist/index.js.map +1 -1
- package/package.json +5 -12
- package/skills/vielzeug/SKILL.md +2 -0
- package/dist/advanced.d.ts +0 -12
- package/dist/advanced.js +0 -13
- package/dist/advanced.js.map +0 -1
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
{
|
|
2
|
-
"apiSource": "export {\n WayfinderApiError,\n WayfinderDisposedError,\n WayfinderError,\n WayfinderRedirectLoopError,\n WayfinderRouteError,\n} from './errors';\nexport {
|
|
2
|
+
"apiSource": "export {\n WayfinderApiError,\n WayfinderConfigError,\n WayfinderDisposedError,\n WayfinderError,\n WayfinderRedirectLoopError,\n WayfinderRouteError,\n} from './errors';\nexport {\n createBrowserHistory,\n createHashHistory,\n createHistoryForBase,\n createMemoryHistory,\n type HashHistoryOptions,\n} from './history';\nexport { redirectTo } from './middleware';\nexport type { PhaseMirror, PhaseMirrorOptions } from './phase-mirror';\nexport { createPhaseMirror } from './phase-mirror';\nexport type { RouteSignals } from './route-signals';\nexport { createRouteSignals } from './route-signals';\nexport type { Router } from './router';\nexport { createRouter } from './router';\nexport type {\n BeforeLeaveBlocker,\n BeforeLeaveOptions,\n CoerceSearchFn,\n DataContext,\n DataFn,\n HistoryDriver,\n IsActiveOptions,\n MaybePromise,\n Middleware,\n NamedNavigationTarget,\n NavigateOptions,\n NavigationDestination,\n NavigationStatus,\n NavigationTarget,\n PathParams,\n QueryParams,\n RawNavigationTarget,\n ResolvedQueryParams,\n ResolvedQueryValue,\n RouteContext,\n RouteDefinition,\n RouteLocation,\n RouteMatch,\n RouteMatchBranch,\n RouteMiddleware,\n RouteName,\n RouteParams,\n RoutePathByName,\n RouterErrorContext,\n RouterOptions,\n RouteState,\n RouteTable,\n RouteViewMap,\n RouteViewName,\n RouteViewRegistry,\n ScrollDecision,\n ScrollPosition,\n Unsubscribe,\n UntypedNamedNavigationTarget,\n} from './types';\n",
|
|
3
3
|
"docs": {
|
|
4
|
-
"index": "---\ntitle: Wayfinder — Client-side router for TypeScript\ndescription: Framework-agnostic client-side router with typed params, async data loading, middleware, leave guards, and cancellation.\npackage: wayfinder\ncategory: routing\nkeywords: [router, client-side, middleware, guards, navigation, history, spa, typed-routes]\nrelated: [ripple, ward, herald]\nexports: [createRouter, createBrowserHistory, createHashHistory, createMemoryHistory, redirectTo, WayfinderError, WayfinderApiError, WayfinderDisposedError, WayfinderRedirectLoopError, WayfinderRouteError]\nenvironments: [browser, node, ssr, deno]\n---\n\n<!-- markdownlint-disable MD025 MD033 MD060 -->\n\n<PackageHero package=\"wayfinder\" />\n\n## Why Wayfinder?\n\nManaging navigation by hand means scattered `popstate` listeners, duplicated path checks, and no shared abstraction for loading data or blocking navigation. Wayfinder moves all of that into one declarative table.\n\n```ts\n// Before — manual navigation with popstate\nwindow.addEventListener('popstate', () => {\n const path = window.location.pathname;\n if (path === '/') renderHome();\n else if (path.startsWith('/dashboard')) renderDashboard();\n else renderNotFound();\n});\ndocument.querySelectorAll('a[data-route]').forEach((a) => {\n a.addEventListener('click', (e) => {\n e.preventDefault();\n history.pushState({}, '', (e.currentTarget as HTMLAnchorElement).href);\n dispatchEvent(new PopStateEvent('popstate'));\n });\n});\n\n// After — with Wayfinder\nimport { createRouter } from '@vielzeug/wayfinder';\n\nconst router = createRouter({\n routes: {\n home: { path: '/' },\n dashboard: { path: '/dashboard' },\n },\n notFound: { data: () => ({ message: 'Page not found' }) },\n});\n\nrouter.subscribe((state) => {\n const leaf = state.matches.at(-1);\n render(leaf?.data);\n});\n```\n\n<div class=\"decision-callout\">\n\n**Use Wayfinder when** you need named navigation, route-level data loading with cancellation, middleware, or leave guards in a framework-agnostic setup.\n\n**Consider a framework's built-in router when** you are deep in a single framework ecosystem (React Router, Vue Router) and want first-class component binding with no adapter layer.\n\n</div>\n\n| Feature | Wayfinder | page.js | Navigo |\n| ------------------------------------ | ----------------------------------------------- | ------------------------------------------ | ------------------------------------------ |\n| Bundle size | <PackageInfo package=\"wayfinder\" type=\"size\" /> | ~1 kB | ~5 kB |\n| History mode | <ore-icon name=\"check\" size=\"16\"></ore-icon> | <ore-icon name=\"check\" size=\"16\"></ore-icon> | <ore-icon name=\"check\" size=\"16\"></ore-icon> |\n| Memory history (tests / non-browser) | <ore-icon name=\"check\" size=\"16\"></ore-icon> | <ore-icon name=\"x\" size=\"16\"></ore-icon> | <ore-icon name=\"x\" size=\"16\"></ore-icon> |\n| Typed path params | <ore-icon name=\"check\" size=\"16\"></ore-icon> | <ore-icon name=\"x\" size=\"16\"></ore-icon> | <ore-icon name=\"x\" size=\"16\"></ore-icon> |\n| Named navigation | <ore-icon name=\"check\" size=\"16\"></ore-icon> | <ore-icon name=\"x\" size=\"16\"></ore-icon> | Partial |\n| Middleware | <ore-icon name=\"check\" size=\"16\"></ore-icon> | <ore-icon name=\"check\" size=\"16\"></ore-icon> | <ore-icon name=\"check\" size=\"16\"></ore-icon> |\n| Data loaders with AbortSignal | <ore-icon name=\"check\" size=\"16\"></ore-icon> | <ore-icon name=\"x\" size=\"16\"></ore-icon> | <ore-icon name=\"x\" size=\"16\"></ore-icon> |\n| Declarative redirects | <ore-icon name=\"check\" size=\"16\"></ore-icon> | <ore-icon name=\"x\" size=\"16\"></ore-icon> | <ore-icon name=\"x\" size=\"16\"></ore-icon> |\n| Search param validation | <ore-icon name=\"check\" size=\"16\"></ore-icon> | <ore-icon name=\"x\" size=\"16\"></ore-icon> | <ore-icon name=\"x\" size=\"16\"></ore-icon> |\n| Error in state | <ore-icon name=\"check\" size=\"16\"></ore-icon> | <ore-icon name=\"x\" size=\"16\"></ore-icon> | <ore-icon name=\"x\" size=\"16\"></ore-icon> |\n| History state in context | <ore-icon name=\"check\" size=\"16\"></ore-icon> | <ore-icon name=\"x\" size=\"16\"></ore-icon> | <ore-icon name=\"x\" size=\"16\"></ore-icon> |\n| Leave guards | <ore-icon name=\"check\" size=\"16\"></ore-icon> | <ore-icon name=\"x\" size=\"16\"></ore-icon> | <ore-icon name=\"x\" size=\"16\"></ore-icon> |\n| Zero dependencies | <ore-icon name=\"check\" size=\"16\"></ore-icon> | <ore-icon name=\"check\" size=\"16\"></ore-icon> | <ore-icon name=\"check\" size=\"16\"></ore-icon> |\n\n## Installation\n\n::: code-group\n\n```sh [pnpm]\npnpm add @vielzeug/wayfinder\n```\n\n```sh [npm]\nnpm install @vielzeug/wayfinder\n```\n\n```sh [yarn]\nyarn add @vielzeug/wayfinder\n```\n\n:::\n\n## Quick Start\n\nCreate a memory-backed router, wait for initial routing, then navigate by name.\n\n```ts\nimport { createMemoryHistory, createRouter } from '@vielzeug/wayfinder';\n\nconst router = createRouter({\n history: createMemoryHistory('/'),\n routes: {\n home: { path: '/' },\n settings: { path: '/settings' },\n },\n});\n\nawait router.ready;\nawait router.navigate({ name: 'settings' });\nconsole.log(router.getSnapshot().location.pathname); // /settings\nrouter.dispose();\n```\n\n## Features\n\n<div class=\"features-grid\">\n\n- `createRouter()` — Compiles named, nested route tables.\n- `navigate()` — Commits route changes after middleware reaches its terminal stage.\n- `ready` — Signals that initial routing has settled.\n- `data()` — Receives cancellation through `AbortSignal` for in-flight loaders.\n- `beforeLeave()` — Blocks route exits before history changes.\n- `match()` / `load()` — Inspect routes synchronously or load detached route data without navigation.\n- `preload()` — Warms and reuses loader results for the next matching client navigation.\n- `createViewRegistry()` — Resolves exhaustive typed route views with an explicit not-found fallback.\n- `scroll` / `viewTransition` — Coordinate browser effects at the navigation commit boundary.\n- `createHashHistory()` — Runs browser routes on static hosts that cannot rewrite deep links.\n- `createMemoryHistory()` — Runs routers in tests and non-browser environments.\n- `subscribe()` — Reactive subscription to navigation state changes.\n\n</div>\n\n## Documentation\n\n<div class=\"doc-links\">\n\n- [Usage Guide](./usage.md)\n- [API Reference](./api.md)\n- [Examples](./examples.md)\n- [Migration Guide](./migration.md)\n\n</div>\n\n## See Also\n\n<div class=\"see-also\">\n\n- [Ripple](/ripple/) — reactive signals; sync router state to a signal for framework-agnostic reactivity\n- [Ward](/ward/) — permission guards; use inside Wayfinder middleware to protect routes\n- [Herald](/herald/) — event bus; dispatch route-change events to decouple navigation side effects\n\n</div>\n\n<!-- markdownlint-enable MD025 MD033 MD060 -->\n",
|
|
5
|
-
"api": "---\ntitle: Wayfinder — API Reference\ndescription: Complete API reference for Wayfinder.\n---\n\n[[toc]]\n\n## API Overview\n\n| Symbol | Purpose | Execution mode | Common gotcha |\n| --------------------------------------- | ---------------------------------------------------------- | -------------------- | ------------------------------------------------------------------------------------------------------------ |\n| `createRouter(options)` | Create a router from a route table | Sync | Initial navigation starts asynchronously in the constructor |\n| `createBrowserHistory()` | Create the default browser history driver | Sync | Requires server-side SPA rewrites |\n| `createHashHistory(options?)` | Create a static-host-safe hash history driver | Sync | Pass the same `base` to the history and router |\n| `createMemoryHistory(initialPath?)` | Create an in-memory history driver | Sync | — |\n| `redirectTo(target, options?)` | Build redirect middleware | Sync (returns fn) | Does not call `next()` — always short-circuits the chain |\n| `router.navigate(target, options?)` | Navigate to a named route, raw path object, or string path | Async | No-op when destination equals current URL unless `force: true` |\n| `router.getSnapshot()` | Return the current immutable route state | Sync | Does not subscribe — call `subscribe()` to react to changes |\n| `router.subscribe(listener)` | Register a listener for state changes | Sync (returns unsub) | Listener is **not** called immediately with current state |\n| `router.url(name, params?, query?)` | Build a URL for a named route | Sync | Throws if the route name is unknown |\n| `router.isActive(name, options?)` | Check if a named route matches the current URL | Sync | Compares against the current snapshot pathname, not `history.location` directly |\n| `router.match(pathname)` | Inspect a pathname as a branch without side effects | Sync | Returns `null` for redirect routes |\n| `router.load(url, options?)` | Load a detached URL state for SSR or prerendering | Async | Middleware is not executed and results are not cached |\n| `router.preload(target)` | Warm loaders for the next matching client navigation | Async | Params and query values are part of the cache key |\n| `router.createViewRegistry(views, options?)` | Create an exhaustive route-to-view resolver | Sync | Pass `options.notFound` to render the configured fallback |\n| `router.ready` | Await the initial navigation | Async | Rejects when initial loading fails |\n| `router.waitFor(name)` | Wait for the router to settle on a named route | Async | Rejects immediately if `status === 'error'`; rejects with `WayfinderDisposedError` if disposed while pending |\n| `router.beforeLeave(blocker, options?)` | Register a global leave guard | Sync (returns unsub) | Scoped to specific routes via `options.routes` |\n| `router.dispose()` | Remove listeners and shut down the router | Sync | Idempotent — safe to call multiple times |\n\n## Package Entry Points\n\n| Import | Purpose |\n| --------------------- | ---------------------- |\n| `@vielzeug/wayfinder` | Main exports and types |\n\n## `createRouter(options)`\n\n```ts\nimport { createRouter } from '@vielzeug/wayfinder';\n\nconst router = createRouter({\n base: '/app',\n routes: {\n home: { path: '/' },\n dashboard: {\n path: '/dashboard',\n children: {\n index: { index: true },\n settings: { path: 'settings', data: () => fetchSettings() },\n },\n },\n },\n notFound: { data: () => ({ message: 'Page not found' }) },\n});\n```\n\n| Option | Type | Default | Description |\n| -------------- | ---------------------------------------------- | ------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------- |\n| `base` | `string` | `'/'` | Base path prefix for all routes |\n| `coerceSearch` | `CoerceSearchFn` | — | Global search-param coercion applied to every route that does not define its own `coerceSearch`. Throwing falls back to raw strings and is reported via `onError`. |\n| `history` | `HistoryDriver` | `createBrowserHistory()` | History source used for reading locations and writing navigations |\n| `middleware` | `Middleware[]` | `[]` | Global middleware prepended to every route |\n| `notFound` | `{ data?, middleware? }` | — | Synthetic route used when no path matches. Global middleware runs first, then `notFound.middleware` and `notFound.data`. `ctx.pathname` is the unmatched path. |\n| `onError` | `(error, context: RouterErrorContext) => void` | — | Optional sink for non-awaited/background router errors |\n| `routes` | `RouteTable` | required | Declarative route table. Object key order defines match precedence. |\n| `scroll` | `(to, from) => ScrollDecision` | — | Apply scroll behavior after successful navigation |\n| `viewTransition` | `boolean` | `false` | Wrap navigation commits in the browser View Transition API when available |\n\n**Returns:** `Router`\n\n## Route Table\n\nDefine routes as a plain object where keys become route names. TypeScript will infer route params from literal `path` strings.\n\n```ts\nconst routes = {\n home: { path: '/' },\n userDetail: { path: '/users/:id' },\n files: { path: '/files/:rest*' },\n};\n```\n\nNested routes are declared with `children`, and child names become compound names with dot notation.\n\n## Route Definition\n\n```ts\nconst routes = {\n home: { path: '/' },\n dashboard: {\n path: '/dashboard',\n middleware: [requireAuth],\n children: {\n index: { index: true },\n settings: {\n path: 'settings',\n data: async () => fetchSettings(),\n },\n },\n },\n userDetail: {\n path: '/users/:id',\n data: async ({ params }) => fetchUser(params.id),\n onError: (error) => ({ error, user: null }),\n },\n};\n```\n\nEach route definition supports these fields:\n\n| Field | Type | Description |\n| -------------- | -------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ |\n| `path` | `string` | Wayfinder pattern. Supports static paths, `:param`, `:param*`, and `*`. Child paths are relative unless they start with `/`. |\n| `children` | `Record<string, RouteDefinition>` | Nested child routes. Child names are appended to the parent route name. |\n| `index` | `boolean` | Default child route that inherits the parent path. |\n| `data` | `DataFn` | Data loader. Runs after middleware; result available as `match.data`. |\n| `middleware` | `Middleware[]` | Optional route-specific middleware |\n| `onError` | `(error, context: DataContext) => MaybePromise<unknown>` | Per-route error boundary for data loader failures. Return value becomes `match.data` for degraded rendering. |\n| `redirect` | `NavigationTarget` | Declarative redirect. Resolved before middleware runs; uses `replaceState` so the original URL is never added to history. |\n| `coerceSearch` | `(raw: QueryParams) => ResolvedQueryParams` | Coerce raw URL string values into typed values. Return value replaces `ctx.query`. Throwing leaves the parsed query unchanged. |\n\n## `createBrowserHistory()`\n\n```ts\nimport { createBrowserHistory } from '@vielzeug/wayfinder';\n\nconst history = createBrowserHistory();\n```\n\nCreate the default `HistoryDriver` backed by the browser History API.\n\n## `createHashHistory(options?)`\n\n```ts\nimport { createHashHistory, createRouter } from '@vielzeug/wayfinder';\n\nconst base = '/my-app/';\nconst router = createRouter({\n base,\n history: createHashHistory({ base }),\n routes,\n});\n```\n\nCreate a browser history driver that stores the route after `#`. Use it for static hosts that cannot rewrite deep links to the SPA entry file. `push()`, `replace()`, query strings, route hashes, state, and back navigation follow the `HistoryDriver` contract.\n\n## `createMemoryHistory(initialPath?)`\n\n```ts\nimport { createMemoryHistory } from '@vielzeug/wayfinder';\n\n// Tests\nconst router = createRouter({\n history: createMemoryHistory('/dashboard'),\n routes,\n});\n\n// Controlled non-browser runtime\nconst router = createRouter({\n history: createMemoryHistory('/request-path'),\n routes,\n});\n```\n\nCreate an in-memory `HistoryDriver`. No browser history globals required — suitable for unit tests and controlled non-browser runtimes. The optional `initialPath` defaults to `'/'`.\n\n## `Router`\n\n### Lifecycle\n\n#### `router.dispose()`\n\nRemove listeners, clear subscribers, and reject future router interaction. Idempotent — safe to call multiple times.\n\n**Returns:** `void`\n\n**Throws:** Never.\n\n---\n\n#### `router.disposed`\n\n`boolean` — `true` after `dispose()` has been called.\n\n---\n\n#### `router.disposalSignal`\n\n`AbortSignal` that is aborted (with a `WayfinderDisposedError` reason) when the router is disposed. Use this to tie external resource lifetimes to the router's lifetime.\n\n```ts\nsource.on('update', syncRouteParams, { signal: router.disposalSignal });\n```\n\n---\n\n### Navigation\n\n#### `router.navigate(target, options?)`\n\n```ts\nawait router.navigate({ name: 'userDetail', params: { id: '42' } });\nawait router.navigate({ name: 'userDetail', params: { id: '42' } }, { replace: true });\nawait router.navigate({ name: 'search', query: { q: 'wayfinder' }, hash: 'results' });\n```\n\n| Option | Type | Default | Description |\n| --------- | --------- | ------- | ------------------------------------------------------- |\n| `replace` | `boolean` | `false` | Use `replaceState` instead of `pushState` |\n| `state` | `unknown` | — | History state payload |\n| `force` | `boolean` | `false` | Re-run even when the destination URL is already current |\n| `viewTransition` | `boolean` | router default | Override view-transition behavior for this navigation |\n\n**Returns:** `Promise<void>`\n\nHistory is written only after middleware reaches the terminal stage. Returning from middleware without `next()` cancels the programmatic navigation without changing history or the route snapshot.\n\nNamed routes stay the primary API, but `navigate()` also accepts raw path objects or a plain string:\n\n```ts\nawait router.navigate({ path: '/marketing?utm_source=campaign' });\nawait router.navigate({ path: '/checkout#payment' }, { replace: true });\n\n// Plain string — most concise for direct paths\nawait router.navigate('/about');\nawait router.navigate('/search?q=hello');\n```\n\n---\n\n### Route Helpers\n\n#### `router.url(name, params?, query?)`\n\n```ts\nrouter.url('userDetail', { id: '42' });\nrouter.url('userDetail', { id: '42' }, { tab: 'profile' });\n```\n\nBuild a base-aware URL for a named route.\n\n**Returns:** `string`\n\n#### `router.isActive(name, options?)`\n\n```ts\nrouter.isActive('userDetail');\nrouter.isActive('users');\nrouter.isActive('users', { exact: true });\n```\n\nCheck whether the current pathname matches a named route exactly or by prefix.\n\n**Returns:** `boolean`\n\n#### `router.match(pathname)`\n\n```ts\nrouter.match('/app/dashboard/settings');\n// => [\n// { name: 'dashboard', ... },\n// { name: 'dashboard.settings', ... },\n// ]\n```\n\nInspect a pathname without running middleware, data loaders, or subscribers. Strips the configured `base` automatically. Returns the matched branch from root to leaf, or `null` for redirect routes and no-match.\n\n**Returns:** `RouteMatchBranch | null`\n\n---\n\n#### `router.load(url, options?)`\n\n```ts\n// SSR data prefetch\nconst state = await router.load('/users/42');\n\n// With cancellation\nconst controller = new AbortController();\nconst state = await router.load('/dashboard', { signal: controller.signal });\n```\n\nLoad a full URL into a `RouteState` including data loader results, without modifying router state or history. Follows declarative redirects (up to five hops). Returns `null` for unmatched URLs.\n\nMiddleware is **not** executed — `load` is a data-only prefetch for SSR and pre-rendering where middleware side effects are not wanted. If your data loaders depend on `ctx.locals` set by middleware, use `navigate()` instead.\n\nWhen a `data()` function throws, the returned state has `status: 'error'` and `error` set to the thrown value.\n\n**Returns:** `Promise<RouteState | null>`\n\n---\n\n#### `router.preload(target)`\n\n```ts\nconst state = await router.preload({\n name: 'userDetail',\n params: { id: '42' },\n query: { tab: 'profile' },\n});\n```\n\nExecutes data loaders without changing router state or history. Concurrent calls for the same target are deduplicated. A subsequent navigation with the same route, params, and query consumes the cached data instead of rerunning loaders.\n\n**Returns:** `Promise<RouteState | null>`\n\n---\n\n#### `router.createViewRegistry(views, options?)`\n\n```ts\nconst views = router.createViewRegistry(\n { home: HomePage, userDetail: UserPage },\n { notFound: NotFoundPage },\n);\n\nconst Component = views.resolve(router.getSnapshot());\n```\n\nRequires one value for every renderable route name, excludes redirect-only routes, and rejects unknown keys. Values may be framework components, lazy factories, or richer presentation descriptors. `options.notFound` handles the synthetic fallback without exposing its internal route name.\n\n**Returns:** `RouteViewRegistry`\n\n---\n\n#### `router.waitFor(name)`\n\n```ts\n// Navigate and wait for data to settle\nawait router.navigate({ name: 'userDetail', params: { id: '42' } });\nconst state = await router.waitFor('userDetail');\nconst user = state.matches.at(-1)?.data;\n\n// Useful in tests with memory history:\nconst history = createMemoryHistory('/dashboard');\nconst router = createRouter({ history, routes });\nconst state = await router.waitFor('dashboard');\n```\n\nWaits for the router to reach `status: 'idle'` with the named route active in the matched branch. Rejects immediately if `status === 'error'`. Resolves immediately if the router is already idle on the target route. Also rejects if `router.dispose()` is called while the promise is pending.\n\n**Returns:** `Promise<RouteState>`\n\n---\n\n#### `router.beforeLeave(blocker, options?)`\n\n```ts\n// Guard unsaved-changes forms\nconst remove = router.beforeLeave(async (destination) => {\n if (!form.isDirty) return true;\n return confirm(`Leave without saving? (going to ${destination.pathname})`);\n});\n\n// Remove the guard when the form unmounts\nremove();\n```\n\nRegister a global leave guard called before user-triggered navigation attempts. Return `true` to allow, `false` to cancel. Multiple guards can be registered; navigation is blocked if any guard returns `false`.\n\nScope a guard to fire only when leaving specific routes using the `routes` option:\n\n```ts\nrouter.beforeLeave(async () => confirm('Discard changes?'), { routes: ['editor'] });\n```\n\nThe guard fires when the router is leaving any route whose name appears in the `routes` array (any node in the active branch, not just the leaf). Declarative `redirect` routes bypass all leave guards.\n\n**Returns:** `() => void`\n\n## `redirectTo(target, options?)`\n\n```ts\nimport { redirectTo } from '@vielzeug/wayfinder';\n\nconst requireAuth = redirectTo({ name: 'login' }, { replace: true });\n```\n\nCreates middleware that navigates to `target` and short-circuits the middleware chain (does not call `next()`). Useful for auth guards and route aliases in middleware.\n\nFor permanent declarative redirects (URL aliases), use the `redirect` field on the route definition instead.\n\n> **Note:** `redirectTo()` internally calls `ctx.navigate()`, which runs `beforeLeave` guards. If a guard blocks navigation, the redirect will not complete. Declarative `redirect` on a route definition bypasses guards entirely.\n\n**Returns:** `Middleware`\n\n---\n\n### State\n\n#### `router.ready`\n\nA `Promise<void>` for the constructor-triggered navigation. It resolves after initial middleware, redirects, and data loaders settle. It resolves after a blocked or unmatched initial navigation, and rejects if initial navigation fails.\n\n```ts\nconst router = createRouter({ routes });\nawait router.ready;\n```\n\n---\n\n#### `router.getSnapshot()`\n\nReturns the current immutable route state snapshot. Use this to read state synchronously. Compatible with React's `useSyncExternalStore`:\n\n```ts\nconst state = useSyncExternalStore(\n (cb) => router.subscribe(cb),\n () => router.getSnapshot(),\n);\n```\n\n```ts\nconst { location, matches, status, error } = router.getSnapshot();\n\nlocation.pathname;\nlocation.query; // raw parsed query (QueryParams) — always string values\nlocation.hash;\nlocation.historyState; // value passed to navigate({ ... }, { state: ... })\n\n\n// When status === 'error':\nconsole.error(error);\n```\n\n`error` is only set when `status === 'error'`. It holds the exact value thrown by the failing `data()` function.\n\n**Returns:** `RouteState`\n\n#### `router.subscribe(listener)`\n\n```ts\nconst unsubscribe = router.subscribe((state) => {\n const leaf = state.matches.at(-1);\n // Read data from the leaf match\n console.log(leaf?.data);\n});\n```\n\nRegister a listener for future state changes. The listener is **not** called with the current snapshot — call `router.getSnapshot()` when you subscribe if you need it.\n\n**Returns:** `() => void`\n\n## Types\n\n### `RouteContext<Params, TRoutes>`\n\nContext passed to middleware and data loader functions.\n\n```ts\ntype RouteContext<Params extends RouteParams = RouteParams, TRoutes extends RouteTable = RouteTable> = {\n readonly hash: string;\n /** State stored on the history entry that triggered this navigation. */\n readonly historyState: unknown;\n locals: Record<string, unknown>;\n readonly matches: RouteMatchBranch;\n readonly navigate: (\n target: NamedNavigationTarget<TRoutes> | RawNavigationTarget | string,\n options?: NavigateOptions,\n ) => Promise<void>;\n readonly params: Params;\n readonly pathname: string;\n readonly query: ResolvedQueryParams;\n};\n```\n\n`ctx.locals` is mutable and shared across the entire middleware chain for one navigation. Use it to pass values from middleware to data loaders.\n\n`ctx.query` is the coerced query (after `coerceSearch`). `router.getSnapshot().location.query` always contains raw string values from URL parsing.\n\n### `DataFn<Params, TRoutes>`\n\n```ts\ntype DataFn<Params extends RouteParams = RouteParams, TRoutes extends RouteTable = RouteTable> = (\n context: DataContext<Params, TRoutes>,\n) => MaybePromise<unknown>;\n```\n\nData loader function. The return value becomes `match.data` on the leaf route match.\n\n### `DataContext<Params, TRoutes>`\n\n```ts\ntype DataContext<Params extends RouteParams = RouteParams, TRoutes extends RouteTable = RouteTable> = RouteContext<\n Params,\n TRoutes\n> & {\n readonly signal: AbortSignal;\n};\n```\n\nThe `signal` is aborted when a newer navigation supersedes the current one. Use it to cancel in-flight fetches or other async work.\n\n### `Middleware<TRoutes>`\n\n```ts\ntype Middleware<TRoutes extends RouteTable = RouteTable> = (\n context: RouteContext<RouteParams, TRoutes>,\n next: () => Promise<void>,\n) => void | Promise<void>;\n```\n\nMiddleware ordering is simple: global middleware first, then route middleware, then `data()`.\n\n### `UntypedNamedNavigationTarget`\n\n```ts\ntype UntypedNamedNavigationTarget = {\n hash?: string;\n name: string;\n params?: RouteParams;\n query?: ResolvedQueryParams;\n};\n```\n\n### `NavigationTarget`\n\n```ts\ntype NavigationTarget =\n | {\n path: string;\n }\n | {\n hash?: string;\n name: string;\n params?: RouteParams;\n query?: ResolvedQueryParams;\n };\n```\n\n### `NavigateOptions`\n\n```ts\ntype NavigateOptions = {\n force?: boolean;\n replace?: boolean;\n state?: unknown;\n};\n```\n\n### `RouteState`\n\n```ts\ntype RouteState = {\n /** The value thrown by a `data()` function. Only set when `status === 'error'`. */\n readonly error?: unknown;\n readonly location: RouteLocation;\n readonly matches: readonly RouteMatch[];\n readonly status: NavigationStatus;\n};\n\ntype RouteLocation = {\n readonly hash: string;\n /** State stored on the history entry that triggered this navigation. */\n readonly historyState: unknown;\n readonly pathname: string;\n /** Raw parsed query params — always string values from URL parsing.\n * For coerced values (numbers, booleans), read `ctx.query` inside middleware or data loaders.\n */\n readonly query: QueryParams;\n};\n```\n\n### `RouteMatch`\n\n```ts\ntype RouteMatch = {\n /** Result of the route's `data()` function, or `undefined` if none was defined. */\n readonly data: unknown;\n readonly name: string;\n readonly params: RouteParams;\n readonly pathname: string;\n};\n```\n\n### `RouteMatchBranch`\n\n```ts\ntype RouteMatchBranch = readonly RouteMatch[];\n```\n\n### `RouteViewName`, `RouteViewMap`, and `RouteViewRegistry`\n\n`RouteViewName<TRoutes>` contains renderable route names and excludes redirect-only routes. `RouteViewMap<TRoutes>` requires one value per renderable route. `RouteViewRegistry<TView>` resolves the active view from a `RouteState` without exposing the synthetic not-found name.\n\n### `ScrollDecision`\n\n```ts\ntype ScrollDecision = 'preserve' | 'top' | { x: number; y: number };\n```\n\n### `PathParams<T>`\n\n```ts\ntype UserParams = PathParams<'/users/:id'>;\n// => { readonly id: string }\n\ntype FileParams = PathParams<'/files/:rest*'>;\n// => { readonly rest: string }\n```\n\n### `QueryParams`\n\n```ts\ntype QueryParams = Record<string, string | string[]>;\n```\n\nRepresents parsed URL query values before route-level coercion.\n\n### `ResolvedQueryParams`\n\n```ts\ntype ResolvedQueryValue = string | number | boolean;\ntype ResolvedQueryParams = Record<string, ResolvedQueryValue | ResolvedQueryValue[]>;\n```\n\nRepresents the query object after optional `coerceSearch` normalization.\n\n### `NavigationStatus`\n\n```ts\ntype NavigationStatus = 'idle' | 'loading' | 'error';\n```\n\nTop-level status of the router.\n\n- `idle` — navigation settled successfully.\n- `loading` — data loaders are in-flight.\n- `error` — a data loader threw and no route-level `onError` handled it.\n\n### `RouteMiddleware<Path, TRoutes>`\n\n```ts\ntype RouteMiddleware<Path extends string = string, TRoutes extends RouteTable = RouteTable> = (\n context: RouteContext<PathParams<Path>, TRoutes>,\n next: () => Promise<void>,\n) => void | Promise<void>;\n```\n\nTyped variant of `Middleware` scoped to a route path. Provides typed `ctx.params` matching the path pattern.\n\n```ts\nconst guard: RouteMiddleware<'/users/:id'> = (ctx, next) => {\n console.log(ctx.params.id); // string\n return next();\n};\n```\n\n### `CoerceSearchFn<Q>`\n\n```ts\ntype CoerceSearchFn<Q extends ResolvedQueryParams = ResolvedQueryParams> = (\n raw: QueryParams,\n) => Q;\n```\n\nFunction signature for both the per-route `coerceSearch` field and the global `RouterOptions.coerceSearch` option. Receives raw URL strings and returns typed values. Throwing inside the function falls back to the original raw query.\n\n### `BeforeLeaveOptions<TRoutes>`\n\n```ts\ntype BeforeLeaveOptions<TRoutes extends RouteTable = RouteTable> = {\n /** Route names that trigger this guard. Omit for a global guard. */\n routes?: RouteName<TRoutes>[];\n};\n```\n\nPassed as the second argument to `router.beforeLeave()`. When `routes` is provided, the guard only fires when the router leaves a route whose name is in the array.\n\n### `BeforeLeaveBlocker`\n\n```ts\n// Return true to allow navigation, false to cancel.\ntype BeforeLeaveBlocker = (destination: NavigationDestination) => MaybePromise<boolean>;\n```\n\n### `NavigationDestination`\n\n```ts\ntype NavigationDestination = {\n readonly name?: string; // route name if navigating to a named route\n readonly params: RouteParams;\n readonly pathname: string;\n readonly query: QueryParams;\n};\n```\n\nPassed to every `beforeLeave` blocker. Use `destination.pathname` and `destination.query` to make context-aware allow/block decisions.\n\n### `IsActiveOptions`\n\n```ts\ntype IsActiveOptions = {\n /** Require an exact pathname match. Defaults to prefix matching. */\n exact?: boolean;\n};\n```\n\n### `RouterErrorContext`\n\n```ts\ntype RouterErrorContext =\n | { routeName: string; source: 'data-loader' } // data() threw\n | { routeName: string; source: 'middleware' } // middleware threw\n | { source: 'coerce-search' | 'history-listener' | 'initial-navigation' | 'preload' };\n```\n\nPassed to the `onError` callback in `createRouter` options. The `routeName` is present when the error originates from a named route's `data()` or `middleware`.\n\n### `HistoryDriver`\n\n```ts\ninterface HistoryDriver {\n readonly location: {\n readonly hash: string;\n readonly pathname: string;\n readonly search: string;\n readonly state: unknown;\n };\n /** Navigate one entry back in history, equivalent to the browser back button. */\n back(): void;\n push(url: string, state?: unknown): void;\n replace(url: string, state?: unknown): void;\n /**\n * Subscribe to backwards/forwards navigation (popstate-equivalent).\n * `push()` and `replace()` are silent — they do not notify subscribers.\n * Only `back()` (and browser popstate events) trigger notifications.\n * Returns an unsubscribe function.\n */\n onPopstate(listener: () => void): () => void;\n}\n```\n\n### `RouteDefinition<Path>`\n\n```ts\ntype RouteDefinition<Path extends string = string> =\n | ContentRouteDefinition<Path> // path + data/middleware/coerceSearch/onError\n | RedirectRouteDefinition<Path>; // path + redirect\n```\n\nThe union type for a single entry in the route table. Use this to type externally-defined route objects:\n\n```ts\nimport type { RouteDefinition } from '@vielzeug/wayfinder';\n\nconst userDetail: RouteDefinition<'/users/:id'> = {\n path: '/users/:id',\n data: async ({ params }) => fetchUser(params.id),\n};\n```\n\n### `RouterOptions<TRoutes>`\n\nThe options object accepted by `createRouter()`. See the [`createRouter(options)`](#createrouter-options) options table above for the full field reference.\n\n```ts\nimport type { RouterOptions } from '@vielzeug/wayfinder';\n\nconst options: RouterOptions<typeof routes> = {\n routes,\n base: '/app',\n};\n```\n\n### `Unsubscribe`\n\n```ts\ntype Unsubscribe = () => void;\n```\n\n## Errors\n\n### `WayfinderError`\n\nBase class for every error Wayfinder throws. Catch this to handle any router-originated error without enumerating subclasses.\n\n```ts\nimport { WayfinderError } from '@vielzeug/wayfinder';\n\ntry {\n await router.navigate({ name: 'home' });\n} catch (e) {\n if (e instanceof WayfinderError) {\n // any router-originated error — check e.name or `instanceof` a subclass for detail\n }\n}\n```\n\n### `WayfinderDisposedError`\n\nThrown when `navigate()`, `subscribe()`, `beforeLeave()`, or `waitFor()` is called after `dispose()`. Also used as the `AbortSignal.reason` on `disposalSignal`.\n\n```ts\nimport { WayfinderDisposedError } from '@vielzeug/wayfinder';\n\ntry {\n await router.navigate({ name: 'home' });\n} catch (e) {\n if (e instanceof WayfinderDisposedError) {\n // router was disposed\n }\n}\n```\n\n### `WayfinderRouteError`\n\nThrown for malformed route definitions — at `createRouter()` time for config errors, or when a `url()`/`navigate()` call references an unknown route name or a missing path param.\n\n### `WayfinderRedirectLoopError`\n\nThrown when a chain of declarative `redirect`s (or a mix of declarative redirects and `ctx.navigate()` calls inside route middleware) exceeds 5 hops.\n\n### `WayfinderApiError`\n\nThrown on middleware misuse — currently only when a middleware function calls its `next()` more than once.\n\n### Runtime error messages\n\n| Message | Class | When |\n| ---------------------------------------------------------------- | ----------------------------- | ---------------------------------------------------------------------- |\n| `Router is disposed` | `WayfinderDisposedError` | Calling a guarded method (see above) after `dispose()` |\n| `Unknown route name: X. Available routes: Y` | `WayfinderRouteError` | Navigating to, resolving, or building a URL for an unregistered route |\n| `Route \"X\" cannot define both index and path` | `WayfinderRouteError` | A route sets `index: true` and `path` at the same time |\n| `Route \"X\" must define path or set index: true` | `WayfinderRouteError` | A route defines neither `index: true` nor `path` |\n| `Duplicate route name: \"X\"` | `WayfinderRouteError` | Two routes resolve to the same compound name during `createRouter()` |\n| `Missing path param: X` | `WayfinderRouteError` | `url()`/`navigate()` omits a param the path pattern requires |\n| `Invalid param name \":X\" in path \"Y\"` | `WayfinderRouteError` | A param name contains non-word characters (e.g., `:user-id`) |\n| `Wildcard \"*\" must be the final segment in path: X` | `WayfinderRouteError` | A `*` segment appears before the last segment |\n| `Wildcard param must be final segment in path: X` | `WayfinderRouteError` | A `:param*` greedy param appears before the last segment |\n| `Redirect loop detected` | `WayfinderRedirectLoopError` | A declarative `redirect` chain (or mixed redirect + `navigate()`) exceeds 5 hops |\n| `next() called multiple times` | `WayfinderApiError` | Middleware calls its `next()` callback more than once |\n\n## Pattern Rules\n\n| Pattern | Example | Meaning |\n| ------------------------------ | ------------------- | ------------------------------------------- |\n| `/about` | `/about` | Exact static path |\n| `/users/:id` | `/users/42` | Single named param |\n| `/users/:userId/posts/:postId` | `/users/1/posts/2` | Multiple named params |\n| `/docs/*` | `/docs/guide/intro` | Wildcard suffix without a named capture |\n| `/files/:rest*` | `/files/a/b/c` | Wildcard suffix captured as one named param |\n| `*` | anything | Global catch-all |\n\n## Design Notes\n\n- Wayfinder is a routing core: route compilation, matching, history, navigation, and cancellation. Data loading is an optional layer via per-route `data()` functions. UI rendering is owned by the framework adapter, not Wayfinder.\n- Wayfinder no longer exposes imperative registration methods like `on()`, `group()`, or `use()`.\n- Wayfinder names come from the route-table object keys.\n- `data()` is the terminal action. Its return value becomes `match.data`. There is no separate `handler` step.\n- For unmatched URLs, use the `notFound` router option rather than `path: '*'` in the route table.\n- Error handling is middleware that wraps `await next()`. The thrown error is also stored on `router.getSnapshot().error`. The original error object is never mutated — error context is carried in internal wrappers and the original `cause` chain is preserved.\n- Declarative `redirect` on a route definition is for permanent alias redirects. The `redirectTo()` middleware helper is for conditional guards.\n- `onError` in a route definition is a per-route data-loader boundary. If `onError` itself throws, the router falls through to `status: 'error'` as usual.\n",
|
|
6
|
-
"usage": "---\ntitle: Wayfinder — Usage Guide\ndescription: Router setup, middleware, data loading, nested routes, and state patterns for Wayfinder.\n---\n\n[[toc]]\n\n::: tip New to Wayfinder?\nStart with the [Overview](./index.md), then use this page for the day-to-day API.\n:::\n\n## Basic Usage\n\nCreate a deterministic router with memory history, wait for startup, and navigate by route name.\n\n```ts\nimport { createMemoryHistory, createRouter } from '@vielzeug/wayfinder';\n\nconst router = createRouter({\n history: createMemoryHistory('/'),\n routes: {\n home: { path: '/' },\n settings: {\n data: async () => ({ section: 'settings' }),\n path: '/settings',\n },\n },\n});\n\nawait router.ready;\nawait router.navigate({ name: 'settings' });\nconsole.log(router.getSnapshot().matches.at(-1)?.data);\nrouter.dispose();\n```\n\n`routes` is required. Route keys become names, and object key order controls match precedence.\n\n## Define Routes\n\nEach route can provide these fields:\n\n| Field | Purpose |\n| -------------- | --------------------------------------------------------------------------------------------------------------------------- |\n| `path` | Match pattern |\n| `children` | Nested child routes |\n| `index` | Default child route that inherits the parent path |\n| `data` | Abortable route data function. Result available as `match.data`. |\n| `middleware` | Route-specific middleware |\n| `onError` | Per-route error boundary. Called when this route's `data()` throws; its return value becomes `match.data`. |\n| `redirect` | Declarative permanent redirect. Resolved before middleware runs. |\n| `coerceSearch` | Coerce raw URL search strings into typed values. Return value replaces `ctx.query`. Throw to leave the raw query unchanged. |\n\nUse wildcard routes for fallback behavior:\n\n```ts\nconst routes = {\n docs: { path: '/docs/*' },\n};\n```\n\nFor a catch-all not-found page, use the `notFound` option in router options instead of a `path: '*'` route:\n\n```ts\nconst router = createRouter({\n routes,\n notFound: {\n data: async ({ pathname }) => ({ requestedPath: pathname }),\n },\n});\n```\n\nAlternatively, `path: '*'` still works as a named route when you need to navigate to it explicitly.\n\nNested routes compose naturally and create compound route names:\n\n```ts\nconst routes = {\n dashboard: {\n path: '/dashboard',\n children: {\n index: { index: true },\n settings: { path: 'settings' },\n },\n },\n};\n\nawait router.navigate({ name: 'dashboard.settings' });\n```\n\n## Route Context\n\nMiddleware and data loaders receive a `RouteContext`:\n\n```ts\nuserDetail: {\n path: '/users/:id',\n middleware: [\n (ctx, next) => {\n ctx.params.id; // typed to path params\n ctx.query.tab; // resolved query (after coerceSearch)\n ctx.pathname;\n ctx.hash;\n ctx.historyState; // value from navigate({ ... }, { state: ... })\n ctx.locals; // mutable bag shared across the middleware chain\n ctx.navigate; // programmatic navigation\n return next();\n },\n ],\n data: async (ctx) => {\n ctx.signal; // AbortSignal — cancelled when navigation is superseded\n return fetchUser(ctx.params.id, { signal: ctx.signal });\n },\n}\n```\n\n`ctx.locals` is mutable and shared through the entire middleware chain for one navigation. Use it to pass values from middleware to data loaders.\n\n## Middleware\n\nMiddleware wraps the navigation using the familiar `async (ctx, next) => { ... }` shape.\n\n```ts\nconst requireAuth = redirectTo({ name: 'login' }, { replace: true });\n\nconst loadCurrentUser = async (ctx, next) => {\n ctx.locals.user = await fetchCurrentUser();\n await next();\n};\n```\n\nOrder is fixed and simple:\n\n```text\nglobal middleware\n ↓\nroute middleware\n ↓\ndata()\n```\n\n### Guards\n\nUse middleware for auth checks, redirects, analytics, and boundaries.\n\n```ts\nconst requireAuth = async (ctx, next) => {\n if (!session.currentUser) {\n await ctx.navigate({ name: 'login' }, { replace: true });\n return; // do not call next()\n }\n ctx.locals.user = session.currentUser;\n await next();\n};\n```\n\nFor unconditional redirects, use the `redirectTo()` helper:\n\n```ts\nimport { redirectTo } from '@vielzeug/wayfinder';\n\nconst requireAuth = redirectTo({ name: 'login' }, { replace: true });\n```\n\nFor permanent URL aliases, use the declarative `redirect` field instead of middleware:\n\n```ts\nconst routes = {\n profile: { path: '/profile', redirect: { name: 'userDetail' } },\n userDetail: { path: '/users/:id' },\n};\n```\n\n> **Note:** `redirectTo()` calls `ctx.navigate()` internally, so `beforeLeave` guards will run and can block it. Declarative `redirect` on a route definition bypasses all leave guards.\n\n### Leave Guards\n\nRegister a global leave guard with `router.beforeLeave()`. Return `false` to cancel navigation.\n\n```ts\nconst removeGuard = router.beforeLeave(async (destination) => {\n if (!form.isDirty) return true;\n return confirm(`Discard changes? (navigating to ${destination.pathname})`);\n});\n\n// Remove when no longer needed:\nremoveGuard();\n```\n\nScope a guard to fire only when leaving specific routes:\n\n```ts\nrouter.beforeLeave(async () => confirm('Discard changes?'), { routes: ['editor'] });\n```\n\nDeclarative `redirect` routes bypass all leave guards.\n\n### Data Loading\n\nUse `data()` for route-local data acquisition. It receives the same route context plus an `AbortSignal`.\n\n```ts\nconst routes = {\n userDetail: {\n path: '/users/:id',\n data: async ({ params, signal }) => fetchUser(params.id, { signal }),\n },\n};\n```\n\nAccess the result via the matched branch:\n\n```ts\nrouter.subscribe((state) => {\n const user = state.matches.at(-1)?.data;\n renderUser(user);\n});\n```\n\n#### Per-route Error Boundaries\n\nUse `onError` to handle data loader failures per-route. The returned value becomes `match.data`, allowing the route to render a degraded state:\n\n```ts\nconst routes = {\n userDetail: {\n path: '/users/:id',\n data: async ({ params, signal }) => fetchUser(params.id, { signal }),\n onError: (error) => ({ error, user: null }),\n },\n};\n```\n\nIf `onError` itself throws, the router falls through to `status: 'error'` as usual.\n\n### Search Param Validation\n\nValidate and coerce `ctx.query` per route. The function receives raw URL strings (`QueryParams`). Throw to leave the parsed query unchanged.\n\n```ts\nconst routes = {\n search: {\n path: '/search',\n coerceSearch: (raw) => ({\n q: String(raw.q ?? ''),\n page: Math.max(1, Number(raw.page ?? 1)),\n }),\n data: async ({ query }) => searchPosts(query.q, query.page),\n },\n};\n```\n\nTo apply the same coercion to every route, set `coerceSearch` on the router options instead. Per-route `coerceSearch` takes precedence over the global one.\n\n```ts\nconst router = createRouter({\n coerceSearch: (raw) => ({ page: Number(raw.page ?? 1) }),\n routes,\n});\n```\n\n### Error Boundaries\n\nWrap `await next()` in middleware for route-wide error handling. The thrown error is also stored on `router.getSnapshot().error`.\n\n```ts\nconst boundary = async (ctx, next) => {\n try {\n await next();\n } catch (error) {\n reportRouteError(ctx.pathname, error);\n await ctx.navigate({ path: '/error' }, { replace: true });\n }\n};\n\nconst router = createRouter({\n middleware: [boundary],\n routes,\n});\n\n// Check after navigation:\nconst { status, error } = router.getSnapshot();\nif (status === 'error') {\n console.error(error);\n}\n```\n\n## Navigation\n\n### Named Navigation\n\n```ts\nawait router.navigate({ name: 'userDetail', params: { id: '42' } });\nawait router.navigate({ name: 'userDetail', params: { id: '42' } }, { replace: true });\nawait router.navigate({ name: 'search', query: { q: 'wayfinder' }, hash: 'results' });\nawait router.navigate({ name: 'dashboard.settings' });\n```\n\n### Raw Path Targets\n\n```ts\nawait router.navigate({ path: '/marketing?utm_source=campaign' });\nawait router.navigate({ path: '/checkout#payment' }, { replace: true });\n```\n\nUse these when a destination does not belong in the route table. The same `navigate()` method covers named routes and raw path targets.\n\n### History State\n\nAttach arbitrary state to a history entry and read it back via `ctx.historyState` or `router.getSnapshot().location.historyState`.\n\n```ts\nawait router.navigate({ name: 'userDetail', params: { id: '42' } }, { state: { from: 'search' } });\n\n// In data():\ndata: async (ctx) => {\n console.log(ctx.historyState); // { from: 'search' }\n return fetchUser(ctx.params.id);\n},\n```\n\n### Same-URL Deduplication\n\n```ts\nawait router.navigate({ name: 'dashboard' });\nawait router.navigate({ name: 'dashboard' }); // no-op\nawait router.navigate({ name: 'dashboard' }, { force: true }); // re-runs\n```\n\n### Leave Guards\n\nGuard navigation until the user confirms — useful for unsaved-changes forms:\n\n```ts\nconst removeGuard = router.beforeLeave(async (destination) => {\n if (!form.isDirty) return true;\n return confirm('Discard changes?');\n});\n\n// Remove when the component unmounts:\nremoveGuard();\n```\n\nScope a guard to a specific route so it only fires when leaving that route:\n\n```ts\nrouter.beforeLeave(async () => confirm('Discard changes?'), { routes: ['editor'] });\n```\n\n## URLs and Active State\n\n```ts\nrouter.url('userDetail', { id: '42' });\nrouter.url('userDetail', { id: '42' }, { tab: 'profile' });\n\nrouter.isActive('userDetail');\nrouter.isActive('users');\nrouter.isActive('users', { exact: true });\n```\n\n`isActive(name)` reads the current router snapshot and is useful for parent navigation items.\n\n## Match a Path Without Navigating\n\n```ts\nconst branch = router.match('/app/dashboard/settings');\n\nif (branch?.at(-1)?.name === 'dashboard.settings') {\n warmSettingsPanel();\n}\n```\n\n`match()` strips the configured base automatically and returns the full matched branch (root to leaf). Data loaders are not executed.\n\n## Load a Path for SSR\n\nUse `router.load(url)` to load a full route state including data loader results without modifying router state or history. This is useful for server-side data prefetching.\n\n```ts\nconst state = await router.load('/users/42');\n\nif (state) {\n const data = state.matches.at(-1)?.data;\n // serialize and send to the client\n}\n```\n\nPass an `AbortSignal` via the options object to cancel in-flight loaders:\n\n```ts\nconst controller = new AbortController();\nconst state = await router.load('/users/42', { signal: controller.signal });\n```\n\n`load()` follows declarative redirects (up to five hops). Middleware is not executed and its result is not reused by client navigation.\n\n## Preload Client Navigation\n\nUse `preload()` with the same typed named target accepted by `navigate()`. It executes route data loaders without changing router state or history, deduplicates concurrent work, and lets the next matching navigation consume the result.\n\n```ts\nawait router.preload({ name: 'userDetail', params: { id: '42' } });\nawait router.navigate({ name: 'userDetail', params: { id: '42' } });\n```\n\nParams and query values are part of the cache key. A navigation with different values runs its loaders normally.\n\n## Coordinate Scroll and View Transitions\n\nKeep commit-sensitive browser behavior on the router:\n\n```ts\nconst router = createRouter({\n routes,\n scroll: (to, from) => (to.location.pathname === from.location.pathname ? 'preserve' : 'top'),\n viewTransition: true,\n});\n\nawait router.navigate({ name: 'settings' }, { viewTransition: false });\n```\n\n`scroll` runs after a successful navigation and can return `'top'`, `'preserve'`, or `{ x, y }`. View transitions fall back to plain navigation when the browser API is unavailable.\n\n## State and Subscriptions\n\n```ts\nrouter.subscribe((state) => {\n const leaf = state.matches.at(-1);\n console.log(leaf?.data);\n});\n```\n\nUse `router.getSnapshot()` to read the current state synchronously:\n\n```ts\nconst { location, matches, status, error } = router.getSnapshot();\n\nlocation.pathname;\nlocation.query; // raw parsed query strings (QueryParams)\nlocation.hash;\nlocation.historyState; // state from the current history entry\n\nmatches; // matched branch from root to leaf\nstatus; // 'idle' | 'loading' | 'error'\nerror; // only set when status === 'error'\n```\n\nThe state object is immutable. A successful navigation replaces it with a new snapshot.\n\n### `waitFor(name)`\n\nWait for the router to reach `status: 'idle'` with a specific route active. Useful in tests and lifecycle coordination:\n\n```ts\n// Navigate and wait for data to settle\nawait router.navigate({ name: 'userDetail', params: { id: '42' } });\nconst state = await router.waitFor('userDetail');\nconst user = state.matches.at(-1)?.data;\n```\n\n`waitFor` rejects immediately if the router is already in `status: 'error'`, and also rejects if `router.dispose()` is called while the promise is pending. Resolves immediately if the named route is already active and idle.\n\n## Testing\n\nUse `createMemoryHistory` to test routers without a browser:\n\n```ts\nimport { createMemoryHistory, createRouter } from '@vielzeug/wayfinder';\n\nconst history = createMemoryHistory('/dashboard');\nconst router = createRouter({ history, routes });\n\n// Use waitFor to avoid manual timing:\nconst state = await router.waitFor('dashboard');\nassert(state.location.pathname === '/dashboard');\n\nrouter.dispose();\n```\n\n## Cleanup\n\n```ts\nrouter.dispose();\n```\n\nRemove listeners, clear subscribers, and prevent future router usage.\n\n## Build a Typed View Registry\n\nKeep matching and data concerns in the route table while storing framework components, lazy factories, and static presentation metadata in an exhaustive registry:\n\n```ts\nconst views = router.createViewRegistry(\n {\n home: { component: HomePage, title: 'Home' },\n settings: { component: SettingsPage, title: 'Settings' },\n },\n { notFound: { component: NotFoundPage, title: 'Not found' } },\n);\n\nconst view = views.resolve(router.getSnapshot());\n```\n\nEvery renderable route name is required; redirect-only routes are excluded. Unknown keys are rejected, and the fallback is explicit rather than relying on the router's internal not-found match name.\n\n## Framework Integration\n\nRoute exposes `getSnapshot()` and `subscribe()`, which map directly to each framework's external-store primitives. Create the router once at module scope and bind actions outside the component lifecycle so references stay stable.\n\n::: code-group\n\n```tsx [React]\nimport { createRouter } from '@vielzeug/wayfinder';\nimport { useSyncExternalStore } from 'react';\n\nconst router = createRouter({\n routes: {\n home: { path: '/' },\n settings: { path: '/settings' },\n },\n notFound: { data: () => ({ message: 'Not found' }) },\n});\n\n// Stable router actions are safe to destructure outside the hook.\nconst { getSnapshot, isActive, navigate, subscribe, url } = router;\n\nexport function useRouter() {\n const state = useSyncExternalStore(subscribe, getSnapshot);\n return { isActive, navigate, state, url };\n}\n\n// RouterView.tsx — exhaustive routes and an explicit fallback\nconst views = router.createViewRegistry(\n { home: HomePage, settings: SettingsPage },\n { notFound: NotFoundPage },\n);\n\nexport function RouterView() {\n const { state } = useRouter();\n const Component = views.resolve(state);\n return Component ? <Component /> : null;\n}\n```\n\n```ts [Vue 3]\nimport { createRouter } from '@vielzeug/wayfinder';\nimport { readonly, shallowRef } from 'vue';\n\nconst router = createRouter({\n routes: {\n home: { path: '/' },\n settings: { path: '/settings' },\n },\n notFound: { data: () => ({ message: 'Not found' }) },\n});\n\n// shallowRef — no need to deep-track immutable route state.\nconst state = shallowRef(router.getSnapshot());\nrouter.subscribe((next) => {\n state.value = next;\n});\n\nexport function useRouter() {\n const { isActive, navigate, url } = router;\n\n return { isActive, navigate, state: readonly(state), url };\n}\n```\n\n```svelte [Svelte]\n<!-- router.ts -->\n<script lang=\"ts\" context=\"module\">\n import { createRouter } from '@vielzeug/wayfinder';\n import { readable } from 'svelte/store';\n\n const router = createRouter({\n routes: {\n home: { path: '/' },\n settings: { path: '/settings' },\n },\n notFound: { data: () => ({ message: 'Not found' }) },\n });\n\n // readable injects the initial value; subscribe() drives updates.\n export const routerState = readable(router.getSnapshot(), (set) => router.subscribe(set));\n export const { isActive, navigate, url } = router;\n</script>\n```\n\n:::\n\nFor full RouterView and RouterLink patterns, see [React Integration](./examples/react-integration.md), [Vue Integration](./examples/vue-integration.md), and [Svelte Integration](./examples/svelte-integration.md).\n\n## Debug Logging\n\n`router.subscribe()` is the reactive subscription API — it receives every state change, including `loading` and `error` transitions. Attach a listener that logs to `console.debug` to inspect navigation without any dedicated debug tooling.\n\n```ts\nimport { createRouter } from '@vielzeug/wayfinder';\n\nconst router = createRouter({ routes });\nconst stop = router.subscribe((state) => {\n console.debug(`[wayfinder] ${state.status} ${state.location.pathname}`);\n});\n\n// Logged once the initial navigation completes:\n// [wayfinder] idle /\n\n// On navigate({ name: 'dashboard' }):\n// [wayfinder] loading /dashboard\n// [wayfinder] idle /dashboard\n```\n\nThe returned function unsubscribes the listener — call it when the logger is no longer needed (e.g. on teardown):\n\n```ts\nstop();\n```\n\nErrors are surfaced on the state object, so you can log them explicitly:\n\n```ts\nrouter.subscribe((state) => {\n if (state.status === 'error') {\n console.error(`[wayfinder] ${state.location.pathname}`, state.error);\n }\n});\n```\n\nUse a label when running multiple routers to distinguish their log output:\n\n```ts\nconst main = createRouter({ routes });\nmain.subscribe((state) => console.debug(`[wayfinder:main] ${state.status} ${state.location.pathname}`));\n\nconst modal = createRouter({ routes: modalRoutes });\nmodal.subscribe((state) => console.debug(`[wayfinder:modal] ${state.status} ${state.location.pathname}`));\n```\n\nDebug logging has no effect on behavior and should not be enabled in production.\n\n::: tip Unhandled router errors\nIf a route's data loader throws and no `onError` callback is set on the router, the error is surfaced via `console.error` in development and silenced in production (`__WAYFINDER_PROD__` set). Always provide an `onError` callback in production to handle errors explicitly.\n:::\n\n## Working with Other Vielzeug Libraries\n\n### With Ward\n\nUse Ward inside Wayfinder middleware to guard protected routes.\n\n```ts\nimport { createRouter } from '@vielzeug/wayfinder';\nimport { createWard } from '@vielzeug/ward';\n\ntype User = { id: string; roles: string[] };\n\nconst ward = createWard([{ role: 'admin', resource: 'settings', action: 'view', effect: 'allow' }]);\n\nconst router = createRouter({\n middleware: [\n (ctx, next) => {\n const user: User = getSessionUser();\n if (!ward.can(user, 'settings', 'view')) return ctx.navigate({ path: '/login' }, { replace: true });\n return next();\n },\n ],\n routes: {\n settings: { path: '/settings' },\n },\n});\n```\n\n### With Ripple\n\nSync router state to a Ripple signal for reactive UI.\n\n```ts\nimport { createRouter } from '@vielzeug/wayfinder';\nimport { signal } from '@vielzeug/ripple';\n\nconst router = createRouter({\n /* ... */\n});\nconst currentRoute = signal(router.getSnapshot().matches.at(-1)?.name ?? '');\n\nrouter.subscribe((state) => {\n currentRoute.value = state.matches.at(-1)?.name ?? '';\n});\n```\n\n## Best Practices\n\n- Define the route table once at app startup and import it where needed.\n- Prefer named navigation (`router.navigate({ name: 'settings' })`) over raw paths.\n- Put auth and permission checks in middleware, not in data loaders.\n- Use `data()` loaders for route data and honor the provided `AbortSignal`.\n- Use `onError` on a route for degraded-state rendering rather than a full redirect to an error page.\n- Use `notFound` in router options for the not-found page rather than `path: '*'` in the route table.\n- Call `router.dispose()` when tearing down apps/tests to release listeners.\n- Use `createMemoryHistory()` for tests and non-browser runtimes; avoid touching `window.history` directly.\n",
|
|
4
|
+
"index": "---\ntitle: Wayfinder — Client-side router for TypeScript\ndescription: Framework-agnostic client-side router with typed params, async data loading, middleware, leave guards, and cancellation.\npackage: wayfinder\ncategory: routing\nkeywords: [router, client-side, middleware, guards, navigation, history, spa, typed-routes]\nrelated: [ripple, ward, herald]\nexports: [createRouter, createBrowserHistory, createHashHistory, createMemoryHistory, redirectTo, WayfinderError, WayfinderApiError, WayfinderConfigError, WayfinderDisposedError, WayfinderRedirectLoopError, WayfinderRouteError]\nenvironments: [browser, node, ssr, deno]\n---\n\n<!-- markdownlint-disable MD025 MD033 MD060 -->\n\n<PackageHero package=\"wayfinder\" />\n\n## Why Wayfinder?\n\nManaging navigation by hand means scattered `popstate` listeners, duplicated path checks, and no shared abstraction for loading data or blocking navigation. Wayfinder moves all of that into one declarative table.\n\n```ts\n// Before — manual navigation with popstate\nwindow.addEventListener('popstate', () => {\n const path = window.location.pathname;\n if (path === '/') renderHome();\n else if (path.startsWith('/dashboard')) renderDashboard();\n else renderNotFound();\n});\ndocument.querySelectorAll('a[data-route]').forEach((a) => {\n a.addEventListener('click', (e) => {\n e.preventDefault();\n history.pushState({}, '', (e.currentTarget as HTMLAnchorElement).href);\n dispatchEvent(new PopStateEvent('popstate'));\n });\n});\n\n// After — with Wayfinder\nimport { createRouter } from '@vielzeug/wayfinder';\n\nconst router = createRouter({\n routes: {\n home: { path: '/' },\n dashboard: { path: '/dashboard' },\n },\n notFound: { data: () => ({ message: 'Page not found' }) },\n});\n\nrouter.subscribe((state) => {\n const leaf = state.matches.at(-1);\n render(leaf?.data);\n});\n```\n\n<div class=\"decision-callout\">\n\n**Use Wayfinder when** you need named navigation, route-level data loading with cancellation, middleware, or leave guards in a framework-agnostic setup.\n\n**Consider a framework's built-in router when** you are deep in a single framework ecosystem (React Router, Vue Router) and want first-class component binding with no adapter layer.\n\n</div>\n\n| Feature | Wayfinder | page.js | Navigo |\n| ------------------------------------ | ----------------------------------------------- | ------------------------------------------ | ------------------------------------------ |\n| Bundle size | <PackageInfo package=\"wayfinder\" type=\"size\" /> | ~1 kB | ~5 kB |\n| History mode | <ore-icon name=\"check\" size=\"16\"></ore-icon> | <ore-icon name=\"check\" size=\"16\"></ore-icon> | <ore-icon name=\"check\" size=\"16\"></ore-icon> |\n| Memory history (tests / non-browser) | <ore-icon name=\"check\" size=\"16\"></ore-icon> | <ore-icon name=\"x\" size=\"16\"></ore-icon> | <ore-icon name=\"x\" size=\"16\"></ore-icon> |\n| Typed path params | <ore-icon name=\"check\" size=\"16\"></ore-icon> | <ore-icon name=\"x\" size=\"16\"></ore-icon> | <ore-icon name=\"x\" size=\"16\"></ore-icon> |\n| Named navigation | <ore-icon name=\"check\" size=\"16\"></ore-icon> | <ore-icon name=\"x\" size=\"16\"></ore-icon> | Partial |\n| Middleware | <ore-icon name=\"check\" size=\"16\"></ore-icon> | <ore-icon name=\"check\" size=\"16\"></ore-icon> | <ore-icon name=\"check\" size=\"16\"></ore-icon> |\n| Data loaders with AbortSignal | <ore-icon name=\"check\" size=\"16\"></ore-icon> | <ore-icon name=\"x\" size=\"16\"></ore-icon> | <ore-icon name=\"x\" size=\"16\"></ore-icon> |\n| Declarative redirects | <ore-icon name=\"check\" size=\"16\"></ore-icon> | <ore-icon name=\"x\" size=\"16\"></ore-icon> | <ore-icon name=\"x\" size=\"16\"></ore-icon> |\n| Search param validation | <ore-icon name=\"check\" size=\"16\"></ore-icon> | <ore-icon name=\"x\" size=\"16\"></ore-icon> | <ore-icon name=\"x\" size=\"16\"></ore-icon> |\n| Error in state | <ore-icon name=\"check\" size=\"16\"></ore-icon> | <ore-icon name=\"x\" size=\"16\"></ore-icon> | <ore-icon name=\"x\" size=\"16\"></ore-icon> |\n| History state in context | <ore-icon name=\"check\" size=\"16\"></ore-icon> | <ore-icon name=\"x\" size=\"16\"></ore-icon> | <ore-icon name=\"x\" size=\"16\"></ore-icon> |\n| Leave guards | <ore-icon name=\"check\" size=\"16\"></ore-icon> | <ore-icon name=\"x\" size=\"16\"></ore-icon> | <ore-icon name=\"x\" size=\"16\"></ore-icon> |\n| Zero dependencies | <ore-icon name=\"check\" size=\"16\"></ore-icon> | <ore-icon name=\"check\" size=\"16\"></ore-icon> | <ore-icon name=\"check\" size=\"16\"></ore-icon> |\n\n## Installation\n\n::: code-group\n\n```sh [pnpm]\npnpm add @vielzeug/wayfinder\n```\n\n```sh [npm]\nnpm install @vielzeug/wayfinder\n```\n\n```sh [yarn]\nyarn add @vielzeug/wayfinder\n```\n\n:::\n\n## Quick Start\n\nCreate a memory-backed router, wait for initial routing, then navigate by name.\n\n```ts\nimport { createMemoryHistory, createRouter } from '@vielzeug/wayfinder';\n\nconst router = createRouter({\n history: createMemoryHistory('/'),\n routes: {\n home: { path: '/' },\n settings: { path: '/settings' },\n },\n});\n\nawait router.ready;\nawait router.navigate({ name: 'settings' });\nconsole.log(router.getSnapshot().location.pathname); // /settings\nrouter.dispose();\n```\n\n## Features\n\n<div class=\"features-grid\">\n\n- `createRouter()` — Compiles named, nested route tables.\n- `navigate()` — Commits route changes after middleware reaches its terminal stage.\n- `ready` — Signals that initial routing has settled.\n- `data()` — Receives cancellation through `AbortSignal` for in-flight loaders.\n- `beforeLeave()` — Blocks route exits before history changes.\n- `match()` / `load()` — Inspect routes synchronously or load detached route data without navigation.\n- `url()` / `href()` — Build base-aware URLs and anchor-ready hrefs for every history driver.\n- `preload()` — Warms and reuses loader results for the next matching client navigation.\n- `createViewRegistry()` — Resolves exhaustive typed route views with an explicit not-found fallback.\n- `scroll` / `viewTransition` — Coordinate browser effects at the navigation commit boundary.\n- `createHashHistory()` — Runs browser routes on static hosts that cannot rewrite deep links.\n- `createMemoryHistory()` — Runs routers in tests and non-browser environments.\n- `subscribe()` — Reactive subscription to navigation state changes.\n\n</div>\n\n## Documentation\n\n<div class=\"doc-links\">\n\n- [Usage Guide](./usage.md)\n- [API Reference](./api.md)\n- [Examples](./examples.md)\n- [Migration Guide](./migration.md)\n\n</div>\n\n## See Also\n\n<div class=\"see-also\">\n\n- [Ripple](/ripple/) — reactive signals; sync router state to a signal for framework-agnostic reactivity\n- [Ward](/ward/) — permission guards; use inside Wayfinder middleware to protect routes\n- [Herald](/herald/) — event bus; dispatch route-change events to decouple navigation side effects\n\n</div>\n\n<!-- markdownlint-enable MD025 MD033 MD060 -->\n",
|
|
5
|
+
"api": "---\ntitle: Wayfinder — API Reference\ndescription: Complete API reference for Wayfinder.\n---\n\n[[toc]]\n\n## API Overview\n\n| Symbol | Purpose | Execution mode | Common gotcha |\n| --------------------------------------- | ---------------------------------------------------------- | -------------------- | ------------------------------------------------------------------------------------------------------------ |\n| `createRouter(options)` | Create a router from a route table | Sync | Initial navigation starts asynchronously in the constructor |\n| `createRouteSignals(router)` | Mirror a router into ripple readables (name/params/query/state) | Sync | `router.getSnapshot()` alone is not ripple-reactive — use this bridge |\n| `createBrowserHistory()` | Create the default browser history driver | Sync | Requires server-side SPA rewrites |\n| `createHashHistory(options?)` | Create a static-host-safe hash history driver | Sync | Pass the same `base` to the history and router |\n| `createHistoryForBase(base)` | Pick browser vs hash history for a deploy base | Sync | Returns the hash driver for any base other than `'/'` |\n| `createMemoryHistory(initialPath?)` | Create an in-memory history driver | Sync | — |\n| `redirectTo(target, options?)` | Build redirect middleware | Sync (returns fn) | Does not call `next()` — always short-circuits the chain |\n| `router.navigate(target, options?)` | Navigate to a named route, raw path object, or string path | Async | No-op when destination equals current URL unless `force: true` |\n| `router.getSnapshot()` | Return the current immutable route state | Sync | Does not subscribe — call `subscribe()` to react to changes |\n| `router.subscribe(listener)` | Register a listener for state changes | Sync (returns unsub) | Listener is **not** called immediately with current state |\n| `router.url(name, params?, query?)` | Build a URL for a named route | Sync | Throws if the route name is unknown |\n| `router.href(name, params?, query?)` | Build an anchor-ready href for a named route | Sync | Hash-prefixed under `createHashHistory` |\n| `router.isActive(name, options?)` | Check if a named route matches the current URL | Sync | Compares against the current snapshot pathname, not `history.location` directly |\n| `router.match(pathname)` | Inspect a pathname as a branch without side effects | Sync | Returns `null` for redirect routes |\n| `router.load(url, options?)` | Load a detached URL state for SSR or prerendering | Async | Middleware is not executed and results are not cached |\n| `router.preload(target)` | Warm loaders for the next matching client navigation | Async | Params and query values are part of the cache key |\n| `router.createViewRegistry(views, options?)` | Create an exhaustive route-to-view resolver | Sync | Pass `options.notFound` to render the configured fallback |\n| `router.ready` | Await the initial navigation | Async | Rejects when initial loading fails |\n| `router.waitFor(name)` | Wait for the router to settle on a named route | Async | Rejects immediately if `status === 'error'`; rejects with `WayfinderDisposedError` if disposed while pending |\n| `router.beforeLeave(blocker, options?)` | Register a global leave guard | Sync (returns unsub) | Scoped to specific routes via `options.routes` |\n| `router.dispose()` | Remove listeners and shut down the router | Sync | Idempotent — safe to call multiple times |\n\n## Package Entry Points\n\n| Import | Purpose |\n| --------------------- | ---------------------- |\n| `@vielzeug/wayfinder` | Main exports and types |\n\n## `createRouter(options)`\n\n```ts\nimport { createRouter } from '@vielzeug/wayfinder';\n\nconst router = createRouter({\n base: '/app',\n routes: {\n home: { path: '/' },\n dashboard: {\n path: '/dashboard',\n children: {\n index: { index: true },\n settings: { path: 'settings', data: () => fetchSettings() },\n },\n },\n },\n notFound: { data: () => ({ message: 'Page not found' }) },\n});\n```\n\n| Option | Type | Default | Description |\n| -------------- | ---------------------------------------------- | ------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------- |\n| `base` | `string` | `'/'` | Base path prefix for all routes |\n| `coerceSearch` | `CoerceSearchFn` | — | Global search-param coercion applied to every route that does not define its own `coerceSearch`. Throwing falls back to raw strings and is reported via `onError`. |\n| `history` | `HistoryDriver` | `createBrowserHistory()` | History source used for reading locations and writing navigations |\n| `middleware` | `Middleware[]` | `[]` | Global middleware prepended to every route |\n| `notFound` | `{ data?, middleware? }` | — | Synthetic route used when no path matches. Global middleware runs first, then `notFound.middleware` and `notFound.data`. `ctx.pathname` is the unmatched path. |\n| `onError` | `(error, context: RouterErrorContext) => void` | — | Optional sink for non-awaited/background router errors |\n| `routes` | `RouteTable` | required | Declarative route table. Object key order defines match precedence. |\n| `scroll` | `(to, from) => ScrollDecision` | — | Apply scroll behavior after successful navigation |\n| `viewTransition` | `boolean` | `false` | Wrap navigation commits in the browser View Transition API when available |\n\n**Returns:** `Router`\n\n## Route Table\n\nDefine routes as a plain object where keys become route names. TypeScript will infer route params from literal `path` strings.\n\n```ts\nconst routes = {\n home: { path: '/' },\n userDetail: { path: '/users/:id' },\n files: { path: '/files/:rest*' },\n};\n```\n\nNested routes are declared with `children`, and child names become compound names with dot notation.\n\n## Route Definition\n\n```ts\nconst routes = {\n home: { path: '/' },\n dashboard: {\n path: '/dashboard',\n middleware: [requireAuth],\n children: {\n index: { index: true },\n settings: {\n path: 'settings',\n data: async () => fetchSettings(),\n },\n },\n },\n userDetail: {\n path: '/users/:id',\n data: async ({ params }) => fetchUser(params.id),\n onError: (error) => ({ error, user: null }),\n },\n};\n```\n\nEach route definition supports these fields:\n\n| Field | Type | Description |\n| -------------- | -------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ |\n| `path` | `string` | Wayfinder pattern. Supports static paths, `:param`, `:param*`, and `*`. Child paths are relative unless they start with `/`. |\n| `children` | `Record<string, RouteDefinition>` | Nested child routes. Child names are appended to the parent route name. |\n| `index` | `boolean` | Default child route that inherits the parent path. |\n| `data` | `DataFn` | Data loader. Runs after middleware; result available as `match.data`. |\n| `middleware` | `Middleware[]` | Optional route-specific middleware |\n| `onError` | `(error, context: DataContext) => MaybePromise<unknown>` | Per-route error boundary for data loader failures. Return value becomes `match.data` for degraded rendering. |\n| `redirect` | `NavigationTarget` | Declarative redirect. Resolved before middleware runs; uses `replaceState` so the original URL is never added to history. |\n| `coerceSearch` | `(raw: QueryParams) => ResolvedQueryParams` | Coerce raw URL string values into typed values. Return value replaces `ctx.query`. Throwing leaves the parsed query unchanged. |\n\n## `createBrowserHistory()`\n\n```ts\nimport { createBrowserHistory } from '@vielzeug/wayfinder';\n\nconst history = createBrowserHistory();\n```\n\nCreate the default `HistoryDriver` backed by the browser History API.\n\n## `createHashHistory(options?)`\n\n```ts\nimport { createHashHistory, createRouter } from '@vielzeug/wayfinder';\n\nconst base = '/my-app/';\nconst router = createRouter({\n base,\n history: createHashHistory({ base }),\n routes,\n});\n```\n\nCreate a browser history driver that stores the route after `#`. Use it for static hosts that cannot rewrite deep links to the SPA entry file. `push()`, `replace()`, query strings, route hashes, state, and back navigation follow the `HistoryDriver` contract.\n\n## `createHistoryForBase(base)`\n\n```ts\nimport { createHistoryForBase, createRouter } from '@vielzeug/wayfinder';\n\nconst base = import.meta.env.BASE_URL;\nconst router = createRouter({ base, history: createHistoryForBase(base), routes });\n```\n\nChoose the history driver for a deploy base: `createBrowserHistory()` when `base` is `'/'` (clean paths), `createHashHistory({ base })` otherwise (deep links survive a static host that cannot rewrite them). Removes the `base === '/' ? … : …` branch every app otherwise hand-rolls.\n\n**Returns:** `HistoryDriver`\n\n## `createMemoryHistory(initialPath?)`\n\n```ts\nimport { createMemoryHistory } from '@vielzeug/wayfinder';\n\n// Tests\nconst router = createRouter({\n history: createMemoryHistory('/dashboard'),\n routes,\n});\n\n// Controlled non-browser runtime\nconst router = createRouter({\n history: createMemoryHistory('/request-path'),\n routes,\n});\n```\n\nCreate an in-memory `HistoryDriver`. No browser history globals required — suitable for unit tests and controlled non-browser runtimes. The optional `initialPath` defaults to `'/'`.\n\n## `Router`\n\n### Lifecycle\n\n#### `router.dispose()`\n\nRemove listeners, clear subscribers, and reject future router interaction. Idempotent — safe to call multiple times.\n\n**Returns:** `void`\n\n**Throws:** Never.\n\n---\n\n#### `router.disposed`\n\n`boolean` — `true` after `dispose()` has been called.\n\n---\n\n#### `router.disposalSignal`\n\n`AbortSignal` that is aborted (with a `WayfinderDisposedError` reason) when the router is disposed. Use this to tie external resource lifetimes to the router's lifetime.\n\n```ts\nsource.on('update', syncRouteParams, { signal: router.disposalSignal });\n```\n\n---\n\n### Navigation\n\n#### `router.navigate(target, options?)`\n\n```ts\nawait router.navigate({ name: 'userDetail', params: { id: '42' } });\nawait router.navigate({ name: 'userDetail', params: { id: '42' } }, { replace: true });\nawait router.navigate({ name: 'search', query: { q: 'wayfinder' }, hash: 'results' });\n```\n\n| Option | Type | Default | Description |\n| --------- | --------- | ------- | ------------------------------------------------------- |\n| `replace` | `boolean` | `false` | Use `replaceState` instead of `pushState` |\n| `state` | `unknown` | — | History state payload |\n| `force` | `boolean` | `false` | Re-run even when the destination URL is already current |\n| `viewTransition` | `boolean` | router default | Override view-transition behavior for this navigation |\n\n**Returns:** `Promise<void>`\n\nHistory is written only after middleware reaches the terminal stage. Returning from middleware without `next()` cancels the programmatic navigation without changing history or the route snapshot.\n\nNamed routes stay the primary API, but `navigate()` also accepts raw path objects or a plain string:\n\n```ts\nawait router.navigate({ path: '/marketing?utm_source=campaign' });\nawait router.navigate({ path: '/checkout#payment' }, { replace: true });\n\n// Plain string — most concise for direct paths\nawait router.navigate('/about');\nawait router.navigate('/search?q=hello');\n```\n\n---\n\n### Route Helpers\n\n#### `router.url(name, params?, query?)`\n\n```ts\nrouter.url('userDetail', { id: '42' });\nrouter.url('userDetail', { id: '42' }, { tab: 'profile' });\n```\n\nBuild a base-aware URL for a named route.\n\n**Returns:** `string`\n\n#### `router.href(name, params?, query?)`\n\n```ts\nrouter.href('userDetail', { id: '42' });\n```\n\nBuild the address-bar form of a named route for anchor elements. Under `createBrowserHistory()` and `createMemoryHistory()` it matches `url()`; under `createHashHistory()` the route lives behind `#`, and `href()` returns that form (`/my-app/#/users/42`).\n\n**Returns:** `string`\n\n#### `router.isActive(name, options?)`\n\n```ts\nrouter.isActive('userDetail');\nrouter.isActive('users');\nrouter.isActive('users', { exact: true });\n```\n\nCheck whether the current pathname matches a named route exactly or by prefix.\n\n**Returns:** `boolean`\n\n#### `router.match(pathname)`\n\n```ts\nrouter.match('/app/dashboard/settings');\n// => [\n// { name: 'dashboard', ... },\n// { name: 'dashboard.settings', ... },\n// ]\n```\n\nInspect a pathname without running middleware, data loaders, or subscribers. Strips the configured `base` automatically. Returns the matched branch from root to leaf, or `null` for redirect routes and no-match.\n\n**Returns:** `RouteMatchBranch | null`\n\n---\n\n#### `router.load(url, options?)`\n\n```ts\n// SSR data prefetch\nconst state = await router.load('/users/42');\n\n// With cancellation\nconst controller = new AbortController();\nconst state = await router.load('/dashboard', { signal: controller.signal });\n```\n\nLoad a full URL into a `RouteState` including data loader results, without modifying router state or history. Follows declarative redirects (up to five hops). Returns `null` for unmatched URLs.\n\nMiddleware is **not** executed — `load` is a data-only prefetch for SSR and pre-rendering where middleware side effects are not wanted. If your data loaders depend on `ctx.locals` set by middleware, use `navigate()` instead.\n\nWhen a `data()` function throws, the returned state has `status: 'error'` and `error` set to the thrown value.\n\n**Returns:** `Promise<RouteState | null>`\n\n---\n\n#### `router.preload(target)`\n\n```ts\nconst state = await router.preload({\n name: 'userDetail',\n params: { id: '42' },\n query: { tab: 'profile' },\n});\n```\n\nExecutes data loaders without changing router state or history. Concurrent calls for the same target are deduplicated. A subsequent navigation with the same route, params, and query consumes the cached data instead of rerunning loaders.\n\n**Returns:** `Promise<RouteState | null>`\n\n---\n\n#### `router.createViewRegistry(views, options?)`\n\n```ts\nconst views = router.createViewRegistry(\n { home: HomePage, userDetail: UserPage },\n { notFound: NotFoundPage },\n);\n\nconst Component = views.resolve(router.getSnapshot());\n```\n\nRequires one value for every renderable route name, excludes redirect-only routes, and rejects unknown keys. Values may be framework components, lazy factories, or richer presentation descriptors. `options.notFound` handles the synthetic fallback without exposing its internal route name.\n\n**Returns:** `RouteViewRegistry`\n\n---\n\n#### `router.waitFor(name)`\n\n```ts\n// Navigate and wait for data to settle\nawait router.navigate({ name: 'userDetail', params: { id: '42' } });\nconst state = await router.waitFor('userDetail');\nconst user = state.matches.at(-1)?.data;\n\n// Useful in tests with memory history:\nconst history = createMemoryHistory('/dashboard');\nconst router = createRouter({ history, routes });\nconst state = await router.waitFor('dashboard');\n```\n\nWaits for the router to reach `status: 'idle'` with the named route active in the matched branch. Rejects immediately if `status === 'error'`. Resolves immediately if the router is already idle on the target route. Also rejects if `router.dispose()` is called while the promise is pending.\n\n**Returns:** `Promise<RouteState>`\n\n---\n\n#### `router.beforeLeave(blocker, options?)`\n\n```ts\n// Guard unsaved-changes forms\nconst remove = router.beforeLeave(async (destination) => {\n if (!form.isDirty) return true;\n return confirm(`Leave without saving? (going to ${destination.pathname})`);\n});\n\n// Remove the guard when the form unmounts\nremove();\n```\n\nRegister a global leave guard called before user-triggered navigation attempts. Return `true` to allow, `false` to cancel. Multiple guards can be registered; navigation is blocked if any guard returns `false`.\n\nScope a guard to fire only when leaving specific routes using the `routes` option:\n\n```ts\nrouter.beforeLeave(async () => confirm('Discard changes?'), { routes: ['editor'] });\n```\n\nThe guard fires when the router is leaving any route whose name appears in the `routes` array (any node in the active branch, not just the leaf). Declarative `redirect` routes bypass all leave guards.\n\n**Returns:** `() => void`\n\n## `createPhaseMirror(options)`\n\nMirrors a domain state machine's phase into a `/:subject/:id/:phase` route segment — the shared routing approach for stepped flows (wizards, chapters, onboarding). Two-way, with the state machine as the sole source of truth:\n\n- **Domain → URL**: every phase change lands on the phase's route, replacing so the browser back button exits the flow rather than stepping between phases.\n- **URL → domain**: a mismatched phase route is a revisit request (`requestRevisit` — the consumer owns its confirm dialog) when `canRevisit(current, target)` allows it, otherwise a bounce back to the actual phase. Reacts to the URL and to the domain loading, never to domain-led changes. The mirror stops following entirely when the route leaves the flow or names a different subject.\n- **Canonicalization**: the bare `detailRoute` URL redirects onto the current phase's route, so every arrival is shareable and reload-safe.\n\nOptions carry the router, the valid `phases`, both route names, and ripple `Readable`s for `currentPhase` and `subjectId` (`null` while the subject is missing). The mirror exposes `routePhase`, `revisitPhases` (readables), `phaseHref`, `followPhase`, `bounceIfMismatched` (the dialog cancel path), and `dispose()`.\n\n## `createRouteSignals(router)`\n\n```ts\nimport { createRouteSignals } from '@vielzeug/wayfinder';\n\nconst route = createRouteSignals(router);\n\nconst activeName = computed(() => route.name.value); // re-computes on every navigation\n```\n\nMirrors a router into ripple readables so templates and computeds react to navigation. `router.getSnapshot()` alone is **not** ripple-reactive: reading it inside a `computed()` computes once and never re-runs, because it registers no tracked dependency. This bridge wraps `subscribe()`/`getSnapshot()` with `fromSubscribable` once, for every consumer.\n\n**Returns:** `RouteSignals<TRoutes>` — `name` (deepest matched route name, `null` while nothing matches), `params` (its path params), `query` (raw string-valued query params of the current location), and `state` (the full `RouteState`, for deriving further computeds).\n\n## `redirectTo(target, options?)`\n\n```ts\nimport { redirectTo } from '@vielzeug/wayfinder';\n\nconst requireAuth = redirectTo({ name: 'login' }, { replace: true });\n```\n\nCreates middleware that navigates to `target` and short-circuits the middleware chain (does not call `next()`). Useful for auth guards and route aliases in middleware.\n\nFor permanent declarative redirects (URL aliases), use the `redirect` field on the route definition instead.\n\n> **Note:** `redirectTo()` internally calls `ctx.navigate()`, which runs `beforeLeave` guards. If a guard blocks navigation, the redirect will not complete. Declarative `redirect` on a route definition bypasses guards entirely.\n\n**Returns:** `Middleware`\n\n---\n\n### State\n\n#### `router.ready`\n\nA `Promise<void>` for the constructor-triggered navigation. It resolves after initial middleware, redirects, and data loaders settle. It resolves after a blocked or unmatched initial navigation, and rejects if initial navigation fails.\n\n```ts\nconst router = createRouter({ routes });\nawait router.ready;\n```\n\n---\n\n#### `router.getSnapshot()`\n\nReturns the current immutable route state snapshot. Use this to read state synchronously. Compatible with React's `useSyncExternalStore`:\n\n```ts\nconst state = useSyncExternalStore(\n (cb) => router.subscribe(cb),\n () => router.getSnapshot(),\n);\n```\n\n```ts\nconst { location, matches, status, error } = router.getSnapshot();\n\nlocation.pathname;\nlocation.query; // raw parsed query (QueryParams) — always string values\nlocation.hash;\nlocation.historyState; // value passed to navigate({ ... }, { state: ... })\n\n\n// When status === 'error':\nconsole.error(error);\n```\n\n`error` is only set when `status === 'error'`. It holds the exact value thrown by the failing `data()` function.\n\n**Returns:** `RouteState`\n\n#### `router.subscribe(listener)`\n\n```ts\nconst unsubscribe = router.subscribe((state) => {\n const leaf = state.matches.at(-1);\n // Read data from the leaf match\n console.log(leaf?.data);\n});\n```\n\nRegister a listener for future state changes. The listener is **not** called with the current snapshot — call `router.getSnapshot()` when you subscribe if you need it.\n\n**Returns:** `() => void`\n\n## Types\n\n### `RouteContext<Params, TRoutes>`\n\nContext passed to middleware and data loader functions.\n\n```ts\ntype RouteContext<Params extends RouteParams = RouteParams, TRoutes extends RouteTable = RouteTable> = {\n readonly hash: string;\n /** State stored on the history entry that triggered this navigation. */\n readonly historyState: unknown;\n locals: Record<string, unknown>;\n readonly matches: RouteMatchBranch;\n readonly navigate: (\n target: NamedNavigationTarget<TRoutes> | RawNavigationTarget | string,\n options?: NavigateOptions,\n ) => Promise<void>;\n readonly params: Params;\n readonly pathname: string;\n readonly query: ResolvedQueryParams;\n};\n```\n\n`ctx.locals` is mutable and shared across the entire middleware chain for one navigation. Use it to pass values from middleware to data loaders.\n\n`ctx.query` is the coerced query (after `coerceSearch`). `router.getSnapshot().location.query` always contains raw string values from URL parsing.\n\n### `DataFn<Params, TRoutes>`\n\n```ts\ntype DataFn<Params extends RouteParams = RouteParams, TRoutes extends RouteTable = RouteTable> = (\n context: DataContext<Params, TRoutes>,\n) => MaybePromise<unknown>;\n```\n\nData loader function. The return value becomes `match.data` on the leaf route match.\n\n### `DataContext<Params, TRoutes>`\n\n```ts\ntype DataContext<Params extends RouteParams = RouteParams, TRoutes extends RouteTable = RouteTable> = RouteContext<\n Params,\n TRoutes\n> & {\n readonly signal: AbortSignal;\n};\n```\n\nThe `signal` is aborted when a newer navigation supersedes the current one. Use it to cancel in-flight fetches or other async work.\n\n### `Middleware<TRoutes>`\n\n```ts\ntype Middleware<TRoutes extends RouteTable = RouteTable> = (\n context: RouteContext<RouteParams, TRoutes>,\n next: () => Promise<void>,\n) => void | Promise<void>;\n```\n\nMiddleware ordering is simple: global middleware first, then route middleware, then `data()`.\n\n### `UntypedNamedNavigationTarget`\n\n```ts\ntype UntypedNamedNavigationTarget = {\n hash?: string;\n name: string;\n params?: RouteParams;\n query?: ResolvedQueryParams;\n};\n```\n\n### `NavigationTarget`\n\n```ts\ntype NavigationTarget =\n | {\n path: string;\n }\n | {\n hash?: string;\n name: string;\n params?: RouteParams;\n query?: ResolvedQueryParams;\n };\n```\n\n### `NavigateOptions`\n\n```ts\ntype NavigateOptions = {\n force?: boolean;\n replace?: boolean;\n state?: unknown;\n};\n```\n\n### `RouteState`\n\n```ts\ntype RouteState = {\n /** The value thrown by a `data()` function. Only set when `status === 'error'`. */\n readonly error?: unknown;\n readonly location: RouteLocation;\n readonly matches: readonly RouteMatch[];\n readonly status: NavigationStatus;\n};\n\ntype RouteLocation = {\n readonly hash: string;\n /** State stored on the history entry that triggered this navigation. */\n readonly historyState: unknown;\n readonly pathname: string;\n /** Raw parsed query params — always string values from URL parsing.\n * For coerced values (numbers, booleans), read `ctx.query` inside middleware or data loaders.\n */\n readonly query: QueryParams;\n};\n```\n\n### `RouteMatch`\n\n```ts\ntype RouteMatch = {\n /** Result of the route's `data()` function, or `undefined` if none was defined. */\n readonly data: unknown;\n readonly name: string;\n readonly params: RouteParams;\n readonly pathname: string;\n};\n```\n\n### `RouteMatchBranch`\n\n```ts\ntype RouteMatchBranch = readonly RouteMatch[];\n```\n\n### `RouteViewName`, `RouteViewMap`, and `RouteViewRegistry`\n\n`RouteViewName<TRoutes>` contains renderable route names and excludes redirect-only routes. `RouteViewMap<TRoutes>` requires one value per renderable route. `RouteViewRegistry<TView>` resolves the active view from a `RouteState` without exposing the synthetic not-found name.\n\n### `ScrollDecision`\n\n```ts\ntype ScrollDecision = 'preserve' | 'top' | { x: number; y: number };\n```\n\n### `PathParams<T>`\n\n```ts\ntype UserParams = PathParams<'/users/:id'>;\n// => { readonly id: string }\n\ntype FileParams = PathParams<'/files/:rest*'>;\n// => { readonly rest: string }\n```\n\n### `QueryParams`\n\n```ts\ntype QueryParams = Record<string, string | string[]>;\n```\n\nRepresents parsed URL query values before route-level coercion.\n\n### `ResolvedQueryParams`\n\n```ts\ntype ResolvedQueryValue = string | number | boolean;\ntype ResolvedQueryParams = Record<string, ResolvedQueryValue | ResolvedQueryValue[]>;\n```\n\nRepresents the query object after optional `coerceSearch` normalization.\n\n### `NavigationStatus`\n\n```ts\ntype NavigationStatus = 'idle' | 'loading' | 'error';\n```\n\nTop-level status of the router.\n\n- `idle` — navigation settled successfully.\n- `loading` — data loaders are in-flight.\n- `error` — a data loader threw and no route-level `onError` handled it.\n\n### `RouteMiddleware<Path, TRoutes>`\n\n```ts\ntype RouteMiddleware<Path extends string = string, TRoutes extends RouteTable = RouteTable> = (\n context: RouteContext<PathParams<Path>, TRoutes>,\n next: () => Promise<void>,\n) => void | Promise<void>;\n```\n\nTyped variant of `Middleware` scoped to a route path. Provides typed `ctx.params` matching the path pattern.\n\n```ts\nconst guard: RouteMiddleware<'/users/:id'> = (ctx, next) => {\n console.log(ctx.params.id); // string\n return next();\n};\n```\n\n### `CoerceSearchFn<Q>`\n\n```ts\ntype CoerceSearchFn<Q extends ResolvedQueryParams = ResolvedQueryParams> = (\n raw: QueryParams,\n) => Q;\n```\n\nFunction signature for both the per-route `coerceSearch` field and the global `RouterOptions.coerceSearch` option. Receives raw URL strings and returns typed values. Throwing inside the function falls back to the original raw query.\n\n### `BeforeLeaveOptions<TRoutes>`\n\n```ts\ntype BeforeLeaveOptions<TRoutes extends RouteTable = RouteTable> = {\n /** Route names that trigger this guard. Omit for a global guard. */\n routes?: RouteName<TRoutes>[];\n};\n```\n\nPassed as the second argument to `router.beforeLeave()`. When `routes` is provided, the guard only fires when the router leaves a route whose name is in the array.\n\n### `BeforeLeaveBlocker`\n\n```ts\n// Return true to allow navigation, false to cancel.\ntype BeforeLeaveBlocker = (destination: NavigationDestination) => MaybePromise<boolean>;\n```\n\n### `NavigationDestination`\n\n```ts\ntype NavigationDestination = {\n readonly name?: string; // route name if navigating to a named route\n readonly params: RouteParams;\n readonly pathname: string;\n readonly query: QueryParams;\n};\n```\n\nPassed to every `beforeLeave` blocker. Use `destination.pathname` and `destination.query` to make context-aware allow/block decisions.\n\n### `IsActiveOptions`\n\n```ts\ntype IsActiveOptions = {\n /** Require an exact pathname match. Defaults to prefix matching. */\n exact?: boolean;\n};\n```\n\n### `RouterErrorContext`\n\n```ts\ntype RouterErrorContext =\n | { routeName: string; source: 'data-loader' } // data() threw\n | { routeName: string; source: 'middleware' } // middleware threw\n | { source: 'coerce-search' | 'history-listener' | 'initial-navigation' | 'preload' };\n```\n\nPassed to the `onError` callback in `createRouter` options. The `routeName` is present when the error originates from a named route's `data()` or `middleware`.\n\n### `HistoryDriver`\n\n```ts\ninterface HistoryDriver {\n readonly location: {\n readonly hash: string;\n readonly pathname: string;\n readonly search: string;\n readonly state: unknown;\n };\n /** Navigate one entry back in history, equivalent to the browser back button. */\n back(): void;\n push(url: string, state?: unknown): void;\n replace(url: string, state?: unknown): void;\n /**\n * Subscribe to backwards/forwards navigation (popstate-equivalent).\n * `push()` and `replace()` are silent — they do not notify subscribers.\n * Only `back()` (and browser popstate events) trigger notifications.\n * Returns an unsubscribe function.\n */\n onPopstate(listener: () => void): () => void;\n}\n```\n\n### `RouteDefinition<Path>`\n\n```ts\ntype RouteDefinition<Path extends string = string> =\n | ContentRouteDefinition<Path> // path + data/middleware/coerceSearch/onError\n | RedirectRouteDefinition<Path>; // path + redirect\n```\n\nThe union type for a single entry in the route table. Use this to type externally-defined route objects:\n\n```ts\nimport type { RouteDefinition } from '@vielzeug/wayfinder';\n\nconst userDetail: RouteDefinition<'/users/:id'> = {\n path: '/users/:id',\n data: async ({ params }) => fetchUser(params.id),\n};\n```\n\n### `RouterOptions<TRoutes>`\n\nThe options object accepted by `createRouter()`. See the [`createRouter(options)`](#createrouter-options) options table above for the full field reference.\n\n```ts\nimport type { RouterOptions } from '@vielzeug/wayfinder';\n\nconst options: RouterOptions<typeof routes> = {\n routes,\n base: '/app',\n};\n```\n\n### `Unsubscribe`\n\n```ts\ntype Unsubscribe = () => void;\n```\n\n## Errors\n\n### `WayfinderError`\n\nBase class for every error Wayfinder throws. Catch this to handle any router-originated error without enumerating subclasses.\n\n```ts\nimport { WayfinderError } from '@vielzeug/wayfinder';\n\ntry {\n await router.navigate({ name: 'home' });\n} catch (e) {\n if (e instanceof WayfinderError) {\n // any router-originated error — check e.name or `instanceof` a subclass for detail\n }\n}\n```\n\n### `WayfinderDisposedError`\n\nThrown when `navigate()`, `subscribe()`, `beforeLeave()`, or `waitFor()` is called after `dispose()`. Also used as the `AbortSignal.reason` on `disposalSignal`.\n\n```ts\nimport { WayfinderDisposedError } from '@vielzeug/wayfinder';\n\ntry {\n await router.navigate({ name: 'home' });\n} catch (e) {\n if (e instanceof WayfinderDisposedError) {\n // router was disposed\n }\n}\n```\n\n### `WayfinderConfigError`\n\nThrown at `createRouter()` time when the route table or a path pattern is malformed — for example a route that sets both `index: true` and `path`, a duplicate route name, or a wildcard that is not the final path segment.\n\n### `WayfinderRouteError`\n\nThrown at navigation time when a `url()`/`navigate()` call references an unknown route name or omits a path param the pattern requires.\n\n### `WayfinderRedirectLoopError`\n\nThrown when a chain of declarative `redirect`s (or a mix of declarative redirects and `ctx.navigate()` calls inside route middleware) exceeds 5 hops.\n\n### `WayfinderApiError`\n\nThrown on middleware misuse — currently only when a middleware function calls its `next()` more than once.\n\n### Runtime error messages\n\n| Message | Class | When |\n| ---------------------------------------------------------------- | ----------------------------- | ---------------------------------------------------------------------- |\n| `Router is disposed` | `WayfinderDisposedError` | Calling a guarded method (see above) after `dispose()` |\n| `Unknown route name: X. Available routes: Y` | `WayfinderRouteError` | Navigating to, resolving, or building a URL for an unregistered route |\n| `Route \"X\" cannot define both index and path` | `WayfinderConfigError` | A route sets `index: true` and `path` at the same time |\n| `Route \"X\" must define path or set index: true` | `WayfinderConfigError` | A route defines neither `index: true` nor `path` |\n| `Duplicate route name: \"X\"` | `WayfinderConfigError` | Two routes resolve to the same compound name during `createRouter()` |\n| `Missing path param: X` | `WayfinderRouteError` | `url()`/`navigate()` omits a param the path pattern requires |\n| `Invalid param name \":X\" in path \"Y\"` | `WayfinderConfigError` | A param name contains non-word characters (e.g., `:user-id`) |\n| `Wildcard \"*\" must be the final segment in path: X` | `WayfinderConfigError` | A `*` segment appears before the last segment |\n| `Wildcard param must be final segment in path: X` | `WayfinderConfigError` | A `:param*` greedy param appears before the last segment |\n| `Redirect loop detected` | `WayfinderRedirectLoopError` | A declarative `redirect` chain (or mixed redirect + `navigate()`) exceeds 5 hops |\n| `next() called multiple times` | `WayfinderApiError` | Middleware calls its `next()` callback more than once |\n\n## Pattern Rules\n\n| Pattern | Example | Meaning |\n| ------------------------------ | ------------------- | ------------------------------------------- |\n| `/about` | `/about` | Exact static path |\n| `/users/:id` | `/users/42` | Single named param |\n| `/users/:userId/posts/:postId` | `/users/1/posts/2` | Multiple named params |\n| `/docs/*` | `/docs/guide/intro` | Wildcard suffix without a named capture |\n| `/files/:rest*` | `/files/a/b/c` | Wildcard suffix captured as one named param |\n| `*` | anything | Global catch-all |\n\n## Design Notes\n\n- Wayfinder is a routing core: route compilation, matching, history, navigation, and cancellation. Data loading is an optional layer via per-route `data()` functions. UI rendering is owned by the framework adapter, not Wayfinder.\n- Wayfinder no longer exposes imperative registration methods like `on()`, `group()`, or `use()`.\n- Wayfinder names come from the route-table object keys.\n- `data()` is the terminal action. Its return value becomes `match.data`. There is no separate `handler` step.\n- For unmatched URLs, use the `notFound` router option rather than `path: '*'` in the route table.\n- Error handling is middleware that wraps `await next()`. The thrown error is also stored on `router.getSnapshot().error`. The original error object is never mutated — error context is carried in internal wrappers and the original `cause` chain is preserved.\n- Declarative `redirect` on a route definition is for permanent alias redirects. The `redirectTo()` middleware helper is for conditional guards.\n- `onError` in a route definition is a per-route data-loader boundary. If `onError` itself throws, the router falls through to `status: 'error'` as usual.\n",
|
|
6
|
+
"usage": "---\ntitle: Wayfinder — Usage Guide\ndescription: Router setup, middleware, data loading, nested routes, and state patterns for Wayfinder.\n---\n\n[[toc]]\n\n::: tip New to Wayfinder?\nStart with the [Overview](./index.md), then use this page for the day-to-day API.\n:::\n\n## Mirror a State Machine into the URL\n\nA stepped flow's URL should name the step the domain is on — shareable, reload-safe, and never ahead of the truth. `createPhaseMirror` wires a `Router` to the domain phase as a ripple `Readable`, replacing (never pushing) so back exits the flow.\n\n```ts\nimport { createPhaseMirror } from '@vielzeug/wayfinder';\nimport { signal } from '@vielzeug/ripple';\n\nconst currentPhase = signal<'story' | 'preparing' | 'fight' | null>(null);\nconst subjectId = signal<string | null>(null);\n\nconst mirror = createPhaseMirror({\n canRevisit: (current, target) => current === 'fight' && target === 'preparing',\n currentPhase,\n detailRoute: 'easyPathDetail',\n phases: ['story', 'preparing', 'fight'],\n phaseRoute: 'easyPathPhase',\n requestRevisit: (phase) => openConfirmDialog(phase),\n router,\n subjectId,\n});\n\nmirror.routePhase.value; // the phase segment of the current route, or null\nmirror.phaseHref('preparing'); // anchor href for a tracker link\nmirror.dispose(); // stop mirroring\n```\n\nThe consumer supplies reactivity; the mirror never writes history. A Vue app bridges its reactive getters into ripple signals and disposes the mirror with its component scope.\n\n## Basic Usage\n\nCreate a deterministic router with memory history, wait for startup, and navigate by route name.\n\n```ts\nimport { createMemoryHistory, createRouter } from '@vielzeug/wayfinder';\n\nconst router = createRouter({\n history: createMemoryHistory('/'),\n routes: {\n home: { path: '/' },\n settings: {\n data: async () => ({ section: 'settings' }),\n path: '/settings',\n },\n },\n});\n\nawait router.ready;\nawait router.navigate({ name: 'settings' });\nconsole.log(router.getSnapshot().matches.at(-1)?.data);\nrouter.dispose();\n```\n\n`routes` is required. Route keys become names, and object key order controls match precedence.\n\n## Define Routes\n\nEach route can provide these fields:\n\n| Field | Purpose |\n| -------------- | --------------------------------------------------------------------------------------------------------------------------- |\n| `path` | Match pattern |\n| `children` | Nested child routes |\n| `index` | Default child route that inherits the parent path |\n| `data` | Abortable route data function. Result available as `match.data`. |\n| `middleware` | Route-specific middleware |\n| `onError` | Per-route error boundary. Called when this route's `data()` throws; its return value becomes `match.data`. |\n| `redirect` | Declarative permanent redirect. Resolved before middleware runs. |\n| `coerceSearch` | Coerce raw URL search strings into typed values. Return value replaces `ctx.query`. Throw to leave the raw query unchanged. |\n\nUse wildcard routes for fallback behavior:\n\n```ts\nconst routes = {\n docs: { path: '/docs/*' },\n};\n```\n\nFor a catch-all not-found page, use the `notFound` option in router options instead of a `path: '*'` route:\n\n```ts\nconst router = createRouter({\n routes,\n notFound: {\n data: async ({ pathname }) => ({ requestedPath: pathname }),\n },\n});\n```\n\nAlternatively, `path: '*'` still works as a named route when you need to navigate to it explicitly.\n\nNested routes compose naturally and create compound route names:\n\n```ts\nconst routes = {\n dashboard: {\n path: '/dashboard',\n children: {\n index: { index: true },\n settings: { path: 'settings' },\n },\n },\n};\n\nawait router.navigate({ name: 'dashboard.settings' });\n```\n\n## Route Context\n\nMiddleware and data loaders receive a `RouteContext`:\n\n```ts\nuserDetail: {\n path: '/users/:id',\n middleware: [\n (ctx, next) => {\n ctx.params.id; // typed to path params\n ctx.query.tab; // resolved query (after coerceSearch)\n ctx.pathname;\n ctx.hash;\n ctx.historyState; // value from navigate({ ... }, { state: ... })\n ctx.locals; // mutable bag shared across the middleware chain\n ctx.navigate; // programmatic navigation\n return next();\n },\n ],\n data: async (ctx) => {\n ctx.signal; // AbortSignal — cancelled when navigation is superseded\n return fetchUser(ctx.params.id, { signal: ctx.signal });\n },\n}\n```\n\n`ctx.locals` is mutable and shared through the entire middleware chain for one navigation. Use it to pass values from middleware to data loaders.\n\n## Middleware\n\nMiddleware wraps the navigation using the familiar `async (ctx, next) => { ... }` shape.\n\n```ts\nconst requireAuth = redirectTo({ name: 'login' }, { replace: true });\n\nconst loadCurrentUser = async (ctx, next) => {\n ctx.locals.user = await fetchCurrentUser();\n await next();\n};\n```\n\nOrder is fixed and simple:\n\n```text\nglobal middleware\n ↓\nroute middleware\n ↓\ndata()\n```\n\n### Guards\n\nUse middleware for auth checks, redirects, analytics, and boundaries.\n\n```ts\nconst requireAuth = async (ctx, next) => {\n if (!session.currentUser) {\n await ctx.navigate({ name: 'login' }, { replace: true });\n return; // do not call next()\n }\n ctx.locals.user = session.currentUser;\n await next();\n};\n```\n\nFor unconditional redirects, use the `redirectTo()` helper:\n\n```ts\nimport { redirectTo } from '@vielzeug/wayfinder';\n\nconst requireAuth = redirectTo({ name: 'login' }, { replace: true });\n```\n\nFor permanent URL aliases, use the declarative `redirect` field instead of middleware:\n\n```ts\nconst routes = {\n profile: { path: '/profile', redirect: { name: 'userDetail' } },\n userDetail: { path: '/users/:id' },\n};\n```\n\n> **Note:** `redirectTo()` calls `ctx.navigate()` internally, so `beforeLeave` guards will run and can block it. Declarative `redirect` on a route definition bypasses all leave guards.\n\n### Leave Guards\n\nRegister a global leave guard with `router.beforeLeave()`. Return `false` to cancel navigation.\n\n```ts\nconst removeGuard = router.beforeLeave(async (destination) => {\n if (!form.isDirty) return true;\n return confirm(`Discard changes? (navigating to ${destination.pathname})`);\n});\n\n// Remove when no longer needed:\nremoveGuard();\n```\n\nScope a guard to fire only when leaving specific routes:\n\n```ts\nrouter.beforeLeave(async () => confirm('Discard changes?'), { routes: ['editor'] });\n```\n\nDeclarative `redirect` routes bypass all leave guards.\n\n### Data Loading\n\nUse `data()` for route-local data acquisition. It receives the same route context plus an `AbortSignal`.\n\n```ts\nconst routes = {\n userDetail: {\n path: '/users/:id',\n data: async ({ params, signal }) => fetchUser(params.id, { signal }),\n },\n};\n```\n\nAccess the result via the matched branch:\n\n```ts\nrouter.subscribe((state) => {\n const user = state.matches.at(-1)?.data;\n renderUser(user);\n});\n```\n\n#### Per-route Error Boundaries\n\nUse `onError` to handle data loader failures per-route. The returned value becomes `match.data`, allowing the route to render a degraded state:\n\n```ts\nconst routes = {\n userDetail: {\n path: '/users/:id',\n data: async ({ params, signal }) => fetchUser(params.id, { signal }),\n onError: (error) => ({ error, user: null }),\n },\n};\n```\n\nIf `onError` itself throws, the router falls through to `status: 'error'` as usual.\n\n### Search Param Validation\n\nValidate and coerce `ctx.query` per route. The function receives raw URL strings (`QueryParams`). Throw to leave the parsed query unchanged.\n\n```ts\nconst routes = {\n search: {\n path: '/search',\n coerceSearch: (raw) => ({\n q: String(raw.q ?? ''),\n page: Math.max(1, Number(raw.page ?? 1)),\n }),\n data: async ({ query }) => searchPosts(query.q, query.page),\n },\n};\n```\n\nTo apply the same coercion to every route, set `coerceSearch` on the router options instead. Per-route `coerceSearch` takes precedence over the global one.\n\n```ts\nconst router = createRouter({\n coerceSearch: (raw) => ({ page: Number(raw.page ?? 1) }),\n routes,\n});\n```\n\n### Error Boundaries\n\nWrap `await next()` in middleware for route-wide error handling. The thrown error is also stored on `router.getSnapshot().error`.\n\n```ts\nconst boundary = async (ctx, next) => {\n try {\n await next();\n } catch (error) {\n reportRouteError(ctx.pathname, error);\n await ctx.navigate({ path: '/error' }, { replace: true });\n }\n};\n\nconst router = createRouter({\n middleware: [boundary],\n routes,\n});\n\n// Check after navigation:\nconst { status, error } = router.getSnapshot();\nif (status === 'error') {\n console.error(error);\n}\n```\n\n## Navigation\n\n### Named Navigation\n\n```ts\nawait router.navigate({ name: 'userDetail', params: { id: '42' } });\nawait router.navigate({ name: 'userDetail', params: { id: '42' } }, { replace: true });\nawait router.navigate({ name: 'search', query: { q: 'wayfinder' }, hash: 'results' });\nawait router.navigate({ name: 'dashboard.settings' });\n```\n\n### Raw Path Targets\n\n```ts\nawait router.navigate({ path: '/marketing?utm_source=campaign' });\nawait router.navigate({ path: '/checkout#payment' }, { replace: true });\n```\n\nUse these when a destination does not belong in the route table. The same `navigate()` method covers named routes and raw path targets.\n\n### History State\n\nAttach arbitrary state to a history entry and read it back via `ctx.historyState` or `router.getSnapshot().location.historyState`.\n\n```ts\nawait router.navigate({ name: 'userDetail', params: { id: '42' } }, { state: { from: 'search' } });\n\n// In data():\ndata: async (ctx) => {\n console.log(ctx.historyState); // { from: 'search' }\n return fetchUser(ctx.params.id);\n},\n```\n\n### Same-URL Deduplication\n\n```ts\nawait router.navigate({ name: 'dashboard' });\nawait router.navigate({ name: 'dashboard' }); // no-op\nawait router.navigate({ name: 'dashboard' }, { force: true }); // re-runs\n```\n\n### Leave Guards\n\nGuard navigation until the user confirms — useful for unsaved-changes forms:\n\n```ts\nconst removeGuard = router.beforeLeave(async (destination) => {\n if (!form.isDirty) return true;\n return confirm('Discard changes?');\n});\n\n// Remove when the component unmounts:\nremoveGuard();\n```\n\nScope a guard to a specific route so it only fires when leaving that route:\n\n```ts\nrouter.beforeLeave(async () => confirm('Discard changes?'), { routes: ['editor'] });\n```\n\n## URLs and Active State\n\n```ts\nrouter.url('userDetail', { id: '42' });\nrouter.url('userDetail', { id: '42' }, { tab: 'profile' });\n\nrouter.isActive('userDetail');\nrouter.isActive('users');\nrouter.isActive('users', { exact: true });\n```\n\nRender anchors with `router.href()`: it returns the address-bar form of a route, so hash-history deployments get `#`-prefixed links that plain `url()` strings cannot provide.\n\n`isActive(name)` reads the current router snapshot and is useful for parent navigation items.\n\n## Match a Path Without Navigating\n\n```ts\nconst branch = router.match('/app/dashboard/settings');\n\nif (branch?.at(-1)?.name === 'dashboard.settings') {\n warmSettingsPanel();\n}\n```\n\n`match()` strips the configured base automatically and returns the full matched branch (root to leaf). Data loaders are not executed.\n\n## Load a Path for SSR\n\nUse `router.load(url)` to load a full route state including data loader results without modifying router state or history. This is useful for server-side data prefetching.\n\n```ts\nconst state = await router.load('/users/42');\n\nif (state) {\n const data = state.matches.at(-1)?.data;\n // serialize and send to the client\n}\n```\n\nPass an `AbortSignal` via the options object to cancel in-flight loaders:\n\n```ts\nconst controller = new AbortController();\nconst state = await router.load('/users/42', { signal: controller.signal });\n```\n\n`load()` follows declarative redirects (up to five hops). Middleware is not executed and its result is not reused by client navigation.\n\n## Preload Client Navigation\n\nUse `preload()` with the same typed named target accepted by `navigate()`. It executes route data loaders without changing router state or history, deduplicates concurrent work, and lets the next matching navigation consume the result.\n\n```ts\nawait router.preload({ name: 'userDetail', params: { id: '42' } });\nawait router.navigate({ name: 'userDetail', params: { id: '42' } });\n```\n\nParams and query values are part of the cache key. A navigation with different values runs its loaders normally.\n\n## Coordinate Scroll and View Transitions\n\nKeep commit-sensitive browser behavior on the router:\n\n```ts\nconst router = createRouter({\n routes,\n scroll: (to, from) => (to.location.pathname === from.location.pathname ? 'preserve' : 'top'),\n viewTransition: true,\n});\n\nawait router.navigate({ name: 'settings' }, { viewTransition: false });\n```\n\n`scroll` runs after a successful navigation and can return `'top'`, `'preserve'`, or `{ x, y }`. View transitions fall back to plain navigation when the browser API is unavailable.\n\n## State and Subscriptions\n\n```ts\nrouter.subscribe((state) => {\n const leaf = state.matches.at(-1);\n console.log(leaf?.data);\n});\n```\n\nUse `router.getSnapshot()` to read the current state synchronously:\n\n```ts\nconst { location, matches, status, error } = router.getSnapshot();\n\nlocation.pathname;\nlocation.query; // raw parsed query strings (QueryParams)\nlocation.hash;\nlocation.historyState; // state from the current history entry\n\nmatches; // matched branch from root to leaf\nstatus; // 'idle' | 'loading' | 'error'\nerror; // only set when status === 'error'\n```\n\nThe state object is immutable. A successful navigation replaces it with a new snapshot.\n\n### `waitFor(name)`\n\nWait for the router to reach `status: 'idle'` with a specific route active. Useful in tests and lifecycle coordination:\n\n```ts\n// Navigate and wait for data to settle\nawait router.navigate({ name: 'userDetail', params: { id: '42' } });\nconst state = await router.waitFor('userDetail');\nconst user = state.matches.at(-1)?.data;\n```\n\n`waitFor` rejects immediately if the router is already in `status: 'error'`, and also rejects if `router.dispose()` is called while the promise is pending. Resolves immediately if the named route is already active and idle.\n\n## Testing\n\nUse `createMemoryHistory` to test routers without a browser:\n\n```ts\nimport { createMemoryHistory, createRouter } from '@vielzeug/wayfinder';\n\nconst history = createMemoryHistory('/dashboard');\nconst router = createRouter({ history, routes });\n\n// Use waitFor to avoid manual timing:\nconst state = await router.waitFor('dashboard');\nassert(state.location.pathname === '/dashboard');\n\nrouter.dispose();\n```\n\n## Cleanup\n\n```ts\nrouter.dispose();\n```\n\nRemove listeners, clear subscribers, and prevent future router usage.\n\n## Build a Typed View Registry\n\nKeep matching and data concerns in the route table while storing framework components, lazy factories, and static presentation metadata in an exhaustive registry:\n\n```ts\nconst views = router.createViewRegistry(\n {\n home: { component: HomePage, title: 'Home' },\n settings: { component: SettingsPage, title: 'Settings' },\n },\n { notFound: { component: NotFoundPage, title: 'Not found' } },\n);\n\nconst view = views.resolve(router.getSnapshot());\n```\n\nEvery renderable route name is required; redirect-only routes are excluded. Unknown keys are rejected, and the fallback is explicit rather than relying on the router's internal not-found match name.\n\n## Framework Integration\n\nRoute exposes `getSnapshot()` and `subscribe()`, which map directly to each framework's external-store primitives. Create the router once at module scope and bind actions outside the component lifecycle so references stay stable.\n\n::: code-group\n\n```tsx [React]\nimport { createRouter } from '@vielzeug/wayfinder';\nimport { useSyncExternalStore } from 'react';\n\nconst router = createRouter({\n routes: {\n home: { path: '/' },\n settings: { path: '/settings' },\n },\n notFound: { data: () => ({ message: 'Not found' }) },\n});\n\n// Stable router actions are safe to destructure outside the hook.\nconst { getSnapshot, isActive, navigate, subscribe, url } = router;\n\nexport function useRouter() {\n const state = useSyncExternalStore(subscribe, getSnapshot);\n return { isActive, navigate, state, url };\n}\n\n// RouterView.tsx — exhaustive routes and an explicit fallback\nconst views = router.createViewRegistry(\n { home: HomePage, settings: SettingsPage },\n { notFound: NotFoundPage },\n);\n\nexport function RouterView() {\n const { state } = useRouter();\n const Component = views.resolve(state);\n return Component ? <Component /> : null;\n}\n```\n\n```ts [Vue 3]\nimport { createRouter } from '@vielzeug/wayfinder';\nimport { readonly, shallowRef } from 'vue';\n\nconst router = createRouter({\n routes: {\n home: { path: '/' },\n settings: { path: '/settings' },\n },\n notFound: { data: () => ({ message: 'Not found' }) },\n});\n\n// shallowRef — no need to deep-track immutable route state.\nconst state = shallowRef(router.getSnapshot());\nrouter.subscribe((next) => {\n state.value = next;\n});\n\nexport function useRouter() {\n const { isActive, navigate, url } = router;\n\n return { isActive, navigate, state: readonly(state), url };\n}\n```\n\n```svelte [Svelte]\n<!-- router.ts -->\n<script lang=\"ts\" context=\"module\">\n import { createRouter } from '@vielzeug/wayfinder';\n import { readable } from 'svelte/store';\n\n const router = createRouter({\n routes: {\n home: { path: '/' },\n settings: { path: '/settings' },\n },\n notFound: { data: () => ({ message: 'Not found' }) },\n });\n\n // readable injects the initial value; subscribe() drives updates.\n export const routerState = readable(router.getSnapshot(), (set) => router.subscribe(set));\n export const { isActive, navigate, url } = router;\n</script>\n```\n\n:::\n\nFor full RouterView and RouterLink patterns, see [React Integration](./examples/react-integration.md), [Vue Integration](./examples/vue-integration.md), and [Svelte Integration](./examples/svelte-integration.md).\n\n## Debug Logging\n\n`router.subscribe()` is the reactive subscription API — it receives every state change, including `loading` and `error` transitions. Attach a listener that logs to `console.debug` to inspect navigation without any dedicated debug tooling.\n\n```ts\nimport { createRouter } from '@vielzeug/wayfinder';\n\nconst router = createRouter({ routes });\nconst stop = router.subscribe((state) => {\n console.debug(`[wayfinder] ${state.status} ${state.location.pathname}`);\n});\n\n// Logged once the initial navigation completes:\n// [wayfinder] idle /\n\n// On navigate({ name: 'dashboard' }):\n// [wayfinder] loading /dashboard\n// [wayfinder] idle /dashboard\n```\n\nThe returned function unsubscribes the listener — call it when the logger is no longer needed (e.g. on teardown):\n\n```ts\nstop();\n```\n\nErrors are surfaced on the state object, so you can log them explicitly:\n\n```ts\nrouter.subscribe((state) => {\n if (state.status === 'error') {\n console.error(`[wayfinder] ${state.location.pathname}`, state.error);\n }\n});\n```\n\nUse a label when running multiple routers to distinguish their log output:\n\n```ts\nconst main = createRouter({ routes });\nmain.subscribe((state) => console.debug(`[wayfinder:main] ${state.status} ${state.location.pathname}`));\n\nconst modal = createRouter({ routes: modalRoutes });\nmodal.subscribe((state) => console.debug(`[wayfinder:modal] ${state.status} ${state.location.pathname}`));\n```\n\nDebug logging has no effect on behavior and should not be enabled in production.\n\n::: tip Unhandled router errors\nIf a route's data loader throws and no `onError` callback is set on the router, the error is surfaced via `console.error` in development and silenced in production (`__WAYFINDER_PROD__` set). Always provide an `onError` callback in production to handle errors explicitly.\n:::\n\n## Working with Other Vielzeug Libraries\n\n### With Ward\n\nUse Ward inside Wayfinder middleware to guard protected routes.\n\n```ts\nimport { createRouter } from '@vielzeug/wayfinder';\nimport { allow, createWard } from '@vielzeug/ward';\n\ntype User = { id: string; roles: string[] };\n\nconst ward = createWard([allow<'view', 'settings'>('admin', 'settings', ['view'])]);\n\nconst router = createRouter({\n middleware: [\n (ctx, next) => {\n const user: User = getSessionUser();\n const decision = ward.decide({ action: 'view', principal: user, resource: 'settings' });\n if (decision.effect !== 'allow') return ctx.navigate({ path: '/login' }, { replace: true });\n return next();\n },\n ],\n routes: {\n settings: { path: '/settings' },\n },\n});\n```\n\n### With Ripple\n\nSync router state to a Ripple signal for reactive UI.\n\n```ts\nimport { createRouter } from '@vielzeug/wayfinder';\nimport { signal } from '@vielzeug/ripple';\n\nconst router = createRouter({\n /* ... */\n});\nconst currentRoute = signal(router.getSnapshot().matches.at(-1)?.name ?? '');\n\nrouter.subscribe((state) => {\n currentRoute.value = state.matches.at(-1)?.name ?? '';\n});\n```\n\n## Best Practices\n\n- Define the route table once at app startup and import it where needed.\n- Prefer named navigation (`router.navigate({ name: 'settings' })`) over raw paths.\n- Put auth and permission checks in middleware, not in data loaders.\n- Use `data()` loaders for route data and honor the provided `AbortSignal`.\n- Use `onError` on a route for degraded-state rendering rather than a full redirect to an error page.\n- Use `notFound` in router options for the not-found page rather than `path: '*'` in the route table.\n- Call `router.dispose()` when tearing down apps/tests to release listeners.\n- Use `createMemoryHistory()` for tests and non-browser runtimes; avoid touching `window.history` directly.\n",
|
|
7
7
|
"examples": "---\ntitle: Wayfinder — Examples\ndescription: Practical examples and recipes for wayfinder.\n---\n\n## Examples\n\n- [Route Table Basics](./examples/route-table-basics.md)\n- [Not Found and Error Boundary](./examples/not-found-and-error-boundary.md)\n- [Page Titles from View Metadata](./examples/page-titles-from-meta.md)\n- [Auth and Guards](./examples/auth-and-guards.md)\n- [Same-URL Deduplication](./examples/same-url-deduplication.md)\n- [Base Path Deployment](./examples/base-path-deployment.md)\n- [Raw Path Targets](./examples/raw-path-targets.md)\n- [View Transitions](./examples/view-transitions.md)\n- [React Integration](./examples/react-integration.md)\n- [Vue Integration](./examples/vue-integration.md)\n- [Svelte Integration](./examples/svelte-integration.md)\n"
|
|
8
8
|
},
|
|
9
9
|
"examples": [
|
|
@@ -59,16 +59,23 @@
|
|
|
59
59
|
}
|
|
60
60
|
],
|
|
61
61
|
"typeSignatures": {
|
|
62
|
-
"WayfinderApiError": "export {\n WayfinderApiError,\n WayfinderDisposedError,\n WayfinderError,\n WayfinderRedirectLoopError,\n WayfinderRouteError,\n} from './errors';",
|
|
63
|
-
"
|
|
64
|
-
"
|
|
65
|
-
"
|
|
66
|
-
"
|
|
67
|
-
"
|
|
68
|
-
"
|
|
69
|
-
"
|
|
70
|
-
"
|
|
62
|
+
"WayfinderApiError": "export {\n WayfinderApiError,\n WayfinderConfigError,\n WayfinderDisposedError,\n WayfinderError,\n WayfinderRedirectLoopError,\n WayfinderRouteError,\n} from './errors';",
|
|
63
|
+
"WayfinderConfigError": "export {\n WayfinderApiError,\n WayfinderConfigError,\n WayfinderDisposedError,\n WayfinderError,\n WayfinderRedirectLoopError,\n WayfinderRouteError,\n} from './errors';",
|
|
64
|
+
"WayfinderDisposedError": "export {\n WayfinderApiError,\n WayfinderConfigError,\n WayfinderDisposedError,\n WayfinderError,\n WayfinderRedirectLoopError,\n WayfinderRouteError,\n} from './errors';",
|
|
65
|
+
"WayfinderError": "export {\n WayfinderApiError,\n WayfinderConfigError,\n WayfinderDisposedError,\n WayfinderError,\n WayfinderRedirectLoopError,\n WayfinderRouteError,\n} from './errors';",
|
|
66
|
+
"WayfinderRedirectLoopError": "export {\n WayfinderApiError,\n WayfinderConfigError,\n WayfinderDisposedError,\n WayfinderError,\n WayfinderRedirectLoopError,\n WayfinderRouteError,\n} from './errors';",
|
|
67
|
+
"WayfinderRouteError": "export {\n WayfinderApiError,\n WayfinderConfigError,\n WayfinderDisposedError,\n WayfinderError,\n WayfinderRedirectLoopError,\n WayfinderRouteError,\n} from './errors';",
|
|
68
|
+
"createBrowserHistory": "export {\n createBrowserHistory,\n createHashHistory,\n createHistoryForBase,\n createMemoryHistory,\n type HashHistoryOptions,\n} from './history';",
|
|
69
|
+
"createHashHistory": "export {\n createBrowserHistory,\n createHashHistory,\n createHistoryForBase,\n createMemoryHistory,\n type HashHistoryOptions,\n} from './history';",
|
|
70
|
+
"createHistoryForBase": "export {\n createBrowserHistory,\n createHashHistory,\n createHistoryForBase,\n createMemoryHistory,\n type HashHistoryOptions,\n} from './history';",
|
|
71
|
+
"createMemoryHistory": "export {\n createBrowserHistory,\n createHashHistory,\n createHistoryForBase,\n createMemoryHistory,\n type HashHistoryOptions,\n} from './history';",
|
|
72
|
+
"HashHistoryOptions": "export {\n createBrowserHistory,\n createHashHistory,\n createHistoryForBase,\n createMemoryHistory,\n type HashHistoryOptions,\n} from './history';",
|
|
71
73
|
"redirectTo": "export { redirectTo } from './middleware';",
|
|
74
|
+
"PhaseMirror": "export type { PhaseMirror, PhaseMirrorOptions } from './phase-mirror';",
|
|
75
|
+
"PhaseMirrorOptions": "export type { PhaseMirror, PhaseMirrorOptions } from './phase-mirror';",
|
|
76
|
+
"createPhaseMirror": "export { createPhaseMirror } from './phase-mirror';",
|
|
77
|
+
"RouteSignals": "export type { RouteSignals } from './route-signals';",
|
|
78
|
+
"createRouteSignals": "export { createRouteSignals } from './route-signals';",
|
|
72
79
|
"Router": "export type { Router } from './router';",
|
|
73
80
|
"createRouter": "export { createRouter } from './router';",
|
|
74
81
|
"BeforeLeaveBlocker": "export type {\n BeforeLeaveBlocker,\n BeforeLeaveOptions,\n CoerceSearchFn,\n DataContext,\n DataFn,\n HistoryDriver,\n IsActiveOptions,\n MaybePromise,\n Middleware,\n NamedNavigationTarget,\n NavigateOptions,\n NavigationDestination,\n NavigationStatus,\n NavigationTarget,\n PathParams,\n QueryParams,\n RawNavigationTarget,\n ResolvedQueryParams,\n ResolvedQueryValue,\n RouteContext,\n RouteDefinition,\n RouteLocation,\n RouteMatch,\n RouteMatchBranch,\n RouteMiddleware,\n RouteName,\n RouteParams,\n RoutePathByName,\n RouterErrorContext,\n RouterOptions,\n RouteState,\n RouteTable,\n RouteViewMap,\n RouteViewName,\n RouteViewRegistry,\n ScrollDecision,\n ScrollPosition,\n Unsubscribe,\n UntypedNamedNavigationTarget,\n} from './types';",
|