Skip to content

Wayfinder 3.2 ​

Wayfinder 3.2 separates definition errors from navigation errors and hardens state notification.

WayfinderConfigError for malformed route tables ​

Definition-time failures now throw WayfinderConfigError instead of WayfinderRouteError: a route that sets both index: true and path, a route with neither, a duplicate compound route name, and invalid path patterns (misplaced * or :param*, non-word param names). WayfinderRouteError remains for navigation-time failures: unknown route names and missing path params in url()/navigate(). Both extend WayfinderError, so a catch on the base class keeps working:

ts
// Before
try {
  createRouter({ routes: { home: { index: true, path: '/' } } });
} catch (e) {
  if (e instanceof WayfinderRouteError) {
    /* ... */
  }
}

// After
try {
  createRouter({ routes: { home: { index: true, path: '/' } } });
} catch (e) {
  if (e instanceof WayfinderConfigError) {
    /* ... */
  }
}

Subscriber errors no longer abort navigation ​

If a subscribe() listener throws, the error is rethrown in a microtask (surfacing as an unhandled error) instead of propagating into the navigation that notified it. Remaining listeners still run and router state stays consistent.

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 ​

  • component field on route definitions and RouteMatch — UI rendering is owned by a typed view registry instead of route matching.
  • meta field on route definitions and RouteMatch — store static UI metadata alongside the component in the view registry.
  • lazy field on route definitions — store framework-specific lazy component factories in the view registry.
  • Streaming data loaders (AsyncGenerator return from data(), DataStream type) — removed. Data loaders return a value or a Promise.
  • Per-match status on RouteMatch — removed. Use the top-level RouteState.status instead.
  • 'streaming' navigation status — removed from NavigationStatus. 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 cause chain are preserved.

Migrate component to adapter-layer mapping ​

ts
// Before — component on route definition
const routes = {
  home: { component: HomePage, path: '/' },
  settings: { component: SettingsPage, path: '/settings' },
};
const Component = state.matches.at(-1)?.component;
ts
// 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 ​

ts
// Before
const routes = {
  userDetail: { path: '/users/:id', meta: { section: 'users' } },
};
const section = match.meta?.section;
ts
// 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 ​

ts
// Before
const routes = {
  settings: { path: '/settings', lazy: () => import('./pages/Settings') },
};
ts
// 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().

ts
// Before
await router.preload('userDetail', { id: '42' });
ts
// 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.

ts
const router = createRouter({
  routes,
  scroll: () => 'top',
  viewTransition: true,
});

await router.navigate({ name: 'settings' }, { viewTransition: false });

Migrate streaming data loaders to plain async ​

ts
// Before — streaming via AsyncGenerator
data: async function* ({ signal }) {
  const items = [];
  for await (const batch of streamBatches({ signal })) {
    items.push(...batch);
    yield items;
  }
  return items;
}
ts
// 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 ​

ts
// Before
const leafStatus = state.matches.at(-1)?.status;
ts
// 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.

ts
// Before
if (WayfinderError.is(err)) {
  // handle router error
}
ts
// 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.

ts
// 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.

ts
// 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.

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

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

ts
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, and lazy from route definitions; move presentation values into router.createViewRegistry().
  • Give the view registry an explicit notFound value when the router has a notFound route.
  • Convert positional router.preload(name, params, query) calls to typed target objects.
  • Keep scroll and viewTransition options at the router navigation boundary.
  • Convert streaming AsyncGenerator data loaders to plain async functions.
  • Replace per-match status reads with top-level state.status.
  • Remove 'streaming' from NavigationStatus comparisons — status is now 'idle' | 'loading' | 'error'.