Wayfinder 3.0
Wayfinder 3.0 narrows the scope to routing core functionalities: route compilation, matching, history, navigation, and cancellation. Data loading remains as an optional layer. UI ownership, speculative async features, and error mutation have been removed.
Removed features
componentfield on route definitions andRouteMatch— UI rendering is owned by a typed view registry instead of route matching.metafield on route definitions andRouteMatch— store static UI metadata alongside the component in the view registry.lazyfield on route definitions — store framework-specific lazy component factories in the view registry.- Streaming data loaders (
AsyncGeneratorreturn fromdata(),DataStreamtype) — removed. Data loaders return a value or a Promise. - Per-match
statusonRouteMatch— removed. Use the top-levelRouteState.statusinstead. 'streaming'navigation status — removed fromNavigationStatus. Status is now'idle' | 'loading' | 'error'.- Error mutation — the router no longer attaches symbol context to thrown objects. Error context is carried in internal wrappers; the original error identity and
causechain are preserved.
Migrate component to adapter-layer mapping
// Before — component on route definition
const routes = {
home: { component: HomePage, path: '/' },
settings: { component: SettingsPage, path: '/settings' },
};
const Component = state.matches.at(-1)?.component;// After — exhaustive route views with an explicit fallback
const routes = {
home: { path: '/' },
settings: { path: '/settings' },
} as const;
const router = createRouter({ routes, notFound: {} });
const views = router.createViewRegistry(
{ home: HomePage, settings: SettingsPage },
{ notFound: NotFoundPage },
);
const Component = views.resolve(state);Migrate meta to the view registry
// Before
const routes = {
userDetail: { path: '/users/:id', meta: { section: 'users' } },
};
const section = match.meta?.section;// After — keep static presentation metadata out of data loading
const router = createRouter({ routes: { userDetail: { path: '/users/:id' } } });
const views = router.createViewRegistry({
userDetail: { component: UserPage, section: 'users' },
});
const section = views.resolve(state)?.section;Migrate lazy to dynamic import in data loader
// Before
const routes = {
settings: { path: '/settings', lazy: () => import('./pages/Settings') },
};// After — keep framework-specific code splitting in the view registry
const router = createRouter({ routes: { settings: { path: '/settings' } } });
const views = router.createViewRegistry({
settings: () => import('./pages/Settings'),
});Update preload() targets
preload() remains cache-aware: its result is consumed by the next matching navigation. It now accepts the same typed named target shape as navigate().
// Before
await router.preload('userDetail', { id: '42' });// After
await router.preload({ name: 'userDetail', params: { id: '42' } });load() remains the detached SSR and prerendering API; it does not warm a later client navigation.
Scroll and view transitions remain coordinated
The scroll and viewTransition options remain on the router because both must run at the navigation commit boundary.
const router = createRouter({
routes,
scroll: () => 'top',
viewTransition: true,
});
await router.navigate({ name: 'settings' }, { viewTransition: false });Migrate streaming data loaders to plain async
// Before — streaming via AsyncGenerator
data: async function* ({ signal }) {
const items = [];
for await (const batch of streamBatches({ signal })) {
items.push(...batch);
yield items;
}
return items;
}// After — plain async function
data: async ({ signal }) => {
const items = [];
for await (const batch of streamBatches({ signal })) {
items.push(...batch);
}
return items;
}Migrate per-match status to top-level status
// Before
const leafStatus = state.matches.at(-1)?.status;// After — use top-level status
const status = state.status;Wayfinder 2.0
Wayfinder 2.0 removed unused exports, dead code, and internal type aliases to shrink the public surface and align with monorepo conventions.
Replace WayfinderError.is() with instanceof
The static type guard is removed. Use instanceof WayfinderError to narrow unknown errors.
// Before
if (WayfinderError.is(err)) {
// handle router error
}// After
if (err instanceof WayfinderError) {
// handle router error
}Rename Path Inspection APIs
Replace the former synchronous resolve() API with match(). It inspects the route branch without running middleware or data loaders.
// Before
const branch = router.resolve('/users/42');
// After
const branch = router.match('/users/42');Replace the former asynchronous match() API with load(). It runs data loaders without changing router state or history.
// Before
const state = await router.match('/users/42');
// After
const state = await router.load('/users/42');Await Initial Navigation
Use router.ready when application startup must wait for the constructor-triggered navigation, including middleware, redirects, and data loaders.
const router = createRouter({ routes });
await router.ready;
mountApplication(router.getSnapshot());The promise resolves after an initial blocked or unmatched navigation settles. It rejects when the initial navigation fails.
Use Destructured Actions
Router actions are stable own properties and can be destructured directly. Remove manual .bind(router) wrappers.
const { isActive, navigate, url } = router;
await navigate({ name: 'settings' });
const href = url('settings');
const active = isActive('settings');Middleware Cancellation and Redirects
Programmatic navigation now waits to write history until middleware reaches the terminal stage. Middleware that returns without next() cancels the navigation without changing the URL or route snapshot.
Redirect middleware continues to work. Call and await ctx.navigate() without calling next() to cancel the original navigation and start the redirect navigation.
const requireAuth = async (ctx, next) => {
if (!session.currentUser) {
await ctx.navigate({ name: 'login' }, { replace: true });
return;
}
await next();
};3.0 Upgrade Checklist
- Remove
component,meta, andlazyfrom route definitions; move presentation values intorouter.createViewRegistry(). - Give the view registry an explicit
notFoundvalue when the router has anotFoundroute. - Convert positional
router.preload(name, params, query)calls to typed target objects. - Keep
scrollandviewTransitionoptions at the router navigation boundary. - Convert streaming
AsyncGeneratordata loaders to plainasyncfunctions. - Replace per-match
statusreads with top-levelstate.status. - Remove
'streaming'fromNavigationStatuscomparisons — status is now'idle' | 'loading' | 'error'.