diff --git a/apps/react/boilerplate-vite/package.json b/apps/react/boilerplate-vite/package.json index f8f57f3ac..5db85efa4 100644 --- a/apps/react/boilerplate-vite/package.json +++ b/apps/react/boilerplate-vite/package.json @@ -21,10 +21,14 @@ "check:biome": "biome check", "check:biome:fix": "biome check --write", "check:ts": "tsc --noEmit", + "test": "vitest run", + "test:coverage": "vitest run --coverage", "storybook": "storybook dev -p 6010 --no-open --host 0.0.0.0" }, "dependencies": { "@canonical/react-ds-global": "^0.22.0", + "@canonical/router-core": "^0.22.0", + "@canonical/router-react": "^0.22.0", "@canonical/react-ssr": "^0.22.0", "@canonical/storybook-config": "^0.22.0", "@canonical/styles": "^0.22.0", @@ -45,6 +49,7 @@ "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^6.0.0", + "@vitest/coverage-v8": "4.1.2", "globals": "^17.4.0", "storybook": "^10.3.1", "typescript": "^5.9.3", diff --git a/apps/react/boilerplate-vite/src/Application.css b/apps/react/boilerplate-vite/src/Application.css index d7a07e04d..54966c29a 100644 --- a/apps/react/boilerplate-vite/src/Application.css +++ b/apps/react/boilerplate-vite/src/Application.css @@ -1,28 +1,121 @@ #root { - max-width: 1280px; + min-height: 100vh; +} + +.app-shell { + box-sizing: border-box; + display: grid; + gap: 2rem; margin: 0 auto; + max-width: 1120px; + min-height: 100vh; + padding: 3rem 1.5rem; +} + +.shell-header { + align-items: start; + display: grid; + gap: 1.5rem; +} + +.shell-title { + font-size: clamp(2rem, 4vw, 3rem); + margin: 0; +} + +.shell-copy, +.lede { + color: #4b5563; + font-size: 1.05rem; + line-height: 1.7; + margin: 0; +} + +.shell-nav { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; +} + +.shell-nav a { + background: #ffffff; + border: 1px solid #d7dbe6; + border-radius: 999px; + color: #111827; + padding: 0.7rem 1rem; + text-decoration: none; + transition: + transform 120ms ease, + border-color 120ms ease, + box-shadow 120ms ease; +} + +.shell-nav a:hover, +.shell-nav a:focus-visible { + border-color: #0f62fe; + box-shadow: 0 12px 32px rgba(15, 98, 254, 0.12); + outline: none; + transform: translateY(-1px); +} + +.shell-main { + display: grid; +} + +.route-panel { + background: rgba(255, 255, 255, 0.94); + border: 1px solid rgba(15, 23, 42, 0.08); + border-radius: 1.5rem; + box-shadow: 0 24px 80px rgba(15, 23, 42, 0.08); padding: 2rem; - text-align: center; } -.logo { - height: 6em; - padding: 1.5em; - will-change: filter; - transition: filter 300ms; +.eyebrow { + color: #0f62fe; + font-size: 0.8rem; + font-weight: 700; + letter-spacing: 0.08em; + margin: 0; + text-transform: uppercase; } -@keyframes logo-spin { - from { - transform: rotate(0deg); - } - to { - transform: rotate(360deg); - } +.feature-list { + display: grid; + gap: 0.9rem; + margin: 0; + padding-left: 1.2rem; +} + +.callout { + background: #eef4ff; + border: 1px solid #c7d7fe; + border-radius: 1rem; + color: #1f2937; + padding: 1rem 1.25rem; } -@media (prefers-reduced-motion: no-preference) { - a:nth-of-type(2) .logo { - animation: logo-spin infinite 20s linear; +.route-fallback { + color: #4b5563; + margin: 0; +} + +.stack { + display: grid; + gap: 1rem; +} + +.stack-tight { + display: grid; + gap: 0.5rem; +} + +@media (min-width: 960px) { + .app-shell { + grid-template-columns: minmax(280px, 320px) minmax(0, 1fr); + padding: 4rem 2rem; + } + + .shell-main { + align-content: start; } } diff --git a/apps/react/boilerplate-vite/src/Navigation.test.tsx b/apps/react/boilerplate-vite/src/Navigation.test.tsx new file mode 100644 index 000000000..e0033f12e --- /dev/null +++ b/apps/react/boilerplate-vite/src/Navigation.test.tsx @@ -0,0 +1,46 @@ +import { createMemoryAdapter, createRouter } from "@canonical/router-core"; +import { RouterProvider } from "@canonical/router-react"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import Navigation from "./Navigation.js"; +import { appRoutes, withAuth } from "./routes.js"; + +describe("Navigation", () => { + it("renders typed links and prefetches the guide route on hover", async () => { + const router = createRouter(appRoutes, { + adapter: createMemoryAdapter("/"), + middleware: [withAuth("/login")], + }); + const prefetchSpy = vi.spyOn(router, "prefetch"); + + render( + + + , + ); + + expect(screen.getByRole("link", { name: "Home" })).toHaveAttribute( + "href", + "/", + ); + expect(screen.getByRole("link", { name: "Guide" })).toHaveAttribute( + "href", + "/guides/router-core", + ); + expect( + screen.getByRole("link", { name: "Protected account" }), + ).toHaveAttribute("href", "/account"); + expect(screen.getByRole("link", { name: "Demo sign-in" })).toHaveAttribute( + "href", + "/account?auth=1", + ); + + fireEvent.mouseEnter(screen.getByRole("link", { name: "Guide" })); + + await waitFor(() => { + expect(prefetchSpy).toHaveBeenCalledWith("guide", { + params: { slug: "router-core" }, + }); + }); + }); +}); diff --git a/apps/react/boilerplate-vite/src/Navigation.tsx b/apps/react/boilerplate-vite/src/Navigation.tsx new file mode 100644 index 000000000..9895a5a9e --- /dev/null +++ b/apps/react/boilerplate-vite/src/Navigation.tsx @@ -0,0 +1,26 @@ +import { Link } from "@canonical/router-react"; +import type { ReactElement } from "react"; + +export default function Navigation(): ReactElement { + return ( +
+
+

React boilerplate

+

Router-enabled Vite shell

+

+ Hover a navigation link to prefetch route data before you click. +

+
+ +
+ ); +} diff --git a/apps/react/boilerplate-vite/src/domains/account/routes.tsx b/apps/react/boilerplate-vite/src/domains/account/routes.tsx new file mode 100644 index 000000000..6f9d263d3 --- /dev/null +++ b/apps/react/boilerplate-vite/src/domains/account/routes.tsx @@ -0,0 +1,105 @@ +import { route } from "@canonical/router-core"; +import type { ReactElement } from "react"; + +interface LoginSearch { + readonly from?: string; +} + +interface AccountSearch { + readonly auth?: string; +} + +interface AccountData { + readonly nextSteps: readonly string[]; + readonly team: string; +} + +function readString(value: unknown): string | undefined { + return typeof value === "string" ? value : undefined; +} + +const loginSearchSchema = { + "~standard": { + output: {} as LoginSearch, + validate(value: unknown): LoginSearch { + const record = value as Record; + + return { + from: readString(record.from), + }; + }, + }, +}; + +const accountSearchSchema = { + "~standard": { + output: {} as AccountSearch, + validate(value: unknown): AccountSearch { + const record = value as Record; + + return { + auth: readString(record.auth), + }; + }, + }, +}; + +const accountRoutes = { + account: route({ + url: "/account", + fetch: async (): Promise => ({ + nextSteps: [ + "Review the prefetched guide data.", + "Confirm the auth middleware redirected correctly.", + "Use the same route map in React and future Lit bindings.", + ], + team: "Router adoption squad", + }), + search: accountSearchSchema, + content: ({ data }: { data: AccountData }): ReactElement => { + return ( +
+

Account domain

+

Protected account workspace

+

+ You reached the protected route after passing the demo auth + middleware. +

+
+ Team: {data.team} +
+
    + {data.nextSteps.map((item: string) => ( +
  • {item}
  • + ))} +
+
+ ); + }, + }), + login: route({ + url: "/login", + search: loginSearchSchema, + content: ({ search }: { search: LoginSearch }): ReactElement => { + return ( +
+

Account domain

+

Sign in to the demo account

+

+ The local withAuth("/login") middleware redirected + this protected request before the route rendered. +

+
+ Redirected from: {search.from ?? "direct visit"} +
+

+ Use the “Demo sign-in” link in the navigation to revisit the + protected route with ?auth=1 applied. +

+
+ ); + }, + }), +} as const; + +export default accountRoutes; diff --git a/apps/react/boilerplate-vite/src/domains/marketing/routes.tsx b/apps/react/boilerplate-vite/src/domains/marketing/routes.tsx new file mode 100644 index 000000000..fce37f48b --- /dev/null +++ b/apps/react/boilerplate-vite/src/domains/marketing/routes.tsx @@ -0,0 +1,71 @@ +import { route } from "@canonical/router-core"; +import type { ReactElement } from "react"; + +interface HomeData { + readonly highlights: readonly string[]; +} + +interface GuideData { + readonly sections: readonly string[]; + readonly slug: string; + readonly summary: string; +} + +const marketingRoutes = { + guide: route({ + url: "/guides/:slug", + fetch: async ({ slug }: { slug: string }): Promise => ({ + sections: [ + "Flat route triplets keep route definitions colocated with the domain.", + "Hovering a navigation link prefetches guide data before you click.", + "Server rendering dehydrates the loaded route into window.__INITIAL_DATA__.", + ], + slug, + summary: + "This guide shows how the Vite boilerplate wires router-core and router-react together.", + }), + content: ({ data }: { data: GuideData }): ReactElement => { + return ( +
+

Marketing domain

+

Guide: {data.slug}

+

{data.summary}

+
    + {data.sections.map((section: string) => ( +
  • {section}
  • + ))} +
+
+ ); + }, + }), + home: route({ + url: "/", + fetch: async (): Promise => ({ + highlights: [ + "SSR and hydration share one route map.", + "Protected routes redirect through local middleware.", + "Domain modules compose into a single app shell.", + ], + }), + content: ({ data }: { data: HomeData }): ReactElement => { + return ( +
+

Router boilerplate

+

Canonical router integration demo

+

+ This boilerplate demonstrates SSR, hydration, hover prefetch, and an + auth redirect using the new router packages. +

+
    + {data.highlights.map((highlight: string) => ( +
  • {highlight}
  • + ))} +
+
+ ); + }, + }), +} as const; + +export default marketingRoutes; diff --git a/apps/react/boilerplate-vite/src/index.css b/apps/react/boilerplate-vite/src/index.css index 886796f19..2f2035778 100644 --- a/apps/react/boilerplate-vite/src/index.css +++ b/apps/react/boilerplate-vite/src/index.css @@ -1 +1,17 @@ @import url("@canonical/styles"); + +:root { + background: + radial-gradient(circle at top, rgba(15, 98, 254, 0.12), transparent 28%), + linear-gradient(180deg, #f8fafc 0%, #eef2ff 100%); + color: #111827; + font-family: Inter, "Segoe UI", sans-serif; +} + +body { + margin: 0; +} + +a { + color: inherit; +} diff --git a/apps/react/boilerplate-vite/src/routes.test.tsx b/apps/react/boilerplate-vite/src/routes.test.tsx new file mode 100644 index 000000000..a8b437d64 --- /dev/null +++ b/apps/react/boilerplate-vite/src/routes.test.tsx @@ -0,0 +1,154 @@ +import { route } from "@canonical/router-core"; +import { render, screen } from "@testing-library/react"; +import type { ReactNode } from "react"; +import { afterEach, describe, expect, it } from "vitest"; +import { + appRoutes, + createHydratedAppRouter, + createServerAppRouter, + getAuthRedirectHref, + hasDemoAuth, + normalizeRequestHref, + withAuth, +} from "./routes.js"; + +describe("app routes", () => { + afterEach(() => { + delete (window as Window & { __INITIAL_DATA__?: unknown }).__INITIAL_DATA__; + window.history.replaceState({}, "", "/"); + }); + + it("renders the home route on the server router", async () => { + const router = createServerAppRouter(); + const result = await router.load("/"); + + render(
{router.render(result) as ReactNode}
); + + expect( + screen.getByRole("heading", { + name: "Canonical router integration demo", + }), + ).toBeInTheDocument(); + }); + + it("redirects the protected route to login when auth is missing", async () => { + const router = createServerAppRouter(); + const result = await router.load("/account"); + + render(
{router.render(result) as ReactNode}
); + + expect(result.location.href).toBe("/login?from=%2Faccount"); + expect( + screen.getByRole("heading", { + name: "Sign in to the demo account", + }), + ).toBeInTheDocument(); + expect(screen.getByText("/account")).toBeInTheDocument(); + }); + + it("loads the protected route when the demo auth token is present", async () => { + const router = createServerAppRouter(); + const result = await router.load("/account?auth=1"); + + render(
{router.render(result) as ReactNode}
); + + expect(result.location.href).toBe("/account?auth=1"); + expect( + screen.getByRole("heading", { + name: "Protected account workspace", + }), + ).toBeInTheDocument(); + }); + + it("hydrates the current route from window.__INITIAL_DATA__", async () => { + const serverRouter = createServerAppRouter(); + + await serverRouter.load("/guides/router-core"); + + (window as Window & { __INITIAL_DATA__?: unknown }).__INITIAL_DATA__ = + serverRouter.dehydrate(); + window.history.replaceState({}, "", "/guides/router-core"); + + const router = createHydratedAppRouter(); + + render(
{router.render() as ReactNode}
); + + expect( + screen.getByRole("heading", { + name: "Guide: router-core", + }), + ).toBeInTheDocument(); + }); + + it("renders the login page for direct visits and the not-found route for misses", async () => { + const loginRouter = createServerAppRouter(); + const loginResult = await loginRouter.load("/login"); + + render(
{loginRouter.render(loginResult) as ReactNode}
); + + expect(screen.getByText("direct visit")).toBeInTheDocument(); + + const missingRouter = createServerAppRouter(); + const missingResult = await missingRouter.load("/missing"); + + render(
{missingRouter.render(missingResult) as ReactNode}
); + + expect( + screen.getByRole("heading", { + name: "Page not found", + }), + ).toBeInTheDocument(); + }); + + it("covers helper edge cases and middleware passthrough behavior", async () => { + const middleware = withAuth("/login"); + const publicRoute = route({ + url: "/public", + content: () => "public", + }); + const bareProtectedRoute = route({ + url: "/account", + content: () => "account", + }); + const wrappedPublicRoute = middleware(publicRoute); + const wrappedProtectedRoute = middleware(bareProtectedRoute); + + expect(wrappedPublicRoute).toBe(publicRoute); + + // Call the middleware-wrapped fetch directly. The middleware operates on + // `unknown` params/search internally, so we cast to exercise the auth + // check paths without satisfying the route's own strict param types. + const fetch = wrappedProtectedRoute.fetch as ( + params: unknown, + search: unknown, + context: { signal: AbortSignal }, + ) => Promise; + + await expect( + fetch({}, { auth: "1" }, { signal: new AbortController().signal }), + ).resolves.toBeNull(); + await expect( + fetch({}, {}, { signal: new AbortController().signal }), + ).rejects.toMatchObject({ + status: 302, + to: "/login?from=%2Faccount", + }); + expect(hasDemoAuth({ auth: "1" })).toBe(true); + expect(hasDemoAuth({ auth: "0" })).toBe(false); + expect(getAuthRedirectHref("/account")).toBe("/login?from=%2Faccount"); + expect(getAuthRedirectHref("/account?auth=1")).toBeNull(); + expect(getAuthRedirectHref("/guides/router-core")).toBeNull(); + expect( + getAuthRedirectHref(new URL("https://example.com/account?auth=1#top")), + ).toBeNull(); + expect( + normalizeRequestHref(new URL("https://example.com/account?auth=1#top")), + ).toBe("/account?auth=1#top"); + expect(normalizeRequestHref("https://example.com/guides/router-core")).toBe( + "/guides/router-core", + ); + expect(appRoutes.guide.render({ slug: "router-core" })).toBe( + "/guides/router-core", + ); + }); +}); diff --git a/apps/react/boilerplate-vite/src/routes.tsx b/apps/react/boilerplate-vite/src/routes.tsx new file mode 100644 index 000000000..a3a3b4bbb --- /dev/null +++ b/apps/react/boilerplate-vite/src/routes.tsx @@ -0,0 +1,152 @@ +import { + type AnyRoute, + createRouter, + group, + type NavigationContext, + type RouteMap, + type RouteMiddleware, + type RouteParamValues, + type RouterDehydratedState, + redirect, + route, + wrapper, +} from "@canonical/router-core"; +import { createHydratedRouter } from "@canonical/router-react"; +import type { ReactElement, ReactNode } from "react"; +import accountRoutes from "./domains/account/routes.js"; +import marketingRoutes from "./domains/marketing/routes.js"; + +const protectedPaths = new Set(["/account"]); + +function toUrl(input: string | URL): URL { + if (input instanceof URL) { + return input; + } + + if (input.startsWith("http://") || input.startsWith("https://")) { + return new URL(input); + } + + return new URL(input, "https://router.local"); +} + +function toHref(input: string | URL): string { + const url = toUrl(input); + + return `${url.pathname}${url.search}${url.hash}`; +} + +export function hasDemoAuth(search: unknown): boolean { + const authValue = (search as Record)?.auth; + + return authValue === "1"; +} + +export function getAuthRedirectHref(input: string | URL): string | null { + const url = toUrl(input); + + if ( + !protectedPaths.has(url.pathname) || + hasDemoAuth({ auth: url.searchParams.get("auth") }) + ) { + return null; + } + + return `/login?from=${encodeURIComponent(url.pathname)}`; +} + +export function withAuth(loginPath: string): RouteMiddleware { + return ((currentRoute: AnyRoute) => { + if (!protectedPaths.has(currentRoute.url)) { + return currentRoute; + } + + const currentFetch = currentRoute.fetch; + + return { + ...currentRoute, + fetch: async ( + params: unknown, + search: unknown, + context: NavigationContext, + ) => { + if (!hasDemoAuth(search)) { + const from = currentRoute.render( + (params ?? {}) as RouteParamValues | Record, + ); + + redirect(`${loginPath}?from=${encodeURIComponent(from)}`, 302); + } + + if (currentFetch) { + return currentFetch(params, search, context); + } + + return null; + }, + }; + }) as RouteMiddleware; +} + +const publicLayout = wrapper({ + id: "public-layout", + component: ({ children }: { children: ReactNode }): ReactElement => ( +
{children}
+ ), +}); + +const [guide, home] = group(publicLayout, [ + marketingRoutes.guide, + marketingRoutes.home, +] as const); + +export const appRoutes = { + guide, + home, + ...accountRoutes, +} as const; + +export type AppRoutes = typeof appRoutes; +export type AppInitialData = RouterDehydratedState; + +declare module "@canonical/router-react" { + interface RouterRegister { + routes: AppRoutes; + } +} + +const notFoundRoute = route({ + url: "/404", + content: (): ReactElement => { + return ( +
+

Fallback route

+

Page not found

+

+ The boilerplate also wires a typed not-found route for unmatched URLs. +

+
+ ); + }, +}); + +export function createServerAppRouter( + initialData?: RouterDehydratedState, +) { + return createRouter(appRoutes, { + hydratedState: initialData, + middleware: [withAuth("/login")], + notFound: notFoundRoute, + }); +} + +export function createHydratedAppRouter() { + return createHydratedRouter(appRoutes, { + middleware: [withAuth("/login")], + notFound: notFoundRoute, + }); +} + +export function normalizeRequestHref(input: string | URL): string { + return toHref(input); +} diff --git a/apps/react/boilerplate-vite/src/ssr/Shell.tsx b/apps/react/boilerplate-vite/src/ssr/Shell.tsx index e39d7ca98..aac6bc559 100644 --- a/apps/react/boilerplate-vite/src/ssr/Shell.tsx +++ b/apps/react/boilerplate-vite/src/ssr/Shell.tsx @@ -1,37 +1,34 @@ import type { ServerEntrypointProps } from "@canonical/react-ssr/renderer"; -import Application from "../Application.js"; +import type { ReactElement, ReactNode } from "react"; + +interface ShellProps extends ServerEntrypointProps> { + readonly children: ReactNode; + readonly navigation: ReactNode; +} export type InitialData = Record; -/** - * This function returns the component that renders the full page both in the Server and in the - * Client (as it needs to match exactly for hydration to work). - * If you need to pass the initial data to the Renderer constructor. - * - * @param props props can be all automatically extracted by the renderer from the HTML index page - * or can be provided programmatically to the renderer constructor. - * @returns root component containing all the HTML of the page to be rendered. - */ -function Shell(props: ServerEntrypointProps) { +export default function Shell(props: ShellProps): ReactElement { return ( - Canonical React Vite Boilerplate + Canonical router boilerplate + {props.otherHeadElements} {props.scriptElements} {props.linkElements}
- { - // Add the following to pass initial data to the Application: - // - } - +
+ {props.navigation} +
{props.children}
+
); } - -export default Shell; diff --git a/apps/react/boilerplate-vite/src/ssr/entry-client.tsx b/apps/react/boilerplate-vite/src/ssr/entry-client.tsx index a077615f7..61b846b68 100644 --- a/apps/react/boilerplate-vite/src/ssr/entry-client.tsx +++ b/apps/react/boilerplate-vite/src/ssr/entry-client.tsx @@ -1,8 +1,21 @@ +import { Outlet, RouterProvider } from "@canonical/router-react"; import { hydrateRoot } from "react-dom/client"; +import Navigation from "../Navigation.js"; +import { createHydratedAppRouter } from "../routes.js"; +import "../Application.css"; import "../index.css"; import Shell from "./Shell.js"; -// entry-server page must match exactly the hydrated page in entry-client -hydrateRoot(document, ); +const router = createHydratedAppRouter(); -console.log("hydrated"); +hydrateRoot( + document, + + } + > + Loading route…

} /> +
+
, +); diff --git a/apps/react/boilerplate-vite/src/ssr/entry-server.test.tsx b/apps/react/boilerplate-vite/src/ssr/entry-server.test.tsx new file mode 100644 index 000000000..4319f0228 --- /dev/null +++ b/apps/react/boilerplate-vite/src/ssr/entry-server.test.tsx @@ -0,0 +1,29 @@ +import { renderToString } from "react-dom/server"; +import { describe, expect, it } from "vitest"; +import { createServerAppRouter } from "../routes.js"; +import EntryServer from "./entry-server.js"; + +describe("EntryServer", () => { + it("renders the shell and the server-routed page", async () => { + const router = createServerAppRouter(); + + await router.load("/guides/router-core"); + const initialData = router.dehydrate(); + + if (!initialData) { + throw new Error("Expected dehydrated router state for SSR test."); + } + + const html = renderToString( + } + lang="en" + />, + ); + + expect(html).toContain("Canonical router boilerplate"); + expect(html).toContain("Guide:"); + expect(html).toContain("router-core"); + expect(html).toContain("Hover a navigation link to prefetch route data"); + }); +}); diff --git a/apps/react/boilerplate-vite/src/ssr/entry-server.tsx b/apps/react/boilerplate-vite/src/ssr/entry-server.tsx index 4bd583ced..e7b75a6b1 100644 --- a/apps/react/boilerplate-vite/src/ssr/entry-server.tsx +++ b/apps/react/boilerplate-vite/src/ssr/entry-server.tsx @@ -1,6 +1,25 @@ import type { ServerEntrypoint } from "@canonical/react-ssr/renderer"; +import type { RouterDehydratedState, RouteMap } from "@canonical/router-core"; +import { Outlet, RouterProvider } from "@canonical/router-react"; +import Navigation from "../Navigation.js"; +import { createServerAppRouter } from "../routes.js"; import Shell, { type InitialData } from "./Shell.js"; -const EntryServer: ServerEntrypoint = Shell; +const EntryServer: ServerEntrypoint = (props) => { + const initialData = props.initialData as + | RouterDehydratedState + | undefined; + const router = createServerAppRouter(initialData); + + return ( + + }> + Loading route…

} + /> +
+
+ ); +}; export default EntryServer; diff --git a/apps/react/boilerplate-vite/src/ssr/renderer.tsx b/apps/react/boilerplate-vite/src/ssr/renderer.tsx index 216c023db..ecd9c9816 100644 --- a/apps/react/boilerplate-vite/src/ssr/renderer.tsx +++ b/apps/react/boilerplate-vite/src/ssr/renderer.tsx @@ -1,13 +1,31 @@ import fs from "node:fs/promises"; import path from "node:path"; import { JSXRenderer } from "@canonical/react-ssr/renderer"; +import { createServerAppRouter, normalizeRequestHref } from "../routes.js"; import EntryServer from "./entry-server.js"; +import type { InitialData } from "./Shell.js"; export const htmlString = await fs.readFile( path.join(process.cwd(), "dist", "client", "index.html"), "utf-8", ); -export default function createRenderer() { - return new JSXRenderer(EntryServer, {}, { htmlString }); +export interface RenderPreparation { + readonly initialData: InitialData; + readonly renderer: JSXRenderer; +} + +export default async function prepareRender( + requestUrl: string, +): Promise { + const router = createServerAppRouter(); + const loadResult = await router.load(normalizeRequestHref(requestUrl)); + const initialData = loadResult.dehydrate() as unknown as InitialData; + + return { + initialData, + renderer: new JSXRenderer(EntryServer, initialData, { + htmlString, + }), + }; } diff --git a/apps/react/boilerplate-vite/src/ssr/server.ts b/apps/react/boilerplate-vite/src/ssr/server.ts index e7e369552..b9ac115a2 100644 --- a/apps/react/boilerplate-vite/src/ssr/server.ts +++ b/apps/react/boilerplate-vite/src/ssr/server.ts @@ -1,15 +1,37 @@ import * as process from "node:process"; -import { serveStream } from "@canonical/react-ssr/server"; import express from "express"; -import createRenderer from "./renderer.js"; +import { getAuthRedirectHref, normalizeRequestHref } from "../routes.js"; +import prepareRender from "./renderer.js"; const PORT = process.env.PORT || 5173; const app = express(); -app.use(/^\/(assets|public)/, express.static("dist/client/assets")); +app.use(/^\/assets/, express.static("dist/client/assets")); -app.use(serveStream(createRenderer)); +app.use(async (req, res, next) => { + try { + const requestHref = normalizeRequestHref(req.originalUrl || req.url || "/"); + const redirectHref = getAuthRedirectHref(requestHref); + + if (redirectHref) { + res.redirect(302, redirectHref); + return; + } + + const { renderer } = await prepareRender(requestHref); + + const result = renderer.renderToPipeableStream(); + + await renderer.statusReady; + res.writeHead(renderer.statusCode, { + "Content-Type": "text/html; charset=utf-8", + }); + result.pipe(res); + } catch (error) { + next(error); + } +}); app.listen(PORT, () => { console.log(`Server started on http://localhost:${PORT}/`); diff --git a/apps/react/boilerplate-vite/vitest.config.ts b/apps/react/boilerplate-vite/vitest.config.ts new file mode 100644 index 000000000..7ace23cd7 --- /dev/null +++ b/apps/react/boilerplate-vite/vitest.config.ts @@ -0,0 +1,25 @@ +import react from "@vitejs/plugin-react"; +import tsconfigPaths from "vite-tsconfig-paths"; +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + plugins: [tsconfigPaths(), react()], + test: { + coverage: { + exclude: [], + include: [ + "src/Navigation.tsx", + "src/domains/**/*.tsx", + "src/routes.tsx", + "src/ssr/Shell.tsx", + "src/ssr/entry-server.tsx", + ], + provider: "v8", + reporter: ["text"], + }, + environment: "jsdom", + globals: true, + include: ["src/**/*.test.ts", "src/**/*.test.tsx", "src/**/*.tests.tsx"], + setupFiles: ["./vitest.setup.ts"], + }, +}); diff --git a/apps/react/boilerplate-vite/vitest.setup.ts b/apps/react/boilerplate-vite/vitest.setup.ts new file mode 100644 index 000000000..f149f27ae --- /dev/null +++ b/apps/react/boilerplate-vite/vitest.setup.ts @@ -0,0 +1 @@ +import "@testing-library/jest-dom/vitest"; diff --git a/bun.lock b/bun.lock index b121be428..bc6d0aefc 100644 --- a/bun.lock +++ b/bun.lock @@ -14,18 +14,20 @@ "name": "@canonical/react-boilerplate-vite", "version": "0.22.0", "dependencies": { - "@canonical/react-ds-global": "^0.20.0", - "@canonical/react-ssr": "^0.20.0", - "@canonical/storybook-config": "^0.20.0", - "@canonical/styles": "^0.20.0", + "@canonical/react-ds-global": "^0.22.0", + "@canonical/react-ssr": "^0.22.0", + "@canonical/router-core": "^0.22.0", + "@canonical/router-react": "^0.22.0", + "@canonical/storybook-config": "^0.22.0", + "@canonical/styles": "^0.22.0", "express": "^5.2.1", "react": "^19.2.4", "react-dom": "^19.2.4", }, "devDependencies": { "@biomejs/biome": "2.4.9", - "@canonical/biome-config": "^0.20.0", - "@canonical/typescript-config-react": "^0.20.0", + "@canonical/biome-config": "^0.22.0", + "@canonical/typescript-config-react": "^0.22.0", "@chromatic-com/storybook": "^5.0.1", "@storybook/react-vite": "^10.3.1", "@testing-library/jest-dom": "^6.9.1", @@ -35,6 +37,7 @@ "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^6.0.0", + "@vitest/coverage-v8": "4.1.2", "globals": "^17.4.0", "storybook": "^10.3.1", "typescript": "^5.9.3", @@ -134,10 +137,10 @@ "name": "@canonical/storybook-config", "version": "0.22.0", "dependencies": { - "@canonical/ds-assets": "^0.21.0", - "@canonical/storybook-addon-shell-theme": "^0.21.0", - "@canonical/storybook-addon-utils": "^0.21.0", - "@canonical/styles-debug": "^0.21.0", + "@canonical/ds-assets": "^0.22.0", + "@canonical/storybook-addon-shell-theme": "^0.22.0", + "@canonical/storybook-addon-utils": "^0.22.0", + "@canonical/styles-debug": "^0.22.0", "@chromatic-com/storybook": "^5.0.1", "@storybook/addon-a11y": "^10.3.1", "@storybook/addon-docs": "^10.3.1", @@ -149,8 +152,8 @@ }, "devDependencies": { "@biomejs/biome": "2.4.9", - "@canonical/biome-config": "^0.21.0", - "@canonical/typescript-config-react": "^0.21.0", + "@canonical/biome-config": "^0.22.0", + "@canonical/typescript-config-react": "^0.22.0", "@types/node": "^24.12.0", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", @@ -316,9 +319,9 @@ "version": "0.22.0", "devDependencies": { "@biomejs/biome": "2.4.9", - "@canonical/biome-config": "^0.21.0", - "@canonical/typescript-config": "^0.21.0", - "@canonical/webarchitect": "^0.21.0", + "@canonical/biome-config": "^0.22.0", + "@canonical/typescript-config": "^0.22.0", + "@canonical/webarchitect": "^0.22.0", "@types/jsdom": "^28.0.0", "@types/node": "^24.12.0", "jsdom": "^28.1.0", @@ -591,22 +594,22 @@ "name": "@canonical/react-ds-global", "version": "0.22.0", "dependencies": { - "@canonical/ds-assets": "^0.20.0", - "@canonical/storybook-config": "^0.20.0", - "@canonical/styles": "^0.20.0", - "@canonical/styles-old": "^0.20.0", - "@canonical/utils": "^0.20.0", + "@canonical/ds-assets": "^0.22.0", + "@canonical/storybook-config": "^0.22.0", + "@canonical/styles": "^0.22.0", + "@canonical/styles-old": "^0.22.0", + "@canonical/utils": "^0.22.0", "react": "^19.2.4", "react-dom": "^19.2.4", }, "devDependencies": { "@biomejs/biome": "2.4.9", - "@canonical/biome-config": "^0.20.0", + "@canonical/biome-config": "^0.22.0", "@canonical/design-tokens": "^0.6.0", - "@canonical/ds-types": "^0.20.0", - "@canonical/storybook-helpers": "^0.20.0", - "@canonical/typescript-config-react": "^0.20.0", - "@canonical/webarchitect": "^0.20.0", + "@canonical/ds-types": "^0.22.0", + "@canonical/storybook-helpers": "^0.22.0", + "@canonical/typescript-config-react": "^0.22.0", + "@canonical/webarchitect": "^0.22.0", "@chromatic-com/storybook": "^5.0.1", "@storybook/addon-docs": "^10.3.1", "@storybook/react-vite": "^10.3.1", @@ -832,9 +835,9 @@ "version": "0.22.0", "devDependencies": { "@biomejs/biome": "2.4.9", - "@canonical/biome-config": "^0.21.0", - "@canonical/typescript-config": "^0.21.0", - "@canonical/webarchitect": "^0.21.0", + "@canonical/biome-config": "^0.22.0", + "@canonical/typescript-config": "^0.22.0", + "@canonical/webarchitect": "^0.22.0", "copyfiles": "^2.4.1", "storybook": "^10.3.1", "typescript": "^5.9.3", @@ -902,14 +905,14 @@ "name": "@canonical/storybook-addon-utils", "version": "0.22.0", "dependencies": { - "@canonical/styles-debug": "^0.21.0", + "@canonical/styles-debug": "^0.22.0", "@storybook/icons": "^2.0.1", }, "devDependencies": { "@biomejs/biome": "2.4.9", - "@canonical/biome-config": "^0.21.0", - "@canonical/styles": "^0.21.0", - "@canonical/typescript-config-react": "^0.21.0", + "@canonical/biome-config": "^0.22.0", + "@canonical/styles": "^0.22.0", + "@canonical/typescript-config-react": "^0.22.0", "@storybook/addon-docs": "^10.3.1", "@storybook/react-vite": "^10.3.1", "@types/react": "^19.2.14", @@ -931,15 +934,15 @@ "name": "@canonical/storybook-helpers", "version": "0.22.0", "dependencies": { - "@canonical/ds-types": "^0.21.0", + "@canonical/ds-types": "^0.22.0", "react": "^19.2.4", "react-dom": "^19.2.4", }, "devDependencies": { "@biomejs/biome": "2.4.9", - "@canonical/biome-config": "^0.21.0", - "@canonical/typescript-config-react": "^0.21.0", - "@canonical/webarchitect": "^0.21.0", + "@canonical/biome-config": "^0.22.0", + "@canonical/typescript-config-react": "^0.22.0", + "@canonical/webarchitect": "^0.22.0", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", "@types/react": "^19.2.14", @@ -961,24 +964,24 @@ }, "devDependencies": { "@biomejs/biome": "2.4.9", - "@canonical/biome-config": "^0.20.0", + "@canonical/biome-config": "^0.22.0", }, }, "packages/styles-old/main/canonical": { "name": "@canonical/styles-old", "version": "0.22.0", "dependencies": { - "@canonical/styles-elements": "^0.20.0", - "@canonical/styles-modes-canonical": "^0.20.0", - "@canonical/styles-modes-density": "^0.20.0", - "@canonical/styles-modes-intents": "^0.20.0", - "@canonical/styles-modes-motion": "^0.20.0", - "@canonical/styles-primitives-canonical": "^0.20.0", + "@canonical/styles-elements": "^0.22.0", + "@canonical/styles-modes-canonical": "^0.22.0", + "@canonical/styles-modes-density": "^0.22.0", + "@canonical/styles-modes-intents": "^0.22.0", + "@canonical/styles-modes-motion": "^0.22.0", + "@canonical/styles-primitives-canonical": "^0.22.0", "normalize.css": "^8.0.1", }, "devDependencies": { "@biomejs/biome": "2.4.9", - "@canonical/biome-config": "^0.20.0", + "@canonical/biome-config": "^0.22.0", }, }, "packages/styles-old/modes/canonical": { @@ -986,7 +989,7 @@ "version": "0.22.0", "devDependencies": { "@biomejs/biome": "2.4.9", - "@canonical/biome-config": "^0.20.0", + "@canonical/biome-config": "^0.22.0", }, }, "packages/styles-old/modes/density": { @@ -994,7 +997,7 @@ "version": "0.22.0", "devDependencies": { "@biomejs/biome": "2.4.9", - "@canonical/biome-config": "^0.20.0", + "@canonical/biome-config": "^0.22.0", }, }, "packages/styles-old/modes/intents": { @@ -1005,7 +1008,7 @@ }, "devDependencies": { "@biomejs/biome": "2.4.9", - "@canonical/biome-config": "^0.20.0", + "@canonical/biome-config": "^0.22.0", }, }, "packages/styles-old/modes/motion": { @@ -1016,7 +1019,7 @@ }, "devDependencies": { "@biomejs/biome": "2.4.9", - "@canonical/biome-config": "^0.20.0", + "@canonical/biome-config": "^0.22.0", }, }, "packages/styles-old/primitives/canonical": { @@ -1029,7 +1032,7 @@ }, "devDependencies": { "@biomejs/biome": "2.4.9", - "@canonical/biome-config": "^0.20.0", + "@canonical/biome-config": "^0.22.0", "@canonical/tokens": "^0.9.0", }, }, @@ -1038,7 +1041,7 @@ "version": "0.22.0", "devDependencies": { "@biomejs/biome": "2.4.9", - "@canonical/biome-config": "^0.21.0", + "@canonical/biome-config": "^0.22.0", }, "peerDependencies": { "@canonical/design-tokens": ">=0.4.0", @@ -1052,12 +1055,12 @@ "version": "0.22.0", "dependencies": { "@canonical/design-tokens": "^0.6.0", - "@canonical/styles-typography": "^0.21.0", + "@canonical/styles-typography": "^0.22.0", "normalize.css": "^8.0.1", }, "devDependencies": { "@biomejs/biome": "2.4.9", - "@canonical/biome-config": "^0.21.0", + "@canonical/biome-config": "^0.22.0", }, "peerDependencies": { "@canonical/ds-assets": ">=0.18.0", @@ -1078,8 +1081,8 @@ }, "devDependencies": { "@biomejs/biome": "2.4.9", - "@canonical/biome-config": "^0.21.0", - "@canonical/typescript-config": "^0.21.0", + "@canonical/biome-config": "^0.22.0", + "@canonical/typescript-config": "^0.22.0", "@types/bun": "^1.3.10", "@types/node": "^24.12.0", "typescript": "^5.9.3", @@ -3670,6 +3673,12 @@ "@bundled-es-modules/glob/glob": ["glob@10.5.0", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", "minimatch": "^9.0.4", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg=="], + "@canonical/react-boilerplate-vite/@vitest/coverage-v8": ["@vitest/coverage-v8@4.1.2", "", { "dependencies": { "@bcoe/v8-coverage": "^1.0.2", "@vitest/utils": "4.1.2", "ast-v8-to-istanbul": "^1.0.0", "istanbul-lib-coverage": "^3.2.2", "istanbul-lib-report": "^3.0.1", "istanbul-reports": "^3.2.0", "magicast": "^0.5.2", "obug": "^2.1.1", "std-env": "^4.0.0-rc.1", "tinyrainbow": "^3.1.0" }, "peerDependencies": { "@vitest/browser": "4.1.2", "vitest": "4.1.2" }, "optionalPeers": ["@vitest/browser"] }, "sha512-sPK//PHO+kAkScb8XITeB1bf7fsk85Km7+rt4eeuRR3VS1/crD47cmV5wicisJmjNdfeokTZwjMk4Mj2d58Mgg=="], + + "@canonical/react-ssr/@vitest/coverage-v8": ["@vitest/coverage-v8@4.1.2", "", { "dependencies": { "@bcoe/v8-coverage": "^1.0.2", "@vitest/utils": "4.1.2", "ast-v8-to-istanbul": "^1.0.0", "istanbul-lib-coverage": "^3.2.2", "istanbul-lib-report": "^3.0.1", "istanbul-reports": "^3.2.0", "magicast": "^0.5.2", "obug": "^2.1.1", "std-env": "^4.0.0-rc.1", "tinyrainbow": "^3.1.0" }, "peerDependencies": { "@vitest/browser": "4.1.2", "vitest": "4.1.2" }, "optionalPeers": ["@vitest/browser"] }, "sha512-sPK//PHO+kAkScb8XITeB1bf7fsk85Km7+rt4eeuRR3VS1/crD47cmV5wicisJmjNdfeokTZwjMk4Mj2d58Mgg=="], + + "@canonical/router-react/@vitest/coverage-v8": ["@vitest/coverage-v8@4.1.2", "", { "dependencies": { "@bcoe/v8-coverage": "^1.0.2", "@vitest/utils": "4.1.2", "ast-v8-to-istanbul": "^1.0.0", "istanbul-lib-coverage": "^3.2.2", "istanbul-lib-report": "^3.0.1", "istanbul-reports": "^3.2.0", "magicast": "^0.5.2", "obug": "^2.1.1", "std-env": "^4.0.0-rc.1", "tinyrainbow": "^3.1.0" }, "peerDependencies": { "@vitest/browser": "4.1.2", "vitest": "4.1.2" }, "optionalPeers": ["@vitest/browser"] }, "sha512-sPK//PHO+kAkScb8XITeB1bf7fsk85Km7+rt4eeuRR3VS1/crD47cmV5wicisJmjNdfeokTZwjMk4Mj2d58Mgg=="], + "@canonical/styles-primitives-canonical/@canonical/tokens": ["@canonical/tokens@0.9.0", "", { "dependencies": { "style-dictionary": "^4.3.3" } }, "sha512-S4YC2G80NxbmFU/JgYBJn4zXaQdVkJeIBFcXQurELQXlzHLVOizgacmfVRnw9UKfAGoOK6JezwLwhLyVWI6ozA=="], "@digitalbazaar/http-client/undici": ["undici@6.24.1", "", {}, "sha512-sC+b0tB1whOCzbtlx20fx3WgCXwkW627p4EA9uM+/tNNPkSS+eSEld6pAs9nDv7WbY1UUljBMYPtu9BCOrCWKA=="], @@ -4074,6 +4083,12 @@ "@bundled-es-modules/glob/glob/path-scurry": ["path-scurry@1.11.1", "", { "dependencies": { "lru-cache": "^10.2.0", "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" } }, "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA=="], + "@canonical/react-boilerplate-vite/@vitest/coverage-v8/@vitest/utils": ["@vitest/utils@4.1.2", "", { "dependencies": { "@vitest/pretty-format": "4.1.2", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-xw2/TiX82lQHA06cgbqRKFb5lCAy3axQ4H4SoUFhUsg+wztiet+co86IAMDtF6Vm1hc7J6j09oh/rgDn+JdKIQ=="], + + "@canonical/react-ssr/@vitest/coverage-v8/@vitest/utils": ["@vitest/utils@4.1.2", "", { "dependencies": { "@vitest/pretty-format": "4.1.2", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-xw2/TiX82lQHA06cgbqRKFb5lCAy3axQ4H4SoUFhUsg+wztiet+co86IAMDtF6Vm1hc7J6j09oh/rgDn+JdKIQ=="], + + "@canonical/router-react/@vitest/coverage-v8/@vitest/utils": ["@vitest/utils@4.1.2", "", { "dependencies": { "@vitest/pretty-format": "4.1.2", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-xw2/TiX82lQHA06cgbqRKFb5lCAy3axQ4H4SoUFhUsg+wztiet+co86IAMDtF6Vm1hc7J6j09oh/rgDn+JdKIQ=="], + "@inquirer/core/wrap-ansi/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], "@inquirer/core/wrap-ansi/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], diff --git a/docs/how-to-guides/MIGRATE_FROM_TANSTACK_ROUTER.md b/docs/how-to-guides/MIGRATE_FROM_TANSTACK_ROUTER.md new file mode 100644 index 000000000..7b4eb41c3 --- /dev/null +++ b/docs/how-to-guides/MIGRATE_FROM_TANSTACK_ROUTER.md @@ -0,0 +1,224 @@ +# Migrate from TanStack Router to `@canonical/router-react` + +This guide maps the most common TanStack Router concepts to `@canonical/router-core` and `@canonical/router-react`. + +## When this migration is a good fit + +Choose the Canonical router stack when you want: + +- flat route definitions instead of nested file or tree APIs +- wrapper composition that is explicit and reusable across route groups +- lightweight typed navigation helpers +- SSR dehydration without a framework-specific route compiler +- a small surface area you can embed into existing apps and generators + +## Concept mapping + +| TanStack Router | Canonical router | +|---|---| +| `createRootRoute`, `createRoute`, route tree | `route()` plus a flat `RouteMap` | +| layout routes | `wrapper()` + `group()` | +| `createRouter()` | `createRouter()` | +| `Link` | `Link` | +| `Outlet` | `Outlet` | +| loaders | route `fetch()` | +| route component | route `content` | +| route error component | route `error` | +| redirects | static redirect routes or `redirect()` | +| before-load auth checks | middleware such as `withAuth()` | +| dehydrated loader state | `dehydrate()` / `hydratedState` / `createHydratedRouter()` | + +## 1. Replace the route tree with flat routes + +### TanStack Router + +```tsx +const rootRoute = createRootRoute({ + component: RootLayout, +}); + +const accountRoute = createRoute({ + getParentRoute: () => rootRoute, + path: "/account/$team", + loader: ({ params }) => fetchTeam(params.team), + component: AccountPage, +}); + +const routeTree = rootRoute.addChildren([accountRoute]); +``` + +### Canonical router + +```tsx +import { createRouter, route } from "@canonical/router-core"; + +const routes = { + account: route({ + url: "/account/:team", + fetch: async ({ team }) => fetchTeam(team), + content: ({ data }) => , + }), +} as const; + +const router = createRouter(routes); +``` + +## 2. Replace layout routes with wrappers + +### TanStack Router + +A parent route often carries layout and shared loader concerns. + +### Canonical router + +Move that concern into a wrapper and apply it to a flat route list. + +```tsx +import { group, route, wrapper } from "@canonical/router-core"; + +const appShell = wrapper({ + id: "app:shell", + component: ({ children }) => {children}, + fetch: async () => fetchViewer(), +}); + +const [dashboardRoute, reportsRoute] = group(appShell, [ + route({ url: "/dashboard", content: () => }), + route({ url: "/reports", content: () => }), +] as const); +``` + +## 3. Move loaders to `fetch()` + +TanStack Router loaders become route or wrapper `fetch()` functions. + +```tsx +const userRoute = route({ + url: "/users/:id", + fetch: async ({ id }, search, context) => fetchUser(id, search, context), + content: ({ data }) => , +}); +``` + +## 4. Move route components to `content` + +`component` maps directly to `content`. + +```tsx +const settingsRoute = route({ + url: "/settings", + content: () => , +}); +``` + +## 5. Replace `beforeLoad` with middleware or wrapper fetches + +If your TanStack route used `beforeLoad` for auth or locale setup, prefer one of these: + +- **middleware** when the concern should be applied at router-creation time +- **wrapper fetch** when the concern belongs to a layout boundary +- **route fetch** when the concern is route-local + +### Example: auth + +```tsx +import { redirect, type AnyRoute } from "@canonical/router-core"; + +function withAuth(loginPath: string) { + return (currentRoute: TRoute): TRoute => { + if (currentRoute.url !== "/account") { + return currentRoute; + } + + return { + ...currentRoute, + fetch: async (params, search, context) => { + if (search.auth !== "1") { + redirect(`${loginPath}?from=/account`, 302); + } + + return currentRoute.fetch?.(params, search, context); + }, + } as TRoute; + }; +} +``` + +See [ROUTER_MIDDLEWARE_COOKBOOK.md](ROUTER_MIDDLEWARE_COOKBOOK.md) for more patterns. + +## 6. Replace router context with `RouterProvider` + +```tsx +import { Outlet, RouterProvider } from "@canonical/router-react"; + +export default function Application() { + return ( + + + + ); +} +``` + +## 7. Replace TanStack `Link` with Canonical `Link` + +```tsx +import { Link } from "@canonical/router-react"; + + params={{ team: "web" }} to="account"> + Account + +``` + +This keeps route-name typing and adds hover prefetch. + +## 8. SSR migration + +### TanStack approach + +- run loaders on the server +- serialize router state +- hydrate on the client + +### Canonical approach + +The same flow exists, but is explicit: + +```tsx +const serverRouter = createRouter(routes); +await serverRouter.load("/account/web"); +const initialData = serverRouter.dehydrate(); +``` + +Client side: + +```tsx +const router = createHydratedRouter(routes); +``` + +Or pass `hydratedState` manually to `createRouter()`. + +## 9. Error boundaries + +TanStack route error boundaries map to: + +- route-level `error` +- wrapper-level `error` + +Use route `error` when the fallback is local to one route. Use wrapper `error` when the fallback belongs to a layout or shell. + +## Migration checklist + +- [ ] Flatten the route tree into a `RouteMap` +- [ ] Convert parent/layout routes into wrappers +- [ ] Move loaders into `fetch()` +- [ ] Move route components into `content` +- [ ] Move redirects to static redirect routes or `redirect()` +- [ ] Replace `Link`/`Outlet` imports with `@canonical/router-react` +- [ ] Add `RouterProvider` +- [ ] Replace SSR dehydration with `dehydrate()` and `createHydratedRouter()` +- [ ] Move auth/i18n/timing concerns into middleware or wrapper fetches + +## Reference implementation + +See [apps/react/boilerplate-vite](../../apps/react/boilerplate-vite) for a working SSR React example using the Canonical router stack. diff --git a/docs/how-to-guides/ROUTER_MIDDLEWARE_COOKBOOK.md b/docs/how-to-guides/ROUTER_MIDDLEWARE_COOKBOOK.md new file mode 100644 index 000000000..a2c680205 --- /dev/null +++ b/docs/how-to-guides/ROUTER_MIDDLEWARE_COOKBOOK.md @@ -0,0 +1,189 @@ +# Router middleware cookbook + +Middleware in `@canonical/router-core` transforms routes once, before the router is created. Use middleware when a concern should apply consistently across a route map without changing every route declaration by hand. + +## When to use middleware + +Use middleware for: + +- auth redirects +- locale prefixes or locale-aware fetch setup +- timing or tracing +- shared wrapper or error-boundary policy + +Avoid middleware when the concern is only relevant to a single route. In that case, keep it in the route `fetch()` or `content`. + +## Shape of a middleware + +```ts +import type { AnyRoute } from "@canonical/router-core"; + +function middlewareExample() { + return (currentRoute: TRoute): TRoute => { + return currentRoute; + }; +} +``` + +## `withAuth(loginPath)` + +Use this when a route should redirect anonymous visitors before loading protected data. + +```ts +import { redirect, type AnyRoute } from "@canonical/router-core"; + +function withAuth(loginPath: string) { + const protectedPaths = new Set(["/account", "/settings"]); + + return (currentRoute: TRoute): TRoute => { + if (!protectedPaths.has(currentRoute.url)) { + return currentRoute; + } + + const currentFetch = currentRoute.fetch; + + return { + ...currentRoute, + fetch: async (params, search, context) => { + const record = search as Record; + + if (record.auth !== "1") { + const from = currentRoute.render((params ?? {}) as Record); + redirect(`${loginPath}?from=${encodeURIComponent(from)}`, 302); + } + + return currentFetch?.(params, search, context); + }, + } as TRoute; + }; +} +``` + +### Rationale + +- keeps auth policy centralized +- preserves typed route helpers +- avoids duplicating redirect logic in every protected route + +## `withI18n(defaultLocale)` + +Use this when your app prefixes routes or injects locale context into loaders. + +```ts +import type { AnyRoute } from "@canonical/router-core"; + +function withI18n(defaultLocale: string) { + return (currentRoute: TRoute): TRoute => { + return { + ...currentRoute, + url: `/:locale${currentRoute.url === "/" ? "" : currentRoute.url}`, + fetch: currentRoute.fetch + ? async (params, search, context) => { + const locale = params.locale ?? defaultLocale; + return currentRoute.fetch?.( + params, + { ...search, locale }, + context, + ); + } + : undefined, + } as TRoute; + }; +} +``` + +### Rationale + +- one place to enforce locale-aware URLs +- useful for boilerplates and generators +- works with both route fetches and wrapper fetches + +## `withTiming(report)` + +Use this to measure route loader duration. + +```ts +import type { AnyRoute } from "@canonical/router-core"; + +function withTiming( + report: (event: { route: string; durationMs: number }) => void, +) { + return (currentRoute: TRoute): TRoute => { + if (!currentRoute.fetch) { + return currentRoute; + } + + return { + ...currentRoute, + fetch: async (params, search, context) => { + const startedAt = performance.now(); + + try { + return await currentRoute.fetch?.(params, search, context); + } finally { + report({ + durationMs: performance.now() - startedAt, + route: currentRoute.url, + }); + } + }, + } as TRoute; + }; +} +``` + +### Rationale + +- keeps instrumentation orthogonal to route logic +- easy to disable in tests +- works for analytics, tracing, and SLO dashboards + +## `withErrorBoundary(wrapperDef)` + +Use this when multiple routes should share the same wrapper-level error experience. + +```ts +import { group, wrapper, type AnyRoute } from "@canonical/router-core"; + +const shellBoundary = wrapper({ + id: "shell:error-boundary", + component: ({ children }) => children, + error: ({ status }) => `Shell error ${status}`, +}); + +function withErrorBoundary() { + return (currentRoute: TRoute): TRoute => { + return { + ...currentRoute, + wrappers: [shellBoundary, ...currentRoute.wrappers], + } as TRoute; + }; +} +``` + +### Rationale + +- consistent fallback behavior +- keeps route declarations focused on content and data +- can be layered with layout wrappers + +## Composition order + +Middleware runs in array order. Start with broad URL policy, then auth, then instrumentation. + +```ts +const router = createRouter(routes, { + middleware: [withI18n("en"), withAuth("/login"), withTiming(report)], +}); +``` + +## Rules of thumb + +- return the original route unchanged when the rule does not apply +- preserve `currentRoute.fetch` when wrapping loader logic +- prefer middleware for cross-cutting policy, wrappers for layout and shared UI +- document any redirect or URL-shape changes clearly for consumers + +## Reference implementation + +The live auth example is in [apps/react/boilerplate-vite/src/routes.tsx](../../apps/react/boilerplate-vite/src/routes.tsx). diff --git a/docs/references/ROUTER_API.md b/docs/references/ROUTER_API.md new file mode 100644 index 000000000..665a596cf --- /dev/null +++ b/docs/references/ROUTER_API.md @@ -0,0 +1,1040 @@ +# Router API reference + +This document is the real API reference for `@canonical/router-core` and `@canonical/router-react`. + +Use it when you already understand the mental model and want to answer one of these questions quickly: + +- Which function should I call? +- What does a router instance expose? +- Which type should I reach for in app code? +- How does the React package layer on top of the core package? + +For tutorial-style guidance, start with: + +- [packages/runtime/router/README.md](../../packages/runtime/router/README.md) +- [packages/react/router/README.md](../../packages/react/router/README.md) +- [docs/how-to-guides/MIGRATE_FROM_TANSTACK_ROUTER.md](../how-to-guides/MIGRATE_FROM_TANSTACK_ROUTER.md) +- [docs/how-to-guides/ROUTER_MIDDLEWARE_COOKBOOK.md](../how-to-guides/ROUTER_MIDDLEWARE_COOKBOOK.md) + +## `@canonical/router-core` + +`@canonical/router-core` owns route definitions, matching, loading, navigation intents, router state, dehydration, and optional accessibility orchestration. + +### Route construction + +#### `route(definition)` + +Creates a typed route definition and attaches a path codec. + +Supports two shapes: + +```ts +route({ + url: "/users/:id", + content, + fetch, + search, + error, + wrappers, +}); + +route({ + url: "/old-path", + redirect: "/new-path", + status: 301, + wrappers, +}); +``` + +Behavior: + +- adds a `parse(url)` function that returns typed params or `null` +- adds a `render(params)` function that builds the concrete pathname +- normalizes `wrappers` to an array +- preserves either the data-route or redirect-route shape + +Use `route()` for every route. It is the only route constructor. + +##### Exact route authoring shapes + +`route()` and `wrapper()` are driven by these contracts. + +```ts +interface NavigationContext { + readonly signal: AbortSignal; +} + +interface RouteContentProps { + readonly params: TParams; + readonly search: TSearch; + readonly data: TData; +} + +interface RouteErrorProps { + readonly error: unknown; + readonly status: number; + readonly params: RouteParams; + readonly search: TSearch; + readonly url: string; +} + +interface DataRouteInput { + readonly url: TPath; + readonly content: (props: RouteContentProps<...>) => TRendered; + readonly fetch?: ( + params: RouteParams, + search: InferSearch, + context: NavigationContext, + ) => Promise; + readonly search?: TSearchSchema; + readonly error?: (props: RouteErrorProps>) => TRendered; + readonly wrappers?: readonly AnyWrapper[]; +} + +interface RedirectRouteInput { + readonly url: TPath; + readonly redirect: string; + readonly status: 301 | 308; + readonly wrappers?: readonly AnyWrapper[]; +} +``` + +Practical reading: + +- `content` always receives `params`, `search`, and `data` +- `fetch` always receives `params`, `search`, and an abort `signal` +- route `error` receives the thrown error, resolved status, params, search, and failing URL +- static redirect routes are declaration-time redirects; dynamic redirects belong in `fetch()` via `redirect()` + +##### Lazy content and `content.preload` + +The content leg is the code-splitting boundary. Assign a dynamic import to `content` and attach a `preload` method so the router can prefetch the module on hover: + +```ts +const lazyContent = Object.assign( + (props: RouteContentProps<...>) => import("./Page.js").then(m => m.default(props)), + { preload: () => import("./Page.js") }, +); + +route({ url: "/page", content: lazyContent }); +``` + +When `prefetch()` is called (e.g. on link hover), the router calls `content.preload()` if present. The returned promise resolves the module so subsequent renders hit the module cache instead of triggering a network request. + +#### `wrapper(definition)` + +Creates a nominal wrapper definition. + +```ts +const shell = wrapper({ + id: "app:shell", + component: ({ children }) => children, + fetch, + error, +}); +``` + +Wrappers are reusable layout and boundary units. A wrapper can: + +- render shared UI around matched content +- fetch shared data before route rendering +- provide a shared error fallback + +Exact shape: + +```ts +interface WrapperComponentProps { + readonly data: TData; + readonly children: TRendered; +} + +interface WrapperErrorProps { + readonly error: unknown; + readonly status: number; + readonly params: TParams; + readonly search: TSearch; + readonly url: string; +} + +interface WrapperDefinition { + readonly id: string; + readonly component: (props: WrapperComponentProps) => TRendered; + readonly fetch?: ( + params: RouteParamValues, + context: NavigationContext, + ) => Promise; + readonly error?: (props: WrapperErrorProps) => TRendered; +} +``` + +Important difference from route loaders: + +- wrapper `fetch` receives `params` and `context` +- route `fetch` receives `params`, `search`, and `context` +- wrapper data is stored by wrapper id in `wrapperData` + +#### `group(wrapper, routes)` + +Prepends one wrapper to every route in a flat route list. + +```ts +const [dashboard, reports] = group(shell, [ + route({ url: "/dashboard", content: Dashboard }), + route({ url: "/reports", content: Reports }), +] as const); +``` + +Use `group()` when several flat routes should share the same wrapper stack. + +#### `applyMiddleware(routes, middleware)` + +Applies route endomorphisms to a route list. + +```ts +const nextRoutes = applyMiddleware(routes, [withI18n(), withAuth()]); +``` + +Behavior: + +- middleware runs outermost-first in array order +- each middleware receives one route and returns one route +- useful for auth, i18n, timing, or generated route transforms + +### Redirects and errors + +#### `redirect(to, status?)` + +Throws a redirect value during route or wrapper loading. + +```ts +redirect("/login", 302); +``` + +Accepted statuses: `301 | 302 | 307 | 308`. + +Use this inside `fetch()` when the redirect depends on runtime state. + +#### `Redirect` + +The redirect error class thrown by `redirect()`. + +You normally do not instantiate this directly in app code. + +#### `StatusResponse` + +Error helper with an HTTP-like status code and optional typed data. + +```ts +new StatusResponse(status: number, data?: TData) +``` + +Properties: `status: number`, `data: TData | undefined`. + +Use it when a route or wrapper should fail with a structured status that can be rendered by route- or wrapper-level error boundaries. + +### Router creation and adapters + +#### `createRouter(routes, options?)` + +Creates the router instance. + +```ts +const router = createRouter(routes, { + adapter, + middleware, + notFound, + hydratedState, + initialUrl, + accessibility, +}); +``` + +Key behavior: + +- applies route middleware before setup +- sorts routes by matching priority +- wires an adapter when provided +- auto-initializes browser accessibility helpers when browser globals exist +- can hydrate previously loaded server state +- returns a typed `Router` + +Exact option shape: + +```ts +interface RouterOptions { + readonly adapter?: PlatformAdapter; + readonly accessibility?: RouterAccessibilityOptions; + readonly hydratedState?: RouterDehydratedState; + readonly initialUrl?: string | URL; + readonly middleware?: readonly RouteMiddleware[]; + readonly notFound?: TNotFound; +} +``` + +Typical use: + +- browser app: provide `adapter`, or use `createHydratedRouter()` in React +- tests: provide `createMemoryAdapter()` +- SSR hydrate: provide `hydratedState` +- app-wide transforms: provide `middleware` +- custom 404 screen: provide `notFound` + +#### `createBrowserAdapter(windowLike?)` + +Creates a `PlatformAdapter` backed by `window.history` and `popstate`. + +Use this in browser-only or framework-integrated setups. `@canonical/router-react` does this for you in `createHydratedRouter()`. + +Adapter contract: + +```ts +interface PlatformAdapter { + getLocation(): string | URL; + navigate(url: string, options?: { replace?: boolean; state?: unknown }): void; + subscribe(callback: (location: string | URL) => void): () => void; +} +``` + +#### `createMemoryAdapter(initialUrl?)` + +Creates an in-memory history adapter. + +Useful for: + +- tests +- demos +- non-browser environments +- deterministic navigation assertions + +The returned adapter also exposes `back()` and `forward()`. + +```ts +interface MemoryAdapter extends PlatformAdapter { + back(): void; + forward(): void; +} +``` + +#### `createServerAdapter(initialUrl)` + +Creates a static adapter for one server request URL. + +Use it when you want router matching and loading against an explicit server request location, without client-side navigation. + +#### `createRouterStore(resolveMatch, initialUrl?)` + +Low-level state store used by the router implementation. + +```ts +createRouterStore( + resolveMatch: (input: string | URL) => RouterMatch | null, + initialUrl: string | URL = "/", +): RouterStore +``` + +Most app code should not call this directly. Reach for it only when you are extending router internals or building alternate bindings. + +#### `createSubject()` + +Minimal observable primitive used by store internals. + +#### `createTrackedLocation()` + +Utility for tracking which location properties a consumer accessed. This supports fine-grained subscriptions in bindings. + +### The `Router` instance + +`createRouter()` returns a `Router` with these high-value members. + +#### Properties + +| Member | Meaning | +|---|---| +| `routes` | The resolved typed route map. | +| `notFound` | Optional not-found route definition. | +| `adapter` | Active platform adapter or `null`. | +| `store` | The underlying `RouterStore`. | + +The router instance is the runtime boundary between route definitions and UI bindings. + +- core code can call it directly +- React bindings subscribe to it +- SSR uses `load()`, `dehydrate()`, and `render()` + +#### Lookup and state + +| Member | Meaning | +|---|---| +| `getRoute(name)` | Returns one route definition by name. | +| `getState()` | Returns the current router state. | +| `getTrackedLocation(onAccess)` | Returns a tracked location proxy for fine-grained subscriptions. | +| `match(url)` | Returns the current route match without loading data. | + +#### Navigation and loading + +| Member | Meaning | +|---|---| +| `buildPath(name, options?)` | Builds a concrete href from route name, params, search, and hash. Options are required when the route has path params (`HasParams extends true`). | +| `navigate(name, options?)` | Builds a typed navigation intent and performs adapter navigation when available. Options are required when the route has path params. Supports `replace: boolean` to use `replaceState` instead of `pushState`. | +| `prefetch(name, options?)` | Preloads the route module and data. Options are required when the route has path params. | +| `load(url)` | Matches and resolves route data plus wrapper data for a URL. | +| `render(result?)` | Renders the currently loaded match tree. | + +#### SSR and lifecycle + +| Member | Meaning | +|---|---| +| `dehydrate()` | Serializes the currently loaded result, or returns `null`. | +| `hydrate(state)` | Restores a previous load result into the router. | +| `dispose()` | Tears down subscriptions and router-owned resources. | + +#### Subscriptions + +| Member | Meaning | +|---|---| +| `subscribe(listener)` | Subscribe to any router snapshot change. | +| `subscribeToNavigation(listener)` | Subscribe only to navigation state changes. | +| `subscribeToSearchParam(key, listener)` | Subscribe only to one query-string key. | + +#### Exact runtime state shapes + +```ts +type RouterNavigationState = "idle" | "loading"; + +interface RouterLocationState { + readonly hash: string; + readonly href: string; + readonly pathname: string; + readonly searchParams: URLSearchParams; + readonly status: number; + readonly url: URL; +} + +interface RouterState { + readonly location: RouterLocationState; + readonly match: RouterMatch | null; + readonly navigation: { + readonly state: RouterNavigationState; + }; +} + +interface RouterSnapshot extends RouterLocationState { + readonly match: RouterMatch | null; + readonly navigationState: RouterNavigationState; +} +``` + +Practical reading: + +- `getState()` returns nested state under `location` and `navigation` +- `subscribe()` listeners observe a flattened `RouterSnapshot` +- `status` lives on location state and mirrors the last resolved load status + +### Route and wrapper shapes + +#### `DataRouteInput` + +Input shape for a normal route. + +Important fields: + +| Field | Meaning | +|---|---| +| `url` | Route pattern such as `"/users/:id"`. | +| `content` | Render function for the route. | +| `fetch` | Optional loader. Receives `params`, typed `search`, and `NavigationContext`. | +| `search` | Optional schema used to infer typed query params. | +| `error` | Optional route-level error renderer. | +| `wrappers` | Optional wrapper stack. | + +#### `RedirectRouteInput` + +Input shape for a static redirect route. + +Important fields: + +| Field | Meaning | +|---|---| +| `url` | Source pattern. | +| `redirect` | Target location. | +| `status` | `301` or `308`. | +| `wrappers` | Optional wrapper stack. | + +#### `WrapperDefinition` + +Reusable wrapper contract. + +Important fields: + +| Field | Meaning | +|---|---| +| `id` | Stable wrapper identifier. Must be unique across the route set. | +| `component` | Wrapper renderer around child content. | +| `fetch` | Optional shared loader. | +| `error` | Optional shared error renderer. | + +### Matching and load results + +#### `RouterMatch` + +Union of: + +- `DataRouteMatch` +- `RedirectRouteMatch` +- `NotFoundRouteMatch` + +Every match includes: + +- `route` +- `params` +- `search` +- `pathname` +- `url` + +Variant-specific fields: + +```ts +interface DataRouteMatch { + readonly kind: "route"; + readonly name: TName; + readonly status: 200; +} + +interface RedirectRouteMatch { + readonly kind: "redirect"; + readonly name: TName; + readonly redirectTo: string; + readonly status: TRoute["status"]; +} + +interface NotFoundRouteMatch { + readonly kind: "not-found"; + readonly name: null; + readonly status: 404; +} +``` + +#### `RouterLoadResult` + +Result returned by `load()` and `hydrate()`. + +Important fields: + +| Field | Meaning | +|---|---| +| `match` | The resolved match or `null`. | +| `status` | Final HTTP-like status. | +| `routeData` | Resolved route loader data. | +| `wrapperData` | Resolved wrapper loader data keyed by wrapper id. | +| `error` | Caught error, if any. | +| `errorBoundary` | Indicates whether a route or wrapper boundary handled the failure. | +| `location` | Final resolved location state. | +| `dehydrate()` | Serializes the load result for SSR hydration. | + +Exact shape: + +```ts +interface RouterLoadResult { + dehydrate(): RouterDehydratedState; + readonly error: unknown; + readonly errorBoundary: { + readonly type: "route" | "wrapper"; + readonly wrapperId: string | null; + } | null; + readonly location: RouterLocationState; + readonly match: RouterMatch | null; + readonly routeData: unknown; + readonly status: number; + readonly wrapperData: Readonly>; +} +``` + +Interpretation: + +- `errorBoundary.type === "route"` means the route-level error renderer handled the failure +- `errorBoundary.type === "wrapper"` means one wrapper error renderer handled it +- `wrapperId` identifies which wrapper boundary handled the error + +#### `RouterDehydratedState` + +Serialized SSR payload. + +Important fields: + +| Field | Meaning | +|---|---| +| `href` | Original loaded href. | +| `kind` | `"route" | "not-found" | "unmatched"`. | +| `routeId` | Matched route name or `null`. | +| `routeData` | Serialized route data. | +| `wrapperData` | Serialized wrapper data. | +| `status` | Final status code. | + +### Configuration types + +#### `RouterOptions` + +Primary router configuration object. + +| Field | Meaning | +|---|---| +| `adapter` | Platform adapter implementation. | +| `accessibility` | Accessibility integrations and overrides. | +| `hydratedState` | Previous server load state to hydrate. | +| `initialUrl` | Explicit initial URL for routers without an adapter. | +| `middleware` | Route middleware array. | +| `notFound` | Optional not-found route. | + +#### `RouterAccessibilityOptions` + +Overrides for browser navigation affordances. + +| Field | Meaning | +|---|---| +| `document` | Document-like object used by accessibility helpers. | +| `focusManager` | Custom focus manager or `false` to disable. | +| `getTitle` | Title resolver for route announcements. | +| `routeAnnouncer` | Custom announcer or `false` to disable. | +| `scrollManager` | Custom scroll manager or `false` to disable. | +| `viewTransition` | Custom transition manager or `false` to disable. | + +### Type index by intent + +Use these types when building app code or helpers. + +#### Route authoring + +| Type | When to use it | +|---|---| +| `RouteInput` | Accept either a data route or redirect route. | +| `DataRouteInput` | Constrain an API to normal content routes. | +| `RedirectRouteInput` | Constrain an API to static redirects. | +| `RouteDefinition` | Accept a normalized route returned by `route()`. | +| `WrapperInput` / `WrapperDefinition` | Accept or return wrappers. | +| `RouteContentProps` | Type route render props. | +| `RouteErrorProps` | Type route error render props. | +| `WrapperComponentProps` | Type wrapper render props. | +| `WrapperErrorProps` | Type wrapper error props. | + +#### Type extraction helpers + +| Type | Meaning | +|---|---| +| `RouteMap` | A flat record of route names to routes. | +| `RouteName` | String union of route names. | +| `RouteOf` | Route type for one route name. | +| `ParamsOf` | Param object for one route. | +| `SearchOf` | Search type inferred from the route schema. | +| `DataOf` | Loader data type for one route. | +| `PathBuildOptions` | Options accepted by `buildPath()`. | +| `NavigationIntent` | Typed result of `navigate()`. | + +#### Middleware and grouping helpers + +| Type | Meaning | +|---|---| +| `RouteMiddleware` | One route-to-route transform. | +| `GroupedRoutes` | Output of `group()`. | +| `PrependWrapper` | Result type when a wrapper is prepended. | + +#### Matching and store internals + +| Type | Meaning | +|---|---| +| `RouterState` | Full router state tree. | +| `RouterSnapshot` | Snapshot returned to subscribers. | +| `RouterStore` | Store contract used by the router and bindings. | +| `TrackedLocation` | Proxy-backed location used for selective subscriptions. | +| `PlatformAdapter` | Adapter contract for browser, memory, and server runtimes. | +| `MemoryAdapter` | Platform adapter with `back()` and `forward()`. | + +#### Accessibility + +| Type | Meaning | +|---|---| +| `RouterAccessibilityContext` | Input passed to `getTitle`. | +| `FocusManagerLike` | Focus handoff contract. | +| `RouteAnnouncerLike` | Screen-reader announcement contract. | +| `ScrollManagerLike` | Scroll restoration contract. | +| `ViewTransitionManagerLike` | View transition orchestration contract. | +| `RouterAccessibilityDocumentLike` | Minimal document shape for accessibility helpers. | + +#### Utility and schema types + +| Type | Meaning | +|---|---| +| `StandardSchemaLike` | Minimal standard-schema contract for typed search params. | +| `InferSearch` | Search output inferred from a search schema. | +| `ParamNames` / `RouteParams` | Param extraction from path strings. | +| `BivariantCallback` | Internal helper for callback variance. | +| `UnionToIntersection` | Internal helper used to build typed overload-like helpers. | +| `StripParamModifier` | Internal helper for path param parsing. | +| `HasParams` | Whether a route requires params. | +| `BuildPathFn` / `NavigateFn` / `PrefetchFn` | Typed function shapes used on `Router`. | + +### What is actually exported from `@canonical/router-core` + +Runtime exports: + +- `applyMiddleware` +- `createBrowserAdapter` +- `createMemoryAdapter` +- `createRouter` +- `createRouterStore` +- `createServerAdapter` +- `createSubject` +- `createTrackedLocation` +- `FocusManager` +- `group` +- `Redirect` +- `redirect` +- `route` +- `RouteAnnouncer` +- `ScrollManager` +- `StatusResponse` +- `ViewTransitionManager` +- `wrapper` + +In addition, all public types are re-exported from `types.ts`. + +## `@canonical/router-react` + +`@canonical/router-react` supplies React context, subscriptions, links, outlets, and SSR helpers on top of the core router. + +### Components and helpers + +#### `RouterProvider` + +Places a router into React context. + +```tsx +{children} +``` + +Props are defined by `RouterProviderProps`: + +| Prop | Meaning | +|---|---| +| `router` | The typed router instance. | +| `children` | Descendant React tree. | + +Exact shape: + +```ts +interface RouterProviderProps { + readonly children?: ReactNode; + readonly router: Router; +} +``` + +#### `Outlet` + +Subscribes to the router and renders the active route tree through Suspense. + +```tsx +Loading…

} /> +``` + +Props are defined by `OutletProps`: + +| Prop | Meaning | +|---|---| +| `fallback` | Optional Suspense fallback while content resolves. | + +Exact shape: + +```ts +interface OutletProps { + readonly fallback?: ReactNode; +} +``` + +Runtime context: + +- `Outlet` subscribes with `useSyncExternalStore()` +- it calls `router.render()` to obtain the active rendered tree +- it wraps that tree in `Suspense` + +#### `Link` + +Typed anchor component. + +Behavior: + +- computes `href` from route name plus params/search/hash +- intercepts ordinary left-click navigation +- preserves modifier-key and `_blank` behavior +- triggers `prefetch()` on mouse enter + +Core props: + +| Prop | Meaning | +|---|---| +| `to` | Route name. | +| `params` | Route params when required. | +| `search` | Query object for the target route. | +| `hash` | Optional fragment. | +| `children` | Anchor contents. | +| native anchor props | Passed through except `href`, which is computed. | + +Typed prop shapes are exposed as `LinkBuildOptions` and `LinkProps`. + +Exact shapes: + +```ts +type LinkBuildOptions = { + readonly hash?: string; + readonly search?: SearchOf; +} & (HasParams extends true + ? { readonly params: ParamsOf } + : { readonly params?: ParamsOf }); + +type LinkProps = Omit< + AnchorHTMLAttributes, + "href" +> & + LinkBuildOptions> & { + readonly children?: ReactNode; + readonly onClick?: MouseEventHandler; + readonly onMouseEnter?: MouseEventHandler; + readonly ref?: Ref; + readonly to: TName; + }; +``` + +Runtime context: + +- `href` is always derived from the router +- plain left click becomes client-side navigation +- modified click, non-left click, and `_blank` behave like a normal anchor +- hover triggers `router.prefetch()` for the computed target + +#### `createHydratedRouter(routes, options?)` + +Creates a browser-backed router and reads initial state from `window.__INITIAL_DATA__`. + +Behavior: + +- builds a browser adapter automatically +- reads the SSR payload from the hydration window +- forwards all non-adapter `RouterOptions` + +Use this for the normal client-side half of SSR. + +Exact option shape: + +```ts +interface HydrationWindow { + readonly [key: string]: unknown; +} + +interface CreateHydratedRouterOptions + extends Omit, "adapter"> { + readonly browserWindow?: HydrationWindow; +} +``` + +Runtime context: + +- reads initial state from the `@canonical/react-ssr` initial data key +- constructs the browser adapter internally +- forwards the rest of the router options to core `createRouter()` + +#### `renderToStream(router, url, options?)` + +Loads a URL, renders the matched route tree, and returns everything needed for SSR. + +Return shape: + +| Field | Meaning | +|---|---| +| `stream` | Readable stream from React server rendering. | +| `loadResult` | Router load result for the request. | +| `initialData` | Dehydrated router payload or `null`. | +| `bootstrapScriptContent` | Inline script that assigns the hydration payload to the SSR window key. | + +Exact shapes: + +```ts +interface RenderToStreamOptions { + readonly fallback?: ReactNode; +} + +interface RenderToStreamResult { + readonly bootstrapScriptContent: string | null; + readonly initialData: RouterDehydratedState | null; + readonly loadResult: RouterLoadResult; + readonly stream: ReadableStream; +} +``` + +Runtime context: + +- calls `router.load(url)` first +- then calls `router.dehydrate()` +- then renders the matched route tree inside `RouterProvider` and `Outlet` +- returns the stream and the payload you need to hydrate on the client + +### Hooks + +#### `useRouter()` + +Returns the router from context. + +Throws if no `RouterProvider` is present. + +Signature: + +```ts +function useRouter(): Router +``` + +#### `useNavigationState()` + +Subscribes only to router navigation state and returns `"idle" | "loading"`. + +Use it for global progress indicators and button disabling. + +Signature: + +```ts +function useNavigationState(): "idle" | "loading" +``` + +#### `useRouterState()` + +Power-user hook for subscribing to `router.getState()`. + +It supports two modes: + +- no selector: return the full `RouterState` +- selector: return only the selected slice + +If your selector returns structured objects, pass `isEqual` to preserve the +previous selection when the new value is semantically unchanged. + +Signatures: + +```ts +function useRouterState(): RouterState + +function useRouterState( + selector: (state: RouterState) => TSelected, + options?: { + isEqual?: (previous: TSelected, next: TSelected) => boolean; + }, +): TSelected +``` + +#### `useRoute()` + +Returns a tracked location proxy backed by the current router state. + +This hook tracks which location properties your component reads and only re-renders when one of those properties changes. + +Typical accessed fields include: + +- `pathname` +- `url` +- `searchParams` +- `hash` + +Signature: + +```ts +function useRoute(): TrackedLocation +``` + +Important context: + +- despite the name, this hook does **not** return the current route match object +- it returns tracked location state +- if you need the current match, read `useRouter().getState().match` + +#### `useSearchParam(key)` + +Subscribes only to one query-string key and returns its current string value or `null`. + +Use this when a component only cares about a single query param and should not re-render for unrelated location changes. + +Signature: + +```ts +function useSearchParam(key: string): string | null +``` + +#### `useSearchParams()` + +Subscribes to all search params or to a fixed subset of keys. + +Use it in one of two ways: + +- no arguments: return `URLSearchParams` and re-render for any query-string change +- keyed selection: return an object of selected key/value pairs and re-render + only when one of those keys changes + +Signatures: + +```ts +function useSearchParams(): URLSearchParams + +function useSearchParams( + keys: TKeys, +): Readonly<{ [K in TKeys[number]]: string | null }> +``` + +### React package types + +| Type | Meaning | +|---|---| +| `AnyReactRouter` | Broad router type used inside context plumbing. | +| `RouterProviderProps` | Props for `RouterProvider`. | +| `LinkBuildOptions` | Typed `params` / `search` / `hash` inputs for `Link`. | +| `LinkProps` | Full typed prop bag for `Link`. | +| `OutletProps` | Props for `Outlet`. | +| `RenderToStreamOptions` | Options for `renderToStream()`, currently `fallback`. | +| `RenderToStreamResult` | SSR result contract returned by `renderToStream()`. | +| `HydrationWindow` | Minimal window-like object used for hydration payload lookup. | +| `CreateHydratedRouterOptions` | `RouterOptions` plus optional `browserWindow`. | +| `CreateHydratedRouterWindow` | Alias of `HydrationWindow`. | +| `HydratedNavigationState` | Alias of core `RouterNavigationState`. | +| `SearchParamValues` | Mapped values returned by keyed `useSearchParams()`. | +| `UseRouterStateOptions` | Equality options for `useRouterState()`. | + +### What is actually exported from `@canonical/router-react` + +Runtime exports: + +- `createHydratedRouter` +- `Link` +- `Outlet` +- `RouterProvider` +- `renderToStream` +- `useNavigationState` +- `useRoute` +- `useRouter` +- `useRouterState` +- `useSearchParam` +- `useSearchParams` + +In addition, all public React-facing types are re-exported from `types.ts`. + +## Quick decision guide + +If you are unsure where to look: + +| Need | API | +|---|---| +| Define one route | `route()` | +| Share a shell or boundary | `wrapper()` + `group()` | +| Add cross-cutting route policy | `applyMiddleware()` | +| Redirect during loading | `redirect()` | +| Build the router | `createRouter()` | +| Run in the browser | `createBrowserAdapter()` or `createHydratedRouter()` | +| Run in tests | `createMemoryAdapter()` | +| Run for one server request | `createServerAdapter()` or `renderToStream()` | +| Render the active route in React | `RouterProvider` + `Outlet` | +| Create typed links | `Link` | +| Read router instance in React | `useRouter()` | +| Read loading state in React | `useNavigationState()` | +| Read one search param in React | `useSearchParam()` |