@k8ordo/router

Navigation

<Router> listens to the Navigation API’s navigate event and handles the navigations its table answers inside the browser. This page covers which navigations it takes, what a navigation it takes guarantees, animating page changes, useInterceptedNavigation — the primitive <Router> is built on — and how to test it all.

Which navigations are handled

A same-origin link click, navigateTo, navigation.navigate(), back and forward, and a GET form submission all arrive as the same navigate event. The router intercepts the ones whose destination pathname the table answers.

NavigationHandled as
To another pathname the table answersA page change: the new tree renders in the background through useDeferredValue
To the pathname of the page showing (only the search or the entry state moves)A state change: nothing loads, nothing remounts
To a pathname the table does not answerNot intercepted: a document load, so a 404 is the server’s real answer
A reloadNever intercepted, whatever the table says: left to the browser
A form submitted with a body (POST)Never intercepted, whatever the table says: left to the browser
A download (a link with download)Never intercepted, whatever the table says: left to the browser
A fragment-only change (#section)Never intercepted, whatever the table says: left to the browser
Anything the platform says cannot be intercepted, such as another originNever intercepted, whatever the table says: left to the browser

Intercepting a reload, a POST, a download or a fragment-only change would silently do nothing where the platform would have done the obvious thing: a POST body only the server can act on, an F5 that stops reloading. A GET form carries no body, so the search-shaped submissions @k8ordo/state builds still come through.

Mounted on a pathname the table does not answer, <Router> renders nothing rather than guessing. A /* at the end of the table makes every pathname one the table answers. In exchange, a link to a file the host serves, such as /report.pdf, is claimed too and renders the /* component instead of opening the file; mark such links with download.

How a page change unfolds

A navigation to another pathname the table answers goes through these steps, in order:

  1. On the navigate event, the router decides — synchronously, the only moment interception is possible — whether the navigation is its to handle and whether the table answers the destination.
  2. The URL commits. committed resolves and usePathname returns the new pathname; the previous page is still on screen.
  3. The new match is applied as an ordinary update and rendered in the background through useDeferredValue; that commit is tagged with navigation and a type such as navigation-push.
  4. React commits the new tree. In a layout effect, before the browser paints, the router places the viewport.
  5. finished resolves.

What navigation guarantees

That sequence gives an application a few properties it can rely on.

finished means on screen

The intercept handler resolves in a layout effect, once React has committed the new tree and before the browser paints it. Code awaiting finished is awaiting the render, not the URL write. The render it waits for is the new tree’s first commit: a React.lazy page that suspends into a <Suspense> the navigation mounts anew commits its fallback first, and finished resolves then, before the chunk is in.

Awaiting it inside an async startTransition action does not stall: a page change never joins the action, so finished resolves once the page is on screen instead of waiting for the action to end. A page change that starts while any async action is pending reaches the screen without waiting for that action either. Links & location

A state change is not a page change

When only the search or the entry state moved, the pathname is that of the page on screen. The router intercepts with no handler and leaves the route tree alone: nothing remounts, and neither scroll nor focus is disturbed. This is why a search update never scrolls the page back to the top. With no render to wait for, its finished@k8ordo/state’s update().finished included — settles as soon as the navigation commits.

The comparison is against the page showing, not the address bar. Interception commits the URL first, so a state update issued while another page is still loading is treated as a page change: it lets that page finish arriving, and its finished waits for that render. The original navigation’s finished still rejects with the abort; the page arrives through the state update’s navigation.

A new page starts at the top

Once the new tree is on screen, the viewport goes where a document load would put it: to the element a #fragment names — found by id, or by name the way an old-style <a name> is — or to the top when there is no fragment or nothing answers it. The fragment is decoded before the lookup, so #%E5%B0%8E%E5%85%A5 finds id="導入".

On back and forward the browser restores the position it saved; the router stays out of it. Focus follows the platform’s default reset on a page change and is left where it is on a state change.

Page changes render in the background

The new tree renders at the priority useDeferredValue gives it, so React can keep the previous page interactive while the next one prepares — while a React.lazy chunk arrives, for instance. It is not a transition because React holds every transition until any pending async action ends.

Superseded navigations abort

A second navigation aborts the first through the platform’s own signal. The overtaken finished rejects with the abort reason, and its tree never reaches the screen even if its load had already come back. load receives that signal, so under the framework the payload fetch is cancelled outright. A React.lazy chunk cannot be — a dynamic import takes no signal — so it finishes in the background and is kept for the next visit, while the page it belonged to is never shown.

Push, replace and traverse

The router takes the kind of navigation from the platform’s navigationType and passes it on as a transition type. Every page change is tagged navigation first.

Second typeWhen
navigation-pushA link click, navigateTo (the default)
navigation-replacenavigateTo(…, { history: 'replace' }), navigation.navigate(url, { history: 'replace' })
navigation-traverseBack and forward: navigation.back(), navigation.forward(), navigation.traverseTo()

Putting state on a history entry is @k8ordo/state’s job; navigateTo’s only option is history. @k8ordo/state

Animating page changes

A page change renders in the background, and React’s <ViewTransition> animates what such a render changes, as it does a transition. Wrap the hole the pages render into, and key it on the router’s transition types.

// src/root-layout.tsx
import { href, Outlet } from '@k8ordo/router';
import { ViewTransition } from 'react';

export function RootLayout() {
  return (
    <>
      <nav>
        <a href={href('/')}>Home</a>
        <a href={href('/products')}>Products</a>
      </nav>
      <ViewTransition
        default="none"
        update={{ navigation: 'auto', default: 'none' }}
      >
        <Outlet />
      </ViewTransition>
    </>
  );
}

The types matter because page changes are not the only updates a <ViewTransition> animates: a button’s pending action is a transition, and without the filter every press would cross-fade the whole page. It is update because the boundary stays and its content changes; auto is the browser’s own cross-fade.

The site you are reading does exactly this: its locale layout wraps every page in a <ViewTransition> with update={{ navigation: 'auto', default: 'none' }}, which is why moving between pages here cross-fades. locale-shell.tsx

The second type lets a back button slide the other way from a link:

// src/page-transition.tsx
import { Outlet } from '@k8ordo/router';
import { ViewTransition } from 'react';

export function PageTransition() {
  return (
    <ViewTransition
      default="none"
      update={{
        'navigation-push': 'slide-forward',
        'navigation-replace': 'slide-forward',
        'navigation-traverse': 'slide-back',
        default: 'none',
      }}
    >
      <Outlet />
    </ViewTransition>
  );
}

Style each class through ::view-transition-old(.slide-back) and ::view-transition-new(.slide-back):

@keyframes slide-in-from-right {
  from {
    opacity: 0;
    translate: 32px 0;
  }
}

@keyframes slide-out-to-left {
  to {
    opacity: 0;
    translate: -32px 0;
  }
}

@keyframes slide-in-from-left {
  from {
    opacity: 0;
    translate: -32px 0;
  }
}

@keyframes slide-out-to-right {
  to {
    opacity: 0;
    translate: 32px 0;
  }
}

::view-transition-old(.slide-forward) {
  animation: 200ms ease-in both slide-out-to-left;
}

::view-transition-new(.slide-forward) {
  animation: 200ms ease-out both slide-in-from-right;
}

::view-transition-old(.slide-back) {
  animation: 200ms ease-in both slide-out-to-right;
}

::view-transition-new(.slide-back) {
  animation: 200ms ease-out both slide-in-from-left;
}

A state change — @k8ordo/state’s update() — never changes the tree, so it never animates.

@k8ordo/ui’s stylesheet turns view-transition animations off under prefers-reduced-motion; an application without it adds that rule itself:

@media (prefers-reduced-motion: reduce) {
  ::view-transition-group(*),
  ::view-transition-old(*),
  ::view-transition-new(*) {
    animation: none;
  }
}

Under the framework the same <ViewTransition> wraps a layout’s children, and a Server Component layout can render it directly. Under the framework

useInterceptedNavigation

The navigation half of <Router> is exported as a hook of its own: intercept, load, apply, and resolve the platform’s handler only once the new tree is on screen. Every guarantee above belongs to this hook. What gets loaded is the caller’s business.

It takes a NavigationHandler<T>:

  • claim(url) — whether this navigation is the application’s to handle. It answers synchronously, because that is the only moment interception is possible; false leaves a document load.
  • load(url, signal) — produces what the application renders for this URL, as a value or a promise. signal aborts when the navigation is overtaken.
  • apply(value) — applies it, as an ordinary update outside any transition. The host renders what it set through useDeferredValue: the new page then renders in the background with the previous one still on screen, and generation and finished move in that same commit.
// src/article-host.tsx
'use client';

import {
  NavigationGeneration,
  PathnameProvider,
  useInterceptedNavigation,
} from '@k8ordo/router';
import { useDeferredValue, useState } from 'react';

type Article = { title: string; body: string };

const loadArticle = async (url: URL, signal: AbortSignal) => {
  const response = await fetch(`/api${url.pathname}.json`, { signal });
  return (await response.json()) as Article;
};

export function ArticleHost({
  initial,
  pathname,
}: {
  initial: Article;
  pathname: string;
}) {
  const [latest, setLatest] = useState(initial);
  const { generation } = useInterceptedNavigation<Article>({
    claim: (url) => url.pathname.startsWith('/articles/'),
    load: loadArticle,
    apply: setLatest,
  });
  const article = useDeferredValue(latest);
  return (
    <PathnameProvider pathname={pathname}>
      <NavigationGeneration value={generation}>
        <article>
          <h1>{article.title}</h1>
          <p>{article.body}</p>
        </article>
      </NavigationGeneration>
    </PathnameProvider>
  );
}

The handler is read at event time, not at render time, so a new object on every render needs no memoization. A navigation to the pathname of the page showing — a state change — calls neither claim nor load.

<Router> is this hook given “does the table match” as claim, the match as load, and a state setter as apply. The framework’s runtime claims every same-origin URL and fetches the server’s RSC payload in load.

The generation it returns is a number that changes exactly when a new tree is put on screen, not when the URL moved. A host provides it through <NavigationGeneration value> so the table’s error boundaries know when to let a failure go, and mounts <PathnameProvider pathname> for server renders and hydration. <Router> and the framework’s runtime both do this themselves, so an application reaches for this hook only when it builds its own navigation seam.

Testing

Nothing here is mocked, so a test of navigation needs a real browser environment. The package’s own suite runs in Vitest’s browser mode on Chromium.

A table’s shape, params and precedence need no browser: ask routes.match() directly. matchPath is a pure function too.

// src/routes.test.ts
import { expect, it } from 'vitest';

import { routes } from './routes';

it('matches a product page before the catch-all', () => {
  expect(routes.match('/products/42')).toMatchObject({
    pattern: '/products/:id',
    params: { id: '42' },
  });
  expect(routes.match('/no/such/page')?.pattern).toBe('/*');
});

A test that mounts <Router> already intercepts navigations to paths the table answers. finished resolves when the new tree is on screen, so the screen can be asserted right after awaiting it, with no waitFor. A page’s passive effects run after that, so assert their results with a retry.

One thing to know: a navigation no mounted <Router> intercepts — to a URL outside the table, or back to the runner’s URL when no router is there — has to be intercepted by the test itself. A navigation.navigate() nobody intercepts is a cross-document load that takes the test runner with it. The example below intercepts its own return to the runner’s URL, so the clean-up works whatever the table answers.

// src/app.browser.test.tsx
import { navigateTo, Router } from '@k8ordo/router';
import { afterEach, beforeEach, expect, it } from 'vitest';
import { render } from 'vitest-browser-react';

import { routes } from './routes';

const interceptEverything = (event: NavigateEvent) => {
  if (event.canIntercept) event.intercept();
};

let runnerUrl: string;

beforeEach(() => {
  runnerUrl = location.href;
});

afterEach(async () => {
  navigation.addEventListener('navigate', interceptEverything);
  try {
    await navigation.navigate(runnerUrl, { history: 'replace' }).finished;
  } finally {
    navigation.removeEventListener('navigate', interceptEverything);
  }
});

it('shows the product page once finished resolves', async () => {
  await render(<Router routes={routes} />);

  await navigateTo('/products/:id', { id: '1' }).finished;

  expect(document.querySelector('h1')?.textContent).toBe('Product 1');
});