Reference

lumiose/router

The client router for spa routes: links, navigation, navigation hooks, reactive route state, persistent layouts, transitions, scroll restoration, focus reset and page announcements.

lumiose/router works inside spa routes (export const mode = "spa", or lumiose({ mode: "spa" }) for the whole project); <Link>, beforeNavigate(), afterNavigate() and searchParams work on MPA pages too (see Link, Navigation hooks and searchParams). Pages and layouts of an SPA import it like any module; on the server (ssr pages, rendered per request or prerendered at build time) the same names read the request: params(), route(), url() and searchParams() return the request's values, <Link> renders its <a>, isNavigating() is false, beforeNavigate() and afterNavigate() register nothing, and navigate() and searchParams.set() throw (call them from event handlers and effects). Concepts: Routing, Rendering modes.

#Components

ts
function Link(props: LinkProps): JSX.Element;

type LinkProps = Omit<JSX.IntrinsicElements["a"], "href" | "children"> & {
  href: string;
  /** Default "hover" in spa pages; on MPA pages, lumiose({ prefetch }). */
  prefetch?: "hover" | "viewport" | "none";
  children?: JSX.Element;
};

An <a href> with every attribute and event of <a>. The router navigates plain <a> links too; <Link> adds:

  • Prefetch. It loads the target route's modules ahead of the click: when the pointer enters the link or it gets focus (hover), when it enters the viewport (viewport), or not (none). Data still loads when the page renders. spa pages get no speculation rules, so this is the prefetch of their links.
  • On MPA pages (static and server routes) there is no router: <Link> renders a plain <a>, outside islands without any JavaScript, and prefetch sets how the page's speculation rules from lumiose({ prefetch }) treat it (Routing). The <a> gets data-l-prefetch="<level>"; the rules skip none links, prefetch viewport links with eager and hover links with moderate eagerness, and give unmarked links (<Link> without prefetch, plain <a>) the project default. With lumiose({ prefetch: "none" }), only pages that have hover or viewport links get rules. Browsers prefetch URLs: a none link to a URL that another link on the page prefetches does not stop it.
  • aria-current="page" while its path is the current URL's path (style it with a[aria-current="page"]).

Build href with href() so the route and its params type-check.

ts
function navigate(href: string | URL, options?: NavigateOptions): Promise<void>;

interface NavigateOptions {
  /** Replace the current history entry instead of adding one. */
  replace?: boolean;
}

Navigates on the client when href belongs to the current SPA, else loads the document. Resolves once the new page is on screen (its async content ready), or when another navigation supersedes it. Browser only. Only http: and https: URLs (relative ones included) navigate: any other scheme, such as javascript:, throws a TypeError. An href that only names a fragment of the current page (navigate("#comments")) is the browser's, as a link to it is: it scrolls there and keeps focus, no hook runs, and url() follows.

#isNavigating

ts
function isNavigating(): boolean;

Whether a navigation is in flight: from the click until the new page is on screen. Reactive, and counted by isBusy() (decision-20).

SvelteKit's beforeNavigate and afterNavigate (decision-50). Call them during component setup, like onMount: the hook runs while the component is mounted and is removed when it is disposed.

#beforeNavigate

ts
function beforeNavigate(fn: (navigation: BeforeNavigate) => void): void;

interface BeforeNavigate {
  type: "link" | "navigate" | "popstate" | "leave";
  from: NavigationTarget | null;
  /** `null` when the next document is outside the app (another origin, or `"leave"`). */
  to: NavigationTarget | null;
  /** The document unloads: the navigation is not a client-side one. */
  willUnload: boolean;
  cancel(): void;
}

interface NavigationTarget {
  url: URL;
  /** The matched route pattern (`/songs/[id]`); undefined when none matched. */
  route: Routes | undefined;
  params: Record<string, string>;
}

Runs before each navigation away from the current location, before the router loads anything. type is what started it:

type Navigation
"link" A link click (or a GET form) to a URL of the SPA, or out of it (willUnload). On MPA pages, a click on a same-origin link (willUnload).
"navigate" navigate() or searchParams.set() (a thrown redirect() runs no hook: it continues one).
"popstate" Back/forward.
"leave" The document unloads otherwise: closing the tab, typing a URL, a reload, a POST form. On MPA pages also navigate(), searchParams.set(), forms and links to other origins.

navigation.cancel() stops it, and the URL stays the current one:

  • A "link" or "navigate" navigation does not happen. Inside the SPA the router does not start it; one that would load another document (a page outside the SPA, or a link on an MPA page) is stopped too (the click is prevented), so the page stays as it is, with no prompt. The hook can ask with confirm(), or cancel, show its own dialog and follow navigation.to.url once the user agrees.
  • Back/forward that the browser does not let the page cancel happens, then the router returns to the entry it left (without the Navigation API, each history entry the router creates stores its index in history.state for that).
  • "leave" cannot be stopped by the page: the browser shows its own "Leave site?" prompt (beforeunload), where a custom dialog cannot run. The browser shows it only after the user interacted with the page.

So a hook that guards unsaved changes cancels "leave" and asks the user itself for the rest (see Unsaved changes). A change of the URL's #hash alone is not a navigation for the hooks.

#afterNavigate

ts
function afterNavigate(fn: (navigation: AfterNavigate) => void): void;

interface AfterNavigate {
  type: "enter" | "link" | "navigate" | "popstate";
  from: NavigationTarget | null;
  to: NavigationTarget | null;
  willUnload: boolean;
}

Runs once per completed client navigation, after the new page is on screen (after a view transition's DOM update, not its animation), its scroll position restored, focus reset and the page announced to screen readers. A navigation that a newer one supersedes or a hook cancels never completes. It also runs once when the component mounts, with type: "enter" and from: null, so the first page view counts too; a component that a navigation created gets that navigation's call instead.

#On MPA pages

Islands of static and server pages have no router, and using the hooks loads none of it. There, beforeNavigate sees a click on a same-origin link as "link" (willUnload; route undefined, and to is null for a URL outside Vite's base) and everything else that unloads the document as "leave". afterNavigate fires once with "enter", with route undefined.

  • Which clicks. The hooks hear every link of the page, inside islands or not, through a click listener at window and a beforeunload listener, both added while a hook is registered. Running at window, after the page's own handlers, they skip a click that a handler prevented.
  • Left to the browser. These clicks are not reported: a click with a modifier key or another button (a new tab or window), on a link with target (other than _self) or download, to another origin (mailto: and tel: included), or to a fragment of the current page.
  • Cancelling a "link" prevents the click, so the page stays with no prompt. An island can show its own dialog, then call location.assign(navigation.to.url) once the user agrees (clearing its unsaved state first, or the hook cancels again, as "leave").

#Unsaved changes

While the page holds unsaved work, the hook cancels every navigation, then asks with the page's own dialog where the page can. The same hook works in an SPA and in an island of a static or server page:

  • "link" and "navigate": the page stays, with no prompt. Keep navigation.to.url and open your dialog. to is null for a URL outside the app (another origin in an SPA, or outside Vite's base), which the dialog could not follow: ask with confirm() there.
  • "leave" (closing the tab, a reload, a typed URL, a POST form): the browser shows its own prompt, and no page code runs.
  • "popstate" (back/forward, in an SPA): the router returns to the page.

The dialog's Leave button sets a flag that the hook checks first, then follows the URL: location.assign(url) on an MPA page, where islands that use the hooks load no router, and navigate(url) in an SPA. Without the flag the hook cancels again (on an MPA page as "leave", so the browser asks).

tsx
// An island of a server page that edits a note; in an SPA, the same component as a page
import { signal } from "lumiose";
import { beforeNavigate } from "lumiose/router";

export function NoteEditor() {
  const dirty = signal(false);
  /** Where a cancelled link or navigate() was going; the dialog shows while it is set. */
  const pending = signal<URL | null>(null);
  let leaving = false;

  beforeNavigate((navigation) => {
    if (leaving || !dirty()) return;
    if (navigation.type === "leave") {
      navigation.cancel(); // the browser asks
    } else if (navigation.to === null) {
      // Outside the app: the dialog could not follow it.
      if (!confirm("Discard your changes?")) navigation.cancel();
    } else {
      navigation.cancel(); // the page stays
      if (navigation.type !== "popstate") pending.set(navigation.to.url);
    }
  });

  function leave(url: URL) {
    leaving = true; // the hook lets this navigation through
    location.assign(url); // in an SPA: void navigate(url)
  }

  return (
    // Saving posts the form, which unloads the page too ("leave"): it is not unsaved work then.
    <form method="post" onSubmit={() => dirty.set(false)}>
      <textarea name="text" onInput={() => dirty.set(true)} />
      <button>Save</button>
      {pending() !== null && (
        <p role="alert">
          Your changes are not saved.{" "}
          <button type="button" onClick={() => pending.set(null)}>
            Stay
          </button>{" "}
          <button type="button" onClick={() => leave(pending()!)}>
            Leave
          </button>
        </p>
      )}
    </form>
  );
}

Any dialog works in place of the <p role="alert">, such as @lumiose/ui's alert dialog bound to pending() !== null. When the unsaved work sits in the rows of a list (an editable table), key the rows by the record's id (Lists and keys): a row keyed by its index keeps its typed value after a sort or a filter, next to another record.

#Route state

#params

ts
function params<P extends Routes = never>(): RouteParams<P>; // untyped: Record<string, string>

The current route's params, reactive: params<"/songs/[id]">().id. Pages also receive them as props.params.

#route

ts
function route(): Routes | undefined;

The matched route pattern, reactive: route() === "/songs/[id]". Compare patterns instead of matching url().pathname; typegen makes an unknown pattern a type error. undefined on a not-found page for an unknown URL (a matched route that throws notFound() keeps its pattern).

#url

ts
function url(): URL;

The current URL, reactive. Do not mutate it.

#locale

ts
function locale(): Locale;

With lumiose({ i18n }), the locale of the current URL (its prefix, else the default locale), reactive inside SPA routes; in islands of other routes, the page's. undefined without lumiose({ i18n }), typed string | undefined. A navigation to another locale renders every level again, and <html lang> follows. See Internationalization.

#searchParams

ts
interface SearchParams {
  (): URLSearchParams;
  set(
    next: SearchParamsInit | ((current: URLSearchParams) => SearchParamsInit),
    options?: SearchParamsOptions,
  ): Promise<void>;
}
const searchParams: SearchParams;

type SearchParamsInit =
  | URLSearchParams
  | string
  | Record<string, string | number | boolean | readonly string[] | null | undefined>;

interface SearchParamsOptions extends NavigateOptions {
  shallow?: boolean;
}

searchParams() is the current URL's query, reactive (a new object per change). searchParams.set() navigates to the current path with a new query, keeping the scroll position; null and undefined values are left out. It adds a history entry unless { replace: true }. Inside an SPA, { shallow: true } changes nothing: a query change already keeps every layout and page mounted.

searchParams.set() escapes only what the query needs, so shared URLs stay readable: searchParams.set({ sorts: "price:desc,name:asc" }) writes ?sorts=price:desc,name:asc. Keys and values are written as URLSearchParams writes them (letters, digits and * - . _ as they are, spaces as +, everything else percent-encoded as UTF-8: %, &, =, +, #, quotes, non-ASCII…), except that ! $ ( ) , / : ? @ ~ stay as they are too: a query may hold them, and its parser reads them as themselves. ; stays escaped, because some servers and caches still split a query on it. searchParams() in the browser and on the server, and ctx.url.searchParams in middleware and endpoints, read every value back unchanged, &, =, + and # included. A string or a URLSearchParams passed to set() is parsed first, so set("sorts=price%3Adesc") writes ?sorts=price:desc.

In islands of MPA pages (static and server routes), where no router runs, searchParams() is the document's query, shared by every island, and searchParams.set() loads the page with the new query, like navigate() there. With { shallow: true } it loads nothing: it writes the URL with history.pushState() (replaceState() with { replace: true }, or when the URL does not change) and updates searchParams(), keeping the path, the hash, the scroll position and focus. Back/forward between those entries update searchParams(). The server-rendered HTML then stays as it is, and the navigation hooks do not run. During SSR, islands of server pages read the request's query; at build time a static page has none (LUM5001). See Islands.

While a page is still loading during a navigation, params(), route(), url() and searchParams() called in it (and its props) already describe the page it is loading; everything else keeps the shown URL until the new page is on screen.

#Behaviour

  • Interception. The router handles same-document navigations to URLs of its SPA: link clicks, navigate(), back/forward and (with the Navigation API) GET form submissions. URLs of other routes (static or server pages, endpoints, other SPAs) load the document. Without the Navigation API, a History API fallback intercepts link clicks and popstate; it is a chunk of its own that only those browsers load, shortly after the SPA starts (a click before it loads is a document navigation).
  • Persistent layouts. A layout's children is the outlet. Layouts the old and the new page share stay mounted with their state; the first level that differs (another file, or other values of the params in its path, such as songs/[id]/page.stsx for another song) is replaced. A navigation that only changes the query keeps every level and updates searchParams().
  • Transitions. The new page is created off-screen. The old page stays until the new page's async content (async components and resources outside a <Suspense> of the new page) is ready; then the URL signals change and the pages swap in one step. A <Suspense> inside the new page shows its fallback instead.
  • Scroll and focus. After a navigation the page scrolls to the top (or to the URL's #hash) and focus moves to the document (or the first focusable [autofocus] element), so the next Tab starts from the top of the page, as after a document load; back/forward restore the scroll position. Focus the new page moved while it loaded stays. searchParams.set() keeps both, and back/forward between entries that differ only in #hash keeps focus. A link or navigate() to a #hash of the current page is left to the browser, which scrolls to it and keeps focus. Browsers without the Navigation API get the same behaviour from the History API fallback.
  • Announcements. Screen readers announce a document load, not a client navigation, so the router reads each new page aloud: once it is on screen, scrolled and focused, a visually hidden live region (aria-live="assertive", aria-atomic="true", appended to <body> with a data-l-announcer attribute) gets its document.title (set by <Title>), else the text of its first <h1>, else its path. The first page is not announced (the screen reader reads the document), nor is a navigation that keeps the path (a query change) unless the title changed. Give each page a distinct <Title> so the announcement says where the user is. The region is created once the SPA has hydrated, outside it, and removed with it.
  • Errors. notFound() and unknown URLs render the nearest not-found page of the SPA; other errors its error page ({ status, error } props), with the layouts kept. With src/instrumentation.client.ts, its onError receives the other errors (phase: "navigate"), and its navigate wrappers observe each navigation (Islands). A thrown redirect() navigates. Without such a page, a minimal built-in error page replaces the SPA. A not-found page that the server rendered at the URL of a prerendered page with params (404.html on a static host) renders that page instead: see Prerendered SPA routes.
  • invalidate() from lumiose re-runs the SPA's async components and resources (they keep their UI meanwhile) and resolves once they settled. submit() redirects navigate through the router, and a successful submit() invalidates (on MPA pages it does not: that would reload).
  • Stale modules. A page module that fails to load (an old chunk after a deploy) loads the URL as a document instead (its hooks already ran: no "leave").

#Example

routes/music/layout.tsx
// src/routes/music/layout.tsx: an SPA under /music, with a persistent layout
import { href, type LayoutProps } from "lumiose";
import { Link, isNavigating } from "lumiose/router";

export const mode = "spa";

export default function Layout(props: LayoutProps<"/music">) {
  return (
    <div class="app">
      <nav>
        <Link href={href("/music")}>Songs</Link>
        <Link href={href("/music/settings")} prefetch="viewport">
          Settings
        </Link>
      </nav>
      {isNavigating() && <progress />}
      <main>{props.children}</main>
    </div>
  );
}
routes/music/page.tsx
// src/routes/music/page.tsx: a filter kept in the query
import { searchParams } from "lumiose/router";

export default function Songs() {
  const query = () => searchParams().get("q") ?? "";
  return (
    <input
      type="search"
      value={query()}
      onInput={(event) =>
        searchParams.set({ q: event.currentTarget.value || null }, { replace: true })
      }
    />
  );
}
routes/music/settings/page.tsx
// src/routes/music/settings/page.tsx: asks before leaving unsaved changes
import { href, signal } from "lumiose";
import { afterNavigate, beforeNavigate, navigate } from "lumiose/router";

export default function Settings() {
  const dirty = signal(false);

  beforeNavigate((navigation) => {
    if (!dirty()) return;
    if (navigation.type === "leave") navigation.cancel(); // the browser asks
    else if (!confirm("Discard your changes?")) navigation.cancel();
  });

  afterNavigate((navigation) => {
    if (navigation.to !== null) console.info("page view", navigation.to.url.pathname);
  });

  return (
    <form>
      <input name="volume" onInput={() => dirty.set(true)} />
      <button
        type="button"
        onClick={() => {
          dirty.set(false);
          void navigate(href("/music"), { replace: true });
        }}
      >
        Done
      </button>
    </form>
  );
}