From 457d98d267cd6852773a38f03699f2852b9bf1b0 Mon Sep 17 00:00:00 2001 From: Peter Hurst Date: Sat, 15 Aug 2026 03:57:59 +0100 Subject: [PATCH 01/10] refactor(atoms): split routeAtom.ts into one file per primitive atom Splits the routeAtom.ts grab-bag into locationAtom.ts, routeAtom.ts, rootAtom.ts, staticRouteAtom.ts, paramRouteAtom.ts and transformRouteAtom.ts, with shared types moved to types.ts. Public API from the package root is unchanged. Also untracks src/notAtom.d.ts, a stale generated declaration file checked in before packages/*/src/**/*.d.ts was added to .gitignore; its presence broke the dts build once RouteAtom moved out of routeAtom.ts. --- .../jarl-atoms/src/__tests__/notAtom.test.ts | 3 +- .../src/__tests__/queryAtom.test.ts | 3 +- .../src/__tests__/redirectAtom.test.ts | 3 +- .../src/__tests__/resolvedAtom.test.ts | 3 +- .../src/__tests__/routeAtom.test.ts | 18 +- packages/jarl-atoms/src/index.ts | 12 +- packages/jarl-atoms/src/locationAtom.ts | 71 +++++ packages/jarl-atoms/src/notAtom.d.ts | 10 - packages/jarl-atoms/src/notAtom.ts | 2 +- packages/jarl-atoms/src/paramRouteAtom.ts | 22 ++ packages/jarl-atoms/src/queryAtom.ts | 4 +- packages/jarl-atoms/src/redirectAtom.ts | 4 +- packages/jarl-atoms/src/resolvedAtom.ts | 3 +- packages/jarl-atoms/src/rootAtom.ts | 70 +++++ packages/jarl-atoms/src/routeAtom.ts | 281 +----------------- packages/jarl-atoms/src/staticRouteAtom.ts | 14 + packages/jarl-atoms/src/transformRouteAtom.ts | 37 +++ packages/jarl-atoms/src/types.ts | 66 ++++ 18 files changed, 318 insertions(+), 308 deletions(-) create mode 100644 packages/jarl-atoms/src/locationAtom.ts delete mode 100644 packages/jarl-atoms/src/notAtom.d.ts create mode 100644 packages/jarl-atoms/src/paramRouteAtom.ts create mode 100644 packages/jarl-atoms/src/rootAtom.ts create mode 100644 packages/jarl-atoms/src/staticRouteAtom.ts create mode 100644 packages/jarl-atoms/src/transformRouteAtom.ts create mode 100644 packages/jarl-atoms/src/types.ts diff --git a/packages/jarl-atoms/src/__tests__/notAtom.test.ts b/packages/jarl-atoms/src/__tests__/notAtom.test.ts index c0a2421a..fd2c81eb 100644 --- a/packages/jarl-atoms/src/__tests__/notAtom.test.ts +++ b/packages/jarl-atoms/src/__tests__/notAtom.test.ts @@ -1,7 +1,8 @@ import { createStore } from "jotai/vanilla"; import { describe, expect, it } from "vitest"; -import { locationAtom, staticRouteAtom } from "../routeAtom"; +import { locationAtom } from "../locationAtom"; import { notAtom } from "../notAtom"; +import { staticRouteAtom } from "../staticRouteAtom"; const seed = (store: ReturnType, pathname: string) => { store.set(locationAtom, { pathname, searchParams: new URLSearchParams() }); diff --git a/packages/jarl-atoms/src/__tests__/queryAtom.test.ts b/packages/jarl-atoms/src/__tests__/queryAtom.test.ts index 6624f594..51084a35 100644 --- a/packages/jarl-atoms/src/__tests__/queryAtom.test.ts +++ b/packages/jarl-atoms/src/__tests__/queryAtom.test.ts @@ -1,7 +1,8 @@ import { createStore } from "jotai/vanilla"; import { beforeEach, describe, expect, it } from "vitest"; -import { locationAtom, staticRouteAtom } from "../routeAtom"; +import { locationAtom } from "../locationAtom"; import { parseQuery, queryAtom, queryParamAtom, stringifyQuery } from "../queryAtom"; +import { staticRouteAtom } from "../staticRouteAtom"; const seed = (store: ReturnType, pathname: string, search = "") => { store.set(locationAtom, { pathname, searchParams: new URLSearchParams(search) }); diff --git a/packages/jarl-atoms/src/__tests__/redirectAtom.test.ts b/packages/jarl-atoms/src/__tests__/redirectAtom.test.ts index f4928ec7..9e8ef2f5 100644 --- a/packages/jarl-atoms/src/__tests__/redirectAtom.test.ts +++ b/packages/jarl-atoms/src/__tests__/redirectAtom.test.ts @@ -1,8 +1,9 @@ import { createStore } from "jotai/vanilla"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { locationAtom, staticRouteAtom } from "../routeAtom"; +import { locationAtom } from "../locationAtom"; import { queryParamAtom } from "../queryAtom"; import { Redirect, followRedirects, isRedirect, redirect, redirectAtom } from "../redirectAtom"; +import { staticRouteAtom } from "../staticRouteAtom"; const seed = (store: ReturnType, pathname: string, search = "") => { store.set(locationAtom, { pathname, searchParams: new URLSearchParams(search) }); diff --git a/packages/jarl-atoms/src/__tests__/resolvedAtom.test.ts b/packages/jarl-atoms/src/__tests__/resolvedAtom.test.ts index a9fcbcc9..7466ca5b 100644 --- a/packages/jarl-atoms/src/__tests__/resolvedAtom.test.ts +++ b/packages/jarl-atoms/src/__tests__/resolvedAtom.test.ts @@ -1,8 +1,9 @@ import { atom, createStore } from "jotai/vanilla"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { locationAtom, staticRouteAtom } from "../routeAtom"; +import { locationAtom } from "../locationAtom"; import { isRedirect, redirect } from "../redirectAtom"; import { followResolvedRedirects, resolvedAtom } from "../resolvedAtom"; +import { staticRouteAtom } from "../staticRouteAtom"; const seed = (store: ReturnType, pathname: string, search = "") => { store.set(locationAtom, { pathname, searchParams: new URLSearchParams(search) }); diff --git a/packages/jarl-atoms/src/__tests__/routeAtom.test.ts b/packages/jarl-atoms/src/__tests__/routeAtom.test.ts index 3f379b55..cc85b103 100644 --- a/packages/jarl-atoms/src/__tests__/routeAtom.test.ts +++ b/packages/jarl-atoms/src/__tests__/routeAtom.test.ts @@ -1,16 +1,12 @@ import { createStore } from "jotai/vanilla"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { - DefaultParams, - RouteReturn, - createRootAtom, - locationAtom, - paramRouteAtom, - rootAtom, - routeAtom, - staticRouteAtom, - transformRouteAtom, -} from "../routeAtom"; +import { locationAtom } from "../locationAtom"; +import { paramRouteAtom } from "../paramRouteAtom"; +import { createRootAtom, rootAtom } from "../rootAtom"; +import { routeAtom } from "../routeAtom"; +import { staticRouteAtom } from "../staticRouteAtom"; +import { transformRouteAtom } from "../transformRouteAtom"; +import { DefaultParams, RouteReturn } from "../types"; // RouteReturn is a discriminated union on `match` — asserting it here lets // the rest of a test access `.rest`/`.values` on the matched branch without diff --git a/packages/jarl-atoms/src/index.ts b/packages/jarl-atoms/src/index.ts index f6deae01..d3799aad 100644 --- a/packages/jarl-atoms/src/index.ts +++ b/packages/jarl-atoms/src/index.ts @@ -1,9 +1,15 @@ // jarl-atoms: the framework-agnostic half of JARL. Everything here is plain -// jotai atoms with no React dependency (note routeAtom.ts imports from -// "jotai/vanilla" specifically, not the "jotai" root entry, which would pull -// in jotai/react). The React components and hooks that consume these atoms +// jotai atoms with no React dependency, importing from "jotai/vanilla" +// specifically rather than the "jotai" root entry, which would pull in +// jotai/react. The React components and hooks that consume these atoms // live in the sibling `jarl-react` package. +export * from "./types"; +export * from "./locationAtom"; export * from "./routeAtom"; +export * from "./rootAtom"; +export * from "./staticRouteAtom"; +export * from "./paramRouteAtom"; +export * from "./transformRouteAtom"; export * from "./notAtom"; export * from "./href"; export * from "./queryAtom"; diff --git a/packages/jarl-atoms/src/locationAtom.ts b/packages/jarl-atoms/src/locationAtom.ts new file mode 100644 index 00000000..66bd9137 --- /dev/null +++ b/packages/jarl-atoms/src/locationAtom.ts @@ -0,0 +1,71 @@ +import { SetStateAction, WritableAtom, atom } from "jotai/vanilla"; +import { atomWithLocation } from "jotai-location"; + +// Declared rather than imported: jotai-location exports its structurally identical `Location` +// only from jotai-location/dist/atomWithLocation, so locationAtom's inferred type can't be +// named when emitting declarations (TS2883). +/** The location every route atom reads: pathname, query params and hash. */ +export type JarlLocation = { + pathname?: string; + searchParams?: URLSearchParams; + hash?: string; +}; + +const isBrowser = typeof window !== "undefined"; + +/** + * jotai-location's history-bound location atom. Constructing and *reading* this + * is safe under Node (it falls back to an empty location when there's no + * `window`); only writing is not, since the write path calls + * `history.pushState`/`replaceState` directly. + */ +const historyLocationAtom = atomWithLocation(); + +/** + * Server-side location override. Stays `null` in the browser, where + * `historyLocationAtom` is the single source of truth. + */ +const serverLocationAtom = atom(null); + +/** + * The location every route atom reads from, and the seam where SSR/SSG is made + * possible. + * + * In a browser this is exactly `atomWithLocation()`: reads and writes go + * straight through to jotai-location, so navigation still drives real + * `history.pushState`/`replaceState` and responds to popstate. + * + * Under Node there is no `window` to push history onto, so writes are captured + * in plain jotai state instead and reads prefer that captured value. That makes + * a route seedable per-render on the server: + * + * ```ts + * const store = createStore(); + * store.set(locationAtom, { pathname: "/docs", searchParams: new URLSearchParams() }); + * renderToString(); + * ``` + * + * Each store keeps its own override, so prerendering many routes in one process + * can't leak location between them. + */ +export const locationAtom: WritableAtom, { replace?: boolean }?], void> = + atom( + (get) => { + if (!isBrowser) { + const override = get(serverLocationAtom); + if (override) return override; + } + return get(historyLocationAtom); + }, + (get, set, update: SetStateAction, options?: { replace?: boolean }) => { + if (isBrowser) { + set(historyLocationAtom, update, options); + return; + } + const current = get(serverLocationAtom) ?? get(historyLocationAtom); + set( + serverLocationAtom, + typeof update === "function" ? (update as (prev: JarlLocation) => JarlLocation)(current) : update, + ); + }, + ); diff --git a/packages/jarl-atoms/src/notAtom.d.ts b/packages/jarl-atoms/src/notAtom.d.ts deleted file mode 100644 index 0c741313..00000000 --- a/packages/jarl-atoms/src/notAtom.d.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { Atom } from "jotai/vanilla"; -import { RouteAtom } from "./routeAtom"; -/** - * Matches when none of the given route atoms are an exact match - the - * inverse of a router's full route list, for a catch-all/not-found case. - * Checks `exact` rather than `match`: an ancestor route (or `rootAtom` - * itself) can be `match: true` without being the leaf that actually - * rendered, and only the leaf's exactness should count. - */ -export declare const notAtom: (...routes: RouteAtom[]) => Atom; diff --git a/packages/jarl-atoms/src/notAtom.ts b/packages/jarl-atoms/src/notAtom.ts index 7c1bfc2f..786ffbbf 100644 --- a/packages/jarl-atoms/src/notAtom.ts +++ b/packages/jarl-atoms/src/notAtom.ts @@ -1,5 +1,5 @@ import { Atom, atom } from "jotai/vanilla"; -import { RouteAtom } from "./routeAtom"; +import { RouteAtom } from "./types"; /** * Matches when none of the given route atoms are an exact match - the diff --git a/packages/jarl-atoms/src/paramRouteAtom.ts b/packages/jarl-atoms/src/paramRouteAtom.ts new file mode 100644 index 00000000..e2bd46a5 --- /dev/null +++ b/packages/jarl-atoms/src/paramRouteAtom.ts @@ -0,0 +1,22 @@ +import { routeAtom } from "./routeAtom"; +import { DefaultParams, RouteOptions } from "./types"; + +/** + * Binds one dynamic path segment to a named value: `paramRouteAtom("productId", { parent: + * products })` matches `/products/:productId` and yields `{ productId: "123" }`. + */ +export const paramRouteAtom = ( + name: T, + options?: RouteOptions, +) => { + return routeAtom( + // Only match when there is actually a segment here to bind the param to. + // Returning a value unconditionally would make a param route match its + // parent's own path (e.g. `paramRouteAtom("docName", { parent: docs })` + // matching "/docs" itself, exactly, with `docName: undefined`), so a + // section index and its param child would both render. + (path) => (path ? ({ [name]: path } as { [key in T]: string }) : undefined), + (values) => values[name], + options, + ); +}; diff --git a/packages/jarl-atoms/src/queryAtom.ts b/packages/jarl-atoms/src/queryAtom.ts index ef704910..6d7ec856 100644 --- a/packages/jarl-atoms/src/queryAtom.ts +++ b/packages/jarl-atoms/src/queryAtom.ts @@ -14,8 +14,10 @@ // regression, just not a superset either. import { Getter, atom } from "jotai/vanilla"; -import { DefaultParams, NavOptions, RouteAtom, RouteOptions, locationAtom, rootAtom } from "./routeAtom"; import { appendQueryParam, splitHref } from "./href"; +import { locationAtom } from "./locationAtom"; +import { rootAtom } from "./rootAtom"; +import { DefaultParams, NavOptions, RouteAtom, RouteOptions } from "./types"; /** Parses a `URLSearchParams` (or query string) into a plain object. Repeated * keys become string arrays, matching the common (non-`qs`) convention. */ diff --git a/packages/jarl-atoms/src/redirectAtom.ts b/packages/jarl-atoms/src/redirectAtom.ts index 149d8b72..f5f6a110 100644 --- a/packages/jarl-atoms/src/redirectAtom.ts +++ b/packages/jarl-atoms/src/redirectAtom.ts @@ -23,7 +23,9 @@ import { Getter, atom, createStore } from "jotai/vanilla"; import { Path, splitHref } from "./href"; -import { DefaultParams, NavOptions, RouteAtom, RouteOptions, locationAtom, rootAtom } from "./routeAtom"; +import { locationAtom } from "./locationAtom"; +import { rootAtom } from "./rootAtom"; +import { DefaultParams, NavOptions, RouteAtom, RouteOptions } from "./types"; /** A sentinel object meaning "actually, redirect to this instead". */ export class Redirect { diff --git a/packages/jarl-atoms/src/resolvedAtom.ts b/packages/jarl-atoms/src/resolvedAtom.ts index a8b2c4a7..9a0feb5d 100644 --- a/packages/jarl-atoms/src/resolvedAtom.ts +++ b/packages/jarl-atoms/src/resolvedAtom.ts @@ -23,9 +23,10 @@ import { Atom, Getter, atom } from "jotai/vanilla"; import { splitHref } from "./href"; -import { DefaultParams, RouteAtom, locationAtom } from "./routeAtom"; +import { locationAtom } from "./locationAtom"; import { Redirect, isRedirect } from "./redirectAtom"; import type { Store } from "./redirectAtom"; +import { DefaultParams, RouteAtom } from "./types"; /** Loads the data a matched route needs. Returning a `Redirect` sends the app elsewhere instead. */ export type Resolver = (values: T, get: Getter) => Promise; diff --git a/packages/jarl-atoms/src/rootAtom.ts b/packages/jarl-atoms/src/rootAtom.ts new file mode 100644 index 00000000..fe95a13e --- /dev/null +++ b/packages/jarl-atoms/src/rootAtom.ts @@ -0,0 +1,70 @@ +import { atom } from "jotai/vanilla"; +import { Path, normalizePathname } from "./href"; +import { locationAtom } from "./locationAtom"; +import { DefaultParams, RouteAtom } from "./types"; + +/** Options for `createRootAtom`. */ +export type RootOptions = { + /** + * Scopes the router to a subtree of the URL: the prefix is stripped from the pathname before + * matching begins, and prepended again by `reverse`/write. A location outside `basePath` makes + * the whole tree report `match: false`. + */ + basePath?: Path; +}; + +const stripBasePath = (pathname: string, basePath: string): string | undefined => { + if (!basePath) return pathname; + if (pathname === basePath) return "/"; + if (pathname.indexOf(`${basePath}/`) === 0) { + return pathname.slice(basePath.length) || "/"; + } + return undefined; +}; + +/** + * Creates a root RouteAtom. Call this instead of using the default `rootAtom` export when the + * app needs to be scoped under a basePath. + */ +export const createRootAtom = (options?: RootOptions): RouteAtom => { + const basePath = options?.basePath ? normalizePathname(options.basePath) : ""; + return atom( + (get) => { + const location = get(locationAtom); + const path = location.pathname || "/"; + const withinBase = stripBasePath(path, basePath); + if (withinBase === undefined) { + // Outside of this router's basePath entirely: nothing matches. + return { + match: false, + exact: false, + values: undefined, + reverse: () => basePath || "/", + }; + } + const segments = withinBase === "/" ? [""] : withinBase.split("/"); + // Handle trailing slash + if (segments.length > 1 && segments[segments.length - 1] === "") { + segments.pop(); + } + return { + // root always matches (as long as we're within basePath) + match: true, + exact: segments.length === 1, + rest: { path: segments.slice(1) }, + reverse: () => basePath || "/", + values: {}, + }; + }, + (get, set, action, navOptions) => { + set( + locationAtom, + (prev) => ({ ...prev, pathname: basePath || "/", searchParams: new URLSearchParams() }), + navOptions, + ); + }, + ); +}; + +/** The default root of every route atom chain: matches `/`, and is the implicit `parent`. */ +export const rootAtom = createRootAtom(); diff --git a/packages/jarl-atoms/src/routeAtom.ts b/packages/jarl-atoms/src/routeAtom.ts index a3015daa..d4555b19 100644 --- a/packages/jarl-atoms/src/routeAtom.ts +++ b/packages/jarl-atoms/src/routeAtom.ts @@ -1,153 +1,15 @@ // Heavily borrowed from Wouter -// Import from "jotai/vanilla" rather than the "jotai" root entry point: the -// root entry re-exports "jotai/react" too, which pulls in a React peer -// dependency this package intentionally doesn't have (React bindings are a -// separate concern — see ticket 55). "jotai/vanilla" has everything atoms -// need: atom(), Getter, WritableAtom. -import { Getter, SetStateAction, WritableAtom, atom } from "jotai/vanilla"; -import { atomWithLocation } from "jotai-location"; -import { normalizePathname, splitHref, Path } from "./href"; - -export type { Path }; - -/** The param values a route binds. Empty for routes that bind none, such as a static segment. */ -export type DefaultParams = {}; - -/** - * Extra argument when writing to a RouteAtom, e.g. `set(routeAtom, values, { replace: true })`. - * `replace` navigates with `history.replaceState` rather than `history.pushState`. - */ -export type NavOptions = { replace?: boolean }; - -/** The param name a single pattern segment binds, honouring its `?`, `*` and `+` suffixes. */ -export type ExtractRouteOptionalParam = PathType extends `${infer Param}?` - ? { readonly [k in Param]: string | undefined } - : PathType extends `${infer Param}*` - ? { readonly [k in Param]: string | undefined } - : PathType extends `${infer Param}+` - ? { readonly [k in Param]: string } - : { readonly [k in PathType]: string }; - -/** The full param object a `:name`-style path pattern binds. */ -export type ExtractRouteParams = string extends PathType - ? DefaultParams - : PathType extends `${infer _Start}:${infer ParamWithOptionalRegExp}/${infer Rest}` - ? ParamWithOptionalRegExp extends `${infer Param}(${infer _RegExp})` - ? ExtractRouteOptionalParam & ExtractRouteParams - : ExtractRouteOptionalParam & ExtractRouteParams - : PathType extends `${infer _Start}:${infer ParamWithOptionalRegExp}` - ? ParamWithOptionalRegExp extends `${infer Param}(${infer _RegExp})` - ? ExtractRouteOptionalParam - : ExtractRouteOptionalParam - : {}; - -// Declared rather than imported: jotai-location exports its structurally identical `Location` -// only from jotai-location/dist/atomWithLocation, so locationAtom's inferred type can't be -// named when emitting declarations (TS2883). -/** The location every route atom reads: pathname, query params and hash. */ -export type JarlLocation = { - pathname?: string; - searchParams?: URLSearchParams; - hash?: string; -}; - -const isBrowser = typeof window !== "undefined"; - -/** - * jotai-location's history-bound location atom. Constructing and *reading* this - * is safe under Node (it falls back to an empty location when there's no - * `window`); only writing is not, since the write path calls - * `history.pushState`/`replaceState` directly. - */ -const historyLocationAtom = atomWithLocation(); - -/** - * Server-side location override. Stays `null` in the browser, where - * `historyLocationAtom` is the single source of truth. - */ -const serverLocationAtom = atom(null); - -/** - * The location every route atom reads from, and the seam where SSR/SSG is made - * possible. - * - * In a browser this is exactly `atomWithLocation()`: reads and writes go - * straight through to jotai-location, so navigation still drives real - * `history.pushState`/`replaceState` and responds to popstate. - * - * Under Node there is no `window` to push history onto, so writes are captured - * in plain jotai state instead and reads prefer that captured value. That makes - * a route seedable per-render on the server: - * - * ```ts - * const store = createStore(); - * store.set(locationAtom, { pathname: "/docs", searchParams: new URLSearchParams() }); - * renderToString(); - * ``` - * - * Each store keeps its own override, so prerendering many routes in one process - * can't leak location between them. - */ -export const locationAtom: WritableAtom, { replace?: boolean }?], void> = - atom( - (get) => { - if (!isBrowser) { - const override = get(serverLocationAtom); - if (override) return override; - } - return get(historyLocationAtom); - }, - (get, set, update: SetStateAction, options?: { replace?: boolean }) => { - if (isBrowser) { - set(historyLocationAtom, update, options); - return; - } - const current = get(serverLocationAtom) ?? get(historyLocationAtom); - set( - serverLocationAtom, - typeof update === "function" ? (update as (prev: JarlLocation) => JarlLocation)(current) : update, - ); - }, - ); - -/** - * What reading any route atom gives you. `match`/`exact` say whether and how completely it - * matches the current location, `values` holds the params it and its ancestors bound, `rest` - * the path segments left for its children, and `reverse` turns param values back into a URL. - */ -export type RouteReturn = { - reverse: (values: T) => string; -} & ( - | { - match: true; - values: T; - exact: boolean; - rest: { path: string[] }; - } - | { - match: false; - exact: false; - values: undefined; - } -); - -// jotai's WritableAtom takes its write-side arguments as a tuple (Args) plus -// a Result type, rather than the single-Update-type shape older jotai -// versions used — hence `[T]` (a single-argument tuple) and `void` here. -/** A route: read it for its `RouteReturn` match state, write param values to it to navigate. */ -export type RouteAtom = WritableAtom, [T, NavOptions?], void>; +import { Getter, atom } from "jotai/vanilla"; +import { splitHref } from "./href"; +import { locationAtom } from "./locationAtom"; +import { rootAtom } from "./rootAtom"; +import { DefaultParams, RouteAtom, RouteOptions } from "./types"; // Earlier design sketches (a tuple-shaped RouteReturn, a pattern-string-driven // routeAtom overload, and the type plumbing they'd need) were explored here // and are preserved with context in ../DESIGN-NOTES.md rather than dropped. -/** Common options for every route atom constructor. */ -export type RouteOptions = { - /** Route this one nests under, matching the segment after its parent's. Defaults to `rootAtom`. */ - parent?: RouteAtom; -}; - /** * The primitive every other route atom is built from. `matchPath` decides whether the next * unconsumed path segment matches, and to what param values; `makePath` is its inverse, used by @@ -199,136 +61,3 @@ export const routeAtom = { - if (!basePath) return pathname; - if (pathname === basePath) return "/"; - if (pathname.indexOf(`${basePath}/`) === 0) { - return pathname.slice(basePath.length) || "/"; - } - return undefined; -}; - -/** - * Creates a root RouteAtom. Call this instead of using the default `rootAtom` export when the - * app needs to be scoped under a basePath. - */ -export const createRootAtom = (options?: RootOptions): RouteAtom => { - const basePath = options?.basePath ? normalizePathname(options.basePath) : ""; - return atom( - (get) => { - const location = get(locationAtom); - const path = location.pathname || "/"; - const withinBase = stripBasePath(path, basePath); - if (withinBase === undefined) { - // Outside of this router's basePath entirely: nothing matches. - return { - match: false, - exact: false, - values: undefined, - reverse: () => basePath || "/", - }; - } - const segments = withinBase === "/" ? [""] : withinBase.split("/"); - // Handle trailing slash - if (segments.length > 1 && segments[segments.length - 1] === "") { - segments.pop(); - } - return { - // root always matches (as long as we're within basePath) - match: true, - exact: segments.length === 1, - rest: { path: segments.slice(1) }, - reverse: () => basePath || "/", - values: {}, - }; - }, - (get, set, action, navOptions) => { - set( - locationAtom, - (prev) => ({ ...prev, pathname: basePath || "/", searchParams: new URLSearchParams() }), - navOptions, - ); - }, - ); -}; - -/** The default root of every route atom chain: matches `/`, and is the implicit `parent`. */ -export const rootAtom = createRootAtom(); - -/** Matches one fixed path segment: `staticRouteAtom("about")` matches `/about`. */ -export const staticRouteAtom = ( - name: string, - options?: RouteOptions, -): RouteAtom => { - return routeAtom( - (path) => (name === path ? {} : undefined), - () => name, - options, - ); -}; - -/** - * Binds one dynamic path segment to a named value: `paramRouteAtom("productId", { parent: - * products })` matches `/products/:productId` and yields `{ productId: "123" }`. - */ -export const paramRouteAtom = ( - name: T, - options?: RouteOptions, -) => { - return routeAtom( - // Only match when there is actually a segment here to bind the param to. - // Returning a value unconditionally would make a param route match its - // parent's own path (e.g. `paramRouteAtom("docName", { parent: docs })` - // matching "/docs" itself, exactly, with `docName: undefined`), so a - // section index and its param child would both render. - (path) => (path ? ({ [name]: path } as { [key in T]: string }) : undefined), - (values) => values[name], - options, - ); -}; - -/** - * Reshapes a route's matched `values` into a different shape, and back again for - * `reverse`/write - composable middleware over a chain of route atoms. - */ -export const transformRouteAtom = ( - parentAtom: RouteAtom, - getter: (values: T, get: Getter) => Return | undefined, - setter: (values: Return, get: Getter) => T, -): RouteAtom => { - const reverse = (get: Getter) => (values: Return) => { - const transformed = setter(values, get); - const parent = get(parentAtom); - return parent.reverse(transformed); - }; - return atom( - (get) => { - const parent = get(parentAtom); - let transformed: Return | undefined; - if (!parent.match || !(transformed = getter(parent.values, get))) { - return { - match: false, - exact: false, - values: undefined, - reverse: reverse(get), - }; - } - return { ...parent, values: transformed, reverse: reverse(get) }; - }, - (get, set, action, navOptions) => { - const transformed = setter(action, get); - set(parentAtom, transformed, navOptions); - }, - ); -}; diff --git a/packages/jarl-atoms/src/staticRouteAtom.ts b/packages/jarl-atoms/src/staticRouteAtom.ts new file mode 100644 index 00000000..dffce836 --- /dev/null +++ b/packages/jarl-atoms/src/staticRouteAtom.ts @@ -0,0 +1,14 @@ +import { routeAtom } from "./routeAtom"; +import { DefaultParams, RouteAtom, RouteOptions } from "./types"; + +/** Matches one fixed path segment: `staticRouteAtom("about")` matches `/about`. */ +export const staticRouteAtom = ( + name: string, + options?: RouteOptions, +): RouteAtom => { + return routeAtom( + (path) => (name === path ? {} : undefined), + () => name, + options, + ); +}; diff --git a/packages/jarl-atoms/src/transformRouteAtom.ts b/packages/jarl-atoms/src/transformRouteAtom.ts new file mode 100644 index 00000000..b5c86364 --- /dev/null +++ b/packages/jarl-atoms/src/transformRouteAtom.ts @@ -0,0 +1,37 @@ +import { Getter, atom } from "jotai/vanilla"; +import { DefaultParams, RouteAtom } from "./types"; + +/** + * Reshapes a route's matched `values` into a different shape, and back again for + * `reverse`/write - composable middleware over a chain of route atoms. + */ +export const transformRouteAtom = ( + parentAtom: RouteAtom, + getter: (values: T, get: Getter) => Return | undefined, + setter: (values: Return, get: Getter) => T, +): RouteAtom => { + const reverse = (get: Getter) => (values: Return) => { + const transformed = setter(values, get); + const parent = get(parentAtom); + return parent.reverse(transformed); + }; + return atom( + (get) => { + const parent = get(parentAtom); + let transformed: Return | undefined; + if (!parent.match || !(transformed = getter(parent.values, get))) { + return { + match: false, + exact: false, + values: undefined, + reverse: reverse(get), + }; + } + return { ...parent, values: transformed, reverse: reverse(get) }; + }, + (get, set, action, navOptions) => { + const transformed = setter(action, get); + set(parentAtom, transformed, navOptions); + }, + ); +}; diff --git a/packages/jarl-atoms/src/types.ts b/packages/jarl-atoms/src/types.ts new file mode 100644 index 00000000..916cddf6 --- /dev/null +++ b/packages/jarl-atoms/src/types.ts @@ -0,0 +1,66 @@ +import { WritableAtom } from "jotai/vanilla"; +import { Path } from "./href"; + +/** The param values a route binds. Empty for routes that bind none, such as a static segment. */ +export type DefaultParams = {}; + +/** + * Extra argument when writing to a RouteAtom, e.g. `set(routeAtom, values, { replace: true })`. + * `replace` navigates with `history.replaceState` rather than `history.pushState`. + */ +export type NavOptions = { replace?: boolean }; + +/** The param name a single pattern segment binds, honouring its `?`, `*` and `+` suffixes. */ +export type ExtractRouteOptionalParam = PathType extends `${infer Param}?` + ? { readonly [k in Param]: string | undefined } + : PathType extends `${infer Param}*` + ? { readonly [k in Param]: string | undefined } + : PathType extends `${infer Param}+` + ? { readonly [k in Param]: string } + : { readonly [k in PathType]: string }; + +/** The full param object a `:name`-style path pattern binds. */ +export type ExtractRouteParams = string extends PathType + ? DefaultParams + : PathType extends `${infer _Start}:${infer ParamWithOptionalRegExp}/${infer Rest}` + ? ParamWithOptionalRegExp extends `${infer Param}(${infer _RegExp})` + ? ExtractRouteOptionalParam & ExtractRouteParams + : ExtractRouteOptionalParam & ExtractRouteParams + : PathType extends `${infer _Start}:${infer ParamWithOptionalRegExp}` + ? ParamWithOptionalRegExp extends `${infer Param}(${infer _RegExp})` + ? ExtractRouteOptionalParam + : ExtractRouteOptionalParam + : {}; + +/** + * What reading any route atom gives you. `match`/`exact` say whether and how completely it + * matches the current location, `values` holds the params it and its ancestors bound, `rest` + * the path segments left for its children, and `reverse` turns param values back into a URL. + */ +export type RouteReturn = { + reverse: (values: T) => string; +} & ( + | { + match: true; + values: T; + exact: boolean; + rest: { path: string[] }; + } + | { + match: false; + exact: false; + values: undefined; + } +); + +// jotai's WritableAtom takes its write-side arguments as a tuple (Args) plus +// a Result type, rather than the single-Update-type shape older jotai +// versions used — hence `[T]` (a single-argument tuple) and `void` here. +/** A route: read it for its `RouteReturn` match state, write param values to it to navigate. */ +export type RouteAtom = WritableAtom, [T, NavOptions?], void>; + +/** Common options for every route atom constructor. */ +export type RouteOptions = { + /** Route this one nests under, matching the segment after its parent's. Defaults to `rootAtom`. */ + parent?: RouteAtom; +}; From dcc68abc812c7ba8d3c0ad3172148c2c399cf5f0 Mon Sep 17 00:00:00 2001 From: Peter Hurst Date: Sat, 15 Aug 2026 04:02:55 +0100 Subject: [PATCH 02/10] refactor(atoms): restore Path type export to maintain public API The original routeAtom.ts exported Path; it should be re-exported from routeAtom.ts after splitting to maintain the public API unchanged. Co-Authored-By: Claude Fable 5 --- packages/jarl-atoms/src/routeAtom.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/jarl-atoms/src/routeAtom.ts b/packages/jarl-atoms/src/routeAtom.ts index d4555b19..fd91816a 100644 --- a/packages/jarl-atoms/src/routeAtom.ts +++ b/packages/jarl-atoms/src/routeAtom.ts @@ -1,11 +1,13 @@ // Heavily borrowed from Wouter import { Getter, atom } from "jotai/vanilla"; -import { splitHref } from "./href"; +import { splitHref, Path } from "./href"; import { locationAtom } from "./locationAtom"; import { rootAtom } from "./rootAtom"; import { DefaultParams, RouteAtom, RouteOptions } from "./types"; +export type { Path }; + // Earlier design sketches (a tuple-shaped RouteReturn, a pattern-string-driven // routeAtom overload, and the type plumbing they'd need) were explored here // and are preserved with context in ../DESIGN-NOTES.md rather than dropped. From e9ccb1a62cee9e4203afd20370ea02ebf1a948f7 Mon Sep 17 00:00:00 2001 From: Peter Hurst Date: Sat, 15 Aug 2026 04:13:19 +0100 Subject: [PATCH 03/10] feat(atoms): add numericRouteAtom for numeric path segments Binds a segment to a number instead of a string, with min/max options that reject out-of-range values (no match). Composed from paramRouteAtom + transformRouteAtom, the existing idiom for constrained segments, rather than a new base primitive. --- .../src/__tests__/routeAtom.test.ts | 67 +++++++++++++++++++ packages/jarl-atoms/src/index.ts | 1 + packages/jarl-atoms/src/numericRouteAtom.ts | 39 +++++++++++ 3 files changed, 107 insertions(+) create mode 100644 packages/jarl-atoms/src/numericRouteAtom.ts diff --git a/packages/jarl-atoms/src/__tests__/routeAtom.test.ts b/packages/jarl-atoms/src/__tests__/routeAtom.test.ts index cc85b103..569cbbc1 100644 --- a/packages/jarl-atoms/src/__tests__/routeAtom.test.ts +++ b/packages/jarl-atoms/src/__tests__/routeAtom.test.ts @@ -1,6 +1,7 @@ import { createStore } from "jotai/vanilla"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { locationAtom } from "../locationAtom"; +import { numericRouteAtom } from "../numericRouteAtom"; import { paramRouteAtom } from "../paramRouteAtom"; import { createRootAtom, rootAtom } from "../rootAtom"; import { routeAtom } from "../routeAtom"; @@ -147,6 +148,72 @@ describe("paramRouteAtom", () => { }); }); +describe("numericRouteAtom", () => { + it("matches a numeric segment and converts it to a number", () => { + const store = createStore(); + const year = numericRouteAtom("year"); + seed(store, "/2024"); + + const result = store.get(year); + assertMatch(result); + expect(result.values).toEqual({ year: 2024 }); + }); + + it("does not match a non-numeric segment", () => { + const store = createStore(); + const year = numericRouteAtom("year"); + seed(store, "/soon"); + + expect(store.get(year).match).toBe(false); + }); + + it("does not match a value below min", () => { + const store = createStore(); + const month = numericRouteAtom("month", { min: 1, max: 12 }); + seed(store, "/0"); + + expect(store.get(month).match).toBe(false); + }); + + it("does not match a value above max", () => { + const store = createStore(); + const month = numericRouteAtom("month", { min: 1, max: 12 }); + seed(store, "/13"); + + expect(store.get(month).match).toBe(false); + }); + + it("matches values within an inclusive min/max range", () => { + const store = createStore(); + const month = numericRouteAtom("month", { min: 1, max: 12 }); + seed(store, "/12"); + + const result = store.get(month); + assertMatch(result); + expect(result.values).toEqual({ month: 12 }); + }); + + it("builds hrefs from a number through reverse()", () => { + const store = createStore(); + const blog = staticRouteAtom("blog"); + const year = numericRouteAtom("year", { parent: blog }); + + expect(store.get(year).reverse({ year: 2024 })).toBe("/blog/2024"); + }); + + it("composes with a parent route the same way paramRouteAtom does", () => { + const store = createStore(); + const blog = staticRouteAtom("blog"); + const year = numericRouteAtom("year", { parent: blog }); + seed(store, "/blog/2024"); + + const result = store.get(year); + assertMatch(result); + expect(result.values).toEqual({ year: 2024 }); + expect(result.exact).toBe(true); + }); +}); + describe("nested composition", () => { it("does not match children when a parent segment differs", () => { const store = createStore(); diff --git a/packages/jarl-atoms/src/index.ts b/packages/jarl-atoms/src/index.ts index d3799aad..4fe4dfcd 100644 --- a/packages/jarl-atoms/src/index.ts +++ b/packages/jarl-atoms/src/index.ts @@ -9,6 +9,7 @@ export * from "./routeAtom"; export * from "./rootAtom"; export * from "./staticRouteAtom"; export * from "./paramRouteAtom"; +export * from "./numericRouteAtom"; export * from "./transformRouteAtom"; export * from "./notAtom"; export * from "./href"; diff --git a/packages/jarl-atoms/src/numericRouteAtom.ts b/packages/jarl-atoms/src/numericRouteAtom.ts new file mode 100644 index 00000000..bf693f13 --- /dev/null +++ b/packages/jarl-atoms/src/numericRouteAtom.ts @@ -0,0 +1,39 @@ +import { paramRouteAtom } from "./paramRouteAtom"; +import { transformRouteAtom } from "./transformRouteAtom"; +import { DefaultParams, RouteAtom, RouteOptions } from "./types"; + +/** Options for `numericRouteAtom`: the common route options plus an inclusive numeric range. */ +export type NumericRouteOptions = RouteOptions & { + /** Segments below this don't match. */ + min?: number; + /** Segments above this don't match. */ + max?: number; +}; + +const NUMERIC_SEGMENT = /^\d+$/; + +/** + * Binds one dynamic path segment to a named non-negative integer: `numericRouteAtom("year", { + * parent: blog, min: 2000 })` matches `/blog/:year` and yields `{ year: 2024 }` as a number + * rather than a string. A segment that isn't all digits, or falls outside `min`/`max`, doesn't + * match at all - it's a plain `paramRouteAtom` with a `transformRouteAtom` layered on top, the + * usual way to build a constrained segment from the existing primitives. + */ +export const numericRouteAtom = ( + name: T, + options?: NumericRouteOptions, +): RouteAtom<{ [key in T]: number } & Parent> => { + const param = paramRouteAtom(name, options); + return transformRouteAtom<{ [key in T]: string } & Parent, { [key in T]: number } & Parent>( + param, + (values) => { + const raw = values[name]; + if (!NUMERIC_SEGMENT.test(raw)) return undefined; + const num = Number(raw); + if (options?.min !== undefined && num < options.min) return undefined; + if (options?.max !== undefined && num > options.max) return undefined; + return { ...values, [name]: num } as unknown as { [key in T]: number } & Parent; + }, + (values) => ({ ...values, [name]: String(values[name]) }) as unknown as { [key in T]: string } & Parent, + ); +}; From a1959ee0f76c4093b258788a948bfae539dfeaff Mon Sep 17 00:00:00 2001 From: Peter Hurst Date: Sat, 15 Aug 2026 04:13:29 +0100 Subject: [PATCH 04/10] feat(docs): add blog routing demo A classic /blog/:year/:month/:day/:slug tree over a seeded set of faker-generated mock posts, hand-composed from numericRouteAtom chained as parent/child rather than a dedicated date primitive. Each level validates its own range, plus (once matched) that the date is real and posts exist there, falling back to a not-found view - and to the Switch's own fallback when no level's URL shape matches at all. --- package-lock.json | 18 ++ packages/docs/package.json | 1 + packages/docs/src/App.tsx | 21 +++ packages/docs/src/demos/BlogRoutingApp.tsx | 193 ++++++++++++++++++++ packages/docs/src/demos/blogPosts.ts | 88 +++++++++ packages/docs/src/pages/BlogRoutingDemo.tsx | 61 +++++++ packages/docs/src/pages/DemosIndex.tsx | 9 +- packages/docs/src/router/routes.ts | 12 +- 8 files changed, 401 insertions(+), 2 deletions(-) create mode 100644 packages/docs/src/demos/BlogRoutingApp.tsx create mode 100644 packages/docs/src/demos/blogPosts.ts create mode 100644 packages/docs/src/pages/BlogRoutingDemo.tsx diff --git a/package-lock.json b/package-lock.json index 9785cf47..ce414df6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2167,6 +2167,23 @@ } } }, + "node_modules/@faker-js/faker": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/@faker-js/faker/-/faker-10.6.0.tgz", + "integrity": "sha512-3RQHgEtvL1Frl/d1cSreo7qhJ3Gk1OdNUai/CtZ8G+wYeRQnJih3s9xJ9/kgYekPQRdwgh0HXRPqMlzWGwivIQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/fakerjs" + } + ], + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.13.0 || ^23.5.0 || >=24.0.0", + "npm": ">=10" + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -12486,6 +12503,7 @@ "react-dom": "^19.2.8" }, "devDependencies": { + "@faker-js/faker": "^10.6.0", "@types/react": "^19.2.18", "@types/react-dom": "^19.2.4", "@vitejs/plugin-react": "^6.0.5", diff --git a/packages/docs/package.json b/packages/docs/package.json index a898dced..640601a8 100644 --- a/packages/docs/package.json +++ b/packages/docs/package.json @@ -25,6 +25,7 @@ "react-dom": "^19.2.8" }, "devDependencies": { + "@faker-js/faker": "^10.6.0", "@types/react": "^19.2.18", "@types/react-dom": "^19.2.4", "@vitejs/plugin-react": "^6.0.5", diff --git a/packages/docs/src/App.tsx b/packages/docs/src/App.tsx index 073b36ff..9f1f66c3 100644 --- a/packages/docs/src/App.tsx +++ b/packages/docs/src/App.tsx @@ -12,6 +12,11 @@ import { demosIndexRoute, basicRoutingDemoRoute, basicRoutingDemoPageRoute, + blogRoutingDemoRoute, + blogYearRoute, + blogMonthRoute, + blogDayRoute, + blogPostRoute, } from "./router/routes"; import Home from "./pages/Home"; import { DocsIndex, DocPage } from "./pages/Docs"; @@ -20,6 +25,7 @@ import Changelog from "./pages/Changelog"; import History from "./pages/History"; import DemosIndex from "./pages/DemosIndex"; import BasicRoutingDemo from "./pages/BasicRoutingDemo"; +import BlogRoutingDemo from "./pages/BlogRoutingDemo"; import NotFound from "./pages/NotFound"; export const App = () => ( @@ -57,6 +63,21 @@ export const App = () => ( + + + + + + + + + + + + + + + diff --git a/packages/docs/src/demos/BlogRoutingApp.tsx b/packages/docs/src/demos/BlogRoutingApp.tsx new file mode 100644 index 00000000..8bbc6fdd --- /dev/null +++ b/packages/docs/src/demos/BlogRoutingApp.tsx @@ -0,0 +1,193 @@ +import { Link, Route, Switch } from "jarl-react"; +import { blogRoutingDemoRoute, blogYearRoute, blogMonthRoute, blogDayRoute, blogPostRoute } from "../router/routes"; +import { + BlogPost, + isValidCalendarDate, + postBySlug, + postsForDay, + postsForMonth, + postsForYear, + daysInMonth, + monthsInYear, + yearsWithPosts, +} from "./blogPosts"; + +const MONTH_NAMES = [ + "January", + "February", + "March", + "April", + "May", + "June", + "July", + "August", + "September", + "October", + "November", + "December", +]; + +const formatDate = (post: BlogPost) => `${MONTH_NAMES[post.month - 1]} ${post.day}, ${post.year}`; + +const BlogNav = () => ( + +); + +const BlogNotFound = ({ reason }: { reason: string }) => ( +
+

Not found

+

{reason}

+

+ + Back to all posts + +

+
+); + +const PostList = ({ posts }: { posts: BlogPost[] }) => ( +
    + {posts.map((post) => ( +
  • + + {post.title} + {" "} + — {formatDate(post)} +
  • + ))} +
+); + +const BlogIndex = () => ( +
+

Blog

+
    + {yearsWithPosts().map((year) => ( +
  • + + {year} + {" "} + ({postsForYear(year).length} posts) +
  • + ))} +
+
+); + +const YearPage = ({ year }: { year: number }) => { + const posts = postsForYear(year); + if (posts.length === 0) { + return ; + } + return ( +
+

{year}

+
    + {monthsInYear(year).map((month) => ( +
  • + + {MONTH_NAMES[month - 1]} + {" "} + ({postsForMonth(year, month).length}) +
  • + ))} +
+ +
+ ); +}; + +const MonthPage = ({ year, month }: { year: number; month: number }) => { + const posts = postsForMonth(year, month); + if (posts.length === 0) { + return ; + } + return ( +
+

+ {MONTH_NAMES[month - 1]} {year} +

+
    + {daysInMonth(year, month).map((day) => ( +
  • + + {day} + {" "} + ({postsForDay(year, month, day).length}) +
  • + ))} +
+ +
+ ); +}; + +const DayPage = ({ year, month, day }: { year: number; month: number; day: number }) => { + if (!isValidCalendarDate(year, month, day)) { + return ; + } + const posts = postsForDay(year, month, day); + if (posts.length === 0) { + return ; + } + return ( +
+

+ {MONTH_NAMES[month - 1]} {day}, {year} +

+ +
+ ); +}; + +const PostPage = ({ year, month, day, slug }: { year: number; month: number; day: number; slug: string }) => { + const post = postBySlug(year, month, day, slug); + if (!post) { + return ; + } + return ( +
+

{post.title}

+

+ {formatDate(post)} +

+

{post.excerpt}

+
+ ); +}; + +/** + * Demo of a classic /blog/:year/:month/:day/:slug tree, composed from three + * `numericRouteAtom`s chained as parent/child rather than a single date primitive. Each level + * validates its own segment's range (via `numericRouteAtom`'s `min`/`max`) plus, once matched, + * that the date is real and posts actually exist there - falling back to `BlogNotFound` either + * way, and to the `Switch`'s own fallback when no level's URL shape matches at all. + */ +export const BlogRoutingApp = () => ( + <> + + }> + + + + + {({ year }) => } + + + {({ year, month }) => } + + + {({ year, month, day }) => } + + + {({ year, month, day, slug }) => } + + + +); + +export default BlogRoutingApp; diff --git a/packages/docs/src/demos/blogPosts.ts b/packages/docs/src/demos/blogPosts.ts new file mode 100644 index 00000000..3df9c195 --- /dev/null +++ b/packages/docs/src/demos/blogPosts.ts @@ -0,0 +1,88 @@ +import { faker } from "@faker-js/faker"; + +/** A mock blog post, dated for the classic `/blog/:year/:month/:day/:slug` URL shape. */ +export type BlogPost = { + slug: string; + title: string; + excerpt: string; + year: number; + month: number; + day: number; +}; + +const POST_COUNT = 24; + +const makePost = (): BlogPost => { + const date = faker.date.between({ from: "2022-01-01", to: "2024-12-31" }); + return { + slug: faker.helpers.slugify(faker.lorem.words({ min: 2, max: 5 })).toLowerCase(), + title: faker.lorem.sentence({ min: 3, max: 7 }).replace(/\.$/, ""), + excerpt: faker.lorem.sentences(2), + year: date.getUTCFullYear(), + month: date.getUTCMonth() + 1, + day: date.getUTCDate(), + }; +}; + +// One slug can only appear once per day (that's what the route pattern binds), so keep +// generating until every post's (date, slug) pair is unique. +const makePosts = (count: number): BlogPost[] => { + const seen = new Set(); + const posts: BlogPost[] = []; + while (posts.length < count) { + const post = makePost(); + const key = `${post.year}-${post.month}-${post.day}-${post.slug}`; + if (seen.has(key)) continue; + seen.add(key); + posts.push(post); + } + return posts; +}; + +faker.seed(404); +/** Fixed mock post data, seeded so demo content (and its SSG prerender) is stable across builds. */ +export const blogPosts: BlogPost[] = makePosts(POST_COUNT); + +/** Whether year/month/day form an actual calendar date (rejects e.g. 30 February). */ +export const isValidCalendarDate = (year: number, month: number, day: number): boolean => { + const date = new Date(Date.UTC(year, month - 1, day)); + return date.getUTCFullYear() === year && date.getUTCMonth() === month - 1 && date.getUTCDate() === day; +}; + +export const postsForYear = (year: number): BlogPost[] => blogPosts.filter((post) => post.year === year); + +export const postsForMonth = (year: number, month: number): BlogPost[] => + postsForYear(year).filter((post) => post.month === month); + +export const postsForDay = (year: number, month: number, day: number): BlogPost[] => + postsForMonth(year, month).filter((post) => post.day === day); + +export const postBySlug = (year: number, month: number, day: number, slug: string): BlogPost | undefined => + postsForDay(year, month, day).find((post) => post.slug === slug); + +const distinctSorted = (values: number[]): number[] => [...new Set(values)].sort((a, b) => a - b); + +export const yearsWithPosts = (): number[] => distinctSorted(blogPosts.map((post) => post.year)); + +export const monthsInYear = (year: number): number[] => distinctSorted(postsForYear(year).map((post) => post.month)); + +export const daysInMonth = (year: number, month: number): number[] => + distinctSorted(postsForMonth(year, month).map((post) => post.day)); + +/** Every concrete blog URL the demo can reach, for the docs site's SSG prerender list. */ +export const blogStaticPaths = (): string[] => { + const paths = ["/demos/blog-routing"]; + for (const year of yearsWithPosts()) { + paths.push(`/demos/blog-routing/${year}`); + for (const month of monthsInYear(year)) { + paths.push(`/demos/blog-routing/${year}/${month}`); + for (const day of daysInMonth(year, month)) { + paths.push(`/demos/blog-routing/${year}/${month}/${day}`); + } + } + } + for (const post of blogPosts) { + paths.push(`/demos/blog-routing/${post.year}/${post.month}/${post.day}/${post.slug}`); + } + return paths; +}; diff --git a/packages/docs/src/pages/BlogRoutingDemo.tsx b/packages/docs/src/pages/BlogRoutingDemo.tsx new file mode 100644 index 00000000..e8759bb1 --- /dev/null +++ b/packages/docs/src/pages/BlogRoutingDemo.tsx @@ -0,0 +1,61 @@ +import styled from "@emotion/styled"; +import { theme } from "../theme"; +import { BlogRoutingApp } from "../demos/BlogRoutingApp"; +import demoSource from "../demos/BlogRoutingApp.tsx?raw"; + +const DemoBox = styled.div` + border: 1px solid ${theme.border}; + border-radius: 8px; + padding: 1.5rem; + background: ${theme.bgAlt}; + margin: 1.5rem 0; + + nav { + display: flex; + gap: 1rem; + margin-bottom: 1rem; + border-bottom: 1px solid ${theme.border}; + padding-bottom: 0.75rem; + } + + nav a[data-active] { + color: ${theme.accentStrong}; + font-weight: 600; + } +`; + +const SourceDisclosure = styled.details` + summary { + cursor: pointer; + color: ${theme.fgMuted}; + margin: 1rem 0 0.5rem; + } + + pre { + background: ${theme.codeBg}; + color: ${theme.accent}; + border: 1px solid ${theme.border}; + border-radius: 6px; + padding: 1rem; + overflow-x: auto; + font-size: 0.85em; + font-family: ${theme.fontMono}; + } +`; + +export const BlogRoutingDemo = () => ( + <> +

Live demo: blog routing (atoms)

+ + + + + View source +
+        {demoSource}
+      
+
+ +); + +export default BlogRoutingDemo; diff --git a/packages/docs/src/pages/DemosIndex.tsx b/packages/docs/src/pages/DemosIndex.tsx index b109a057..18971f52 100644 --- a/packages/docs/src/pages/DemosIndex.tsx +++ b/packages/docs/src/pages/DemosIndex.tsx @@ -1,6 +1,6 @@ import { Link } from "jarl-react"; import LinkList from "../lib/LinkList"; -import { basicRoutingDemoRoute } from "../router/routes"; +import { basicRoutingDemoRoute, blogRoutingDemoRoute } from "../router/routes"; export const DemosIndex = () => ( <> @@ -20,6 +20,13 @@ export const DemosIndex = () => ( — a nested router-within-a-router built from staticRouteAtom/paramRouteAtom and the atoms-based Link/Route components. +
  • + + Blog routing + {" "} + — a classic /blog/:year/:month/:day/:slug tree, hand-composed from{" "} + numericRouteAtom chained as parent/child, with 404s for out-of-range and non-existent dates. +
  • ); diff --git a/packages/docs/src/router/routes.ts b/packages/docs/src/router/routes.ts index 896298c2..6bbd1b2d 100644 --- a/packages/docs/src/router/routes.ts +++ b/packages/docs/src/router/routes.ts @@ -5,7 +5,8 @@ * router - and, since the site is prerendered, it doubles as the SSR/SSG proof case for * `jarl-atoms`' server-seedable `locationAtom`. */ -import { rootAtom, staticRouteAtom, paramRouteAtom } from "jarl-atoms"; +import { rootAtom, staticRouteAtom, paramRouteAtom, numericRouteAtom } from "jarl-atoms"; +import { blogStaticPaths } from "../demos/blogPosts"; export const homeRoute = rootAtom; @@ -26,6 +27,14 @@ export const demosIndexRoute = staticRouteAtom("demos"); export const basicRoutingDemoRoute = staticRouteAtom("basic-routing", { parent: demosIndexRoute }); export const basicRoutingDemoPageRoute = paramRouteAtom("page", { parent: basicRoutingDemoRoute }); +// Blog routing demo: a classic /blog/:year/:month/:day/:slug tree, hand-composed from +// numericRouteAtom rather than a single dedicated date primitive. +export const blogRoutingDemoRoute = staticRouteAtom("blog-routing", { parent: demosIndexRoute }); +export const blogYearRoute = numericRouteAtom("year", { parent: blogRoutingDemoRoute }); +export const blogMonthRoute = numericRouteAtom("month", { parent: blogYearRoute, min: 1, max: 12 }); +export const blogDayRoute = numericRouteAtom("day", { parent: blogMonthRoute, min: 1, max: 31 }); +export const blogPostRoute = paramRouteAtom("slug", { parent: blogDayRoute }); + export type DocName = "getting-started" | "data-loading" | "path-variables"; export const docPages: { docName: DocName; title: string }[] = [ @@ -53,4 +62,5 @@ export const staticPaths: string[] = [ "/demos", "/demos/basic-routing", "/demos/basic-routing/about", + ...blogStaticPaths(), ]; From 71010568a3919c57a3d14dd1ed09cbf5317a37bc Mon Sep 17 00:00:00 2001 From: Peter Hurst Date: Sat, 15 Aug 2026 07:14:16 +0100 Subject: [PATCH 05/10] refactor(docs): trim BlogRoutingApp docblock to focus on 404 behavior Remove structural details visible in code; highlight the non-obvious two-layer 404 pattern (URL-shape via Switch, content-level via validation). Co-Authored-By: Claude Fable 5 --- packages/docs/src/demos/BlogRoutingApp.tsx | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/packages/docs/src/demos/BlogRoutingApp.tsx b/packages/docs/src/demos/BlogRoutingApp.tsx index 8bbc6fdd..43420233 100644 --- a/packages/docs/src/demos/BlogRoutingApp.tsx +++ b/packages/docs/src/demos/BlogRoutingApp.tsx @@ -161,11 +161,8 @@ const PostPage = ({ year, month, day, slug }: { year: number; month: number; day }; /** - * Demo of a classic /blog/:year/:month/:day/:slug tree, composed from three - * `numericRouteAtom`s chained as parent/child rather than a single date primitive. Each level - * validates its own segment's range (via `numericRouteAtom`'s `min`/`max`) plus, once matched, - * that the date is real and posts actually exist there - falling back to `BlogNotFound` either - * way, and to the `Switch`'s own fallback when no level's URL shape matches at all. + * Demo of a classic /blog/:year/:month/:day/:slug tree: URL-shape 404s via the Switch + * fallback, content-level 404s via isValidCalendarDate and empty-list checks. */ export const BlogRoutingApp = () => ( <> From 78c3a86a8a8363df29726ca6b059765b91b0f623 Mon Sep 17 00:00:00 2001 From: Peter Hurst Date: Sat, 15 Aug 2026 07:37:54 +0100 Subject: [PATCH 06/10] feat(atoms): add asyncRouteAtom for routes an async lookup decides A route whose existence only a database can answer: the lookup runs against the parent route's matched values, `undefined` back means no match (so a Switch fallback or notAtom renders the not-found case), and a hit matches with the loaded object typed onto the route's own values - one call answering both "does this exist" and "what is it". Matching stays synchronous, so the lookup settles into the store first: preloadRoutes awaits it (the step a server render needs before it can produce HTML and a status code) and returns snapshots, hydrateAsyncRoutes seeds those into a client store without repeating the lookup, and followAsyncRoutes keeps them settled across client navigation. Co-Authored-By: Claude Fable 5 --- packages/jarl-atoms/README.md | 4 +- .../src/__tests__/asyncRouteAtom.test.ts | 178 ++++++++++++++++++ packages/jarl-atoms/src/asyncRouteAtom.ts | 155 +++++++++++++++ packages/jarl-atoms/src/index.ts | 1 + 4 files changed, 337 insertions(+), 1 deletion(-) create mode 100644 packages/jarl-atoms/src/__tests__/asyncRouteAtom.test.ts create mode 100644 packages/jarl-atoms/src/asyncRouteAtom.ts diff --git a/packages/jarl-atoms/README.md b/packages/jarl-atoms/README.md index 0c0cfa20..fb4ff64b 100644 --- a/packages/jarl-atoms/README.md +++ b/packages/jarl-atoms/README.md @@ -50,7 +50,9 @@ const href = store.get(docAtom).reverse({ docName: "getting-started" }); ``` Other exports: `queryAtom`/`queryParamAtom` (query-string state, composable -the same way as path atoms), `redirectAtom`, and `resolvedAtom`. See the full +the same way as path atoms), `redirectAtom`, `resolvedAtom`, and `asyncRouteAtom` +(a route that exists only if an async lookup finds it, with what it found bound +to the route's values). See the full docs and demos for the complete model: [JARL demos and documentation](https://jarl.downplay.co) diff --git a/packages/jarl-atoms/src/__tests__/asyncRouteAtom.test.ts b/packages/jarl-atoms/src/__tests__/asyncRouteAtom.test.ts new file mode 100644 index 00000000..613bfb3e --- /dev/null +++ b/packages/jarl-atoms/src/__tests__/asyncRouteAtom.test.ts @@ -0,0 +1,178 @@ +import { createStore } from "jotai/vanilla"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { asyncRouteAtom, followAsyncRoutes, hydrateAsyncRoutes, preloadRoutes } from "../asyncRouteAtom"; +import { locationAtom } from "../locationAtom"; +import { notAtom } from "../notAtom"; +import { paramRouteAtom } from "../paramRouteAtom"; +import { redirect } from "../redirectAtom"; +import { staticRouteAtom } from "../staticRouteAtom"; + +type Post = { slug: string; title: string }; + +const POSTS: Post[] = [{ slug: "hello", title: "Hello" }]; + +const seed = (store: ReturnType, pathname: string) => { + store.set(locationAtom, { pathname, searchParams: new URLSearchParams() }); +}; + +const blogRoute = staticRouteAtom("blog"); +const slugRoute = paramRouteAtom("slug", { parent: blogRoute }); + +const findPost = async ({ slug }: { slug: string }) => POSTS.find((post) => post.slug === slug); + +describe("asyncRouteAtom", () => { + let store: ReturnType; + + beforeEach(() => { + store = createStore(); + }); + + it("doesn't match until its lookup has been settled", async () => { + const postRoute = asyncRouteAtom(slugRoute, "post", findPost); + seed(store, "/blog/hello"); + + expect(store.get(postRoute).match).toBe(false); + expect(store.get(postRoute.pending)).toBe(true); + + await preloadRoutes(store, [postRoute]); + + expect(store.get(postRoute.pending)).toBe(false); + expect(store.get(postRoute).match).toBe(true); + }); + + it("puts the looked-up object on the route's values under the given name", async () => { + const postRoute = asyncRouteAtom(slugRoute, "post", findPost); + seed(store, "/blog/hello"); + await preloadRoutes(store, [postRoute]); + + const route = store.get(postRoute); + expect(route.values).toEqual({ slug: "hello", post: { slug: "hello", title: "Hello" } }); + expect(route.exact).toBe(true); + }); + + it("doesn't match when the lookup finds nothing, so notAtom reports a not-found", async () => { + const postRoute = asyncRouteAtom(slugRoute, "post", findPost); + const notFound = notAtom(blogRoute, postRoute); + seed(store, "/blog/no-such-post"); + await preloadRoutes(store, [postRoute]); + + expect(store.get(postRoute).match).toBe(false); + expect(store.get(postRoute.pending)).toBe(false); + expect(store.get(notFound)).toBe(true); + }); + + it("is neither pending nor matched where its parent doesn't match at all", async () => { + const postRoute = asyncRouteAtom(slugRoute, "post", findPost); + const lookup = vi.fn(findPost); + seed(store, "/elsewhere"); + await preloadRoutes(store, [asyncRouteAtom(slugRoute, "post", lookup)]); + + expect(store.get(postRoute.pending)).toBe(false); + expect(store.get(postRoute).match).toBe(false); + expect(lookup).not.toHaveBeenCalled(); + }); + + it("stops matching once a snapshot is stale for the new location", async () => { + const postRoute = asyncRouteAtom(slugRoute, "post", findPost); + seed(store, "/blog/hello"); + await preloadRoutes(store, [postRoute]); + seed(store, "/blog/other"); + + expect(store.get(postRoute).match).toBe(false); + expect(store.get(postRoute.pending)).toBe(true); + }); + + it("reverses through its parent, ignoring the looked-up object", async () => { + const postRoute = asyncRouteAtom(slugRoute, "post", findPost); + seed(store, "/blog/hello"); + await preloadRoutes(store, [postRoute]); + + expect(store.get(postRoute).reverse({ slug: "other", post: { slug: "other", title: "Other" } })).toBe( + "/blog/other", + ); + }); + + it("treats a lookup that redirects as no match, leaving followResolvedRedirects to navigate", async () => { + const gatedRoute = asyncRouteAtom(slugRoute, "post", async () => redirect("/login")); + seed(store, "/blog/hello"); + await preloadRoutes(store, [gatedRoute]); + + expect(store.get(gatedRoute).match).toBe(false); + }); +}); + +describe("preloadRoutes", () => { + let store: ReturnType; + + beforeEach(() => { + store = createStore(); + }); + + it("returns a serialisable snapshot per route, in the order given", async () => { + const postRoute = asyncRouteAtom(slugRoute, "post", findPost); + seed(store, "/blog/hello"); + + expect(await preloadRoutes(store, [postRoute])).toEqual([ + { pathname: "/blog/hello", data: { slug: "hello", title: "Hello" } }, + ]); + }); + + it("doesn't look a route up again once it is settled for this location", async () => { + const lookup = vi.fn(findPost); + const postRoute = asyncRouteAtom(slugRoute, "post", lookup); + seed(store, "/blog/hello"); + + await preloadRoutes(store, [postRoute]); + await preloadRoutes(store, [postRoute]); + + expect(lookup).toHaveBeenCalledTimes(1); + }); +}); + +describe("hydrateAsyncRoutes", () => { + it("makes a route match from a server render's snapshot, without looking anything up", () => { + const lookup = vi.fn(findPost); + const postRoute = asyncRouteAtom(slugRoute, "post", lookup); + const store = createStore(); + seed(store, "/blog/hello"); + + hydrateAsyncRoutes(store, [postRoute], [{ pathname: "/blog/hello", data: POSTS[0] }]); + + expect(store.get(postRoute).values).toEqual({ slug: "hello", post: POSTS[0] }); + expect(lookup).not.toHaveBeenCalled(); + }); +}); + +describe("followAsyncRoutes", () => { + const flush = () => new Promise((resolve) => setTimeout(resolve, 0)); + + it("settles the lookup for each new location", async () => { + const postRoute = asyncRouteAtom(slugRoute, "post", findPost); + const store = createStore(); + seed(store, "/blog/no-such-post"); + + const unsubscribe = followAsyncRoutes(store, [postRoute]); + await flush(); + expect(store.get(postRoute).match).toBe(false); + + seed(store, "/blog/hello"); + await flush(); + expect(store.get(postRoute).values).toEqual({ slug: "hello", post: POSTS[0] }); + + unsubscribe(); + }); + + it("leaves a hydrated route alone rather than looking it up a second time", async () => { + const lookup = vi.fn(findPost); + const postRoute = asyncRouteAtom(slugRoute, "post", lookup); + const store = createStore(); + seed(store, "/blog/hello"); + hydrateAsyncRoutes(store, [postRoute], [{ pathname: "/blog/hello", data: POSTS[0] }]); + + const unsubscribe = followAsyncRoutes(store, [postRoute]); + await flush(); + + expect(lookup).not.toHaveBeenCalled(); + unsubscribe(); + }); +}); diff --git a/packages/jarl-atoms/src/asyncRouteAtom.ts b/packages/jarl-atoms/src/asyncRouteAtom.ts new file mode 100644 index 00000000..8fd956f9 --- /dev/null +++ b/packages/jarl-atoms/src/asyncRouteAtom.ts @@ -0,0 +1,155 @@ +// Route matching is synchronous everywhere else in this package - `Switch`, +// `Route` and `notAtom` all read a RouteAtom with a plain `get`. A route whose +// existence only a database can answer therefore can't decide its own match +// inline; the lookup has to be settled into the store first, and that settling +// step is what a server render awaits before producing HTML. + +import { Atom, Getter, WritableAtom, atom } from "jotai/vanilla"; +import { locationAtom } from "./locationAtom"; +import { Redirect, Store, isRedirect } from "./redirectAtom"; +import { Resolver, resolvedAtom } from "./resolvedAtom"; +import { transformRouteAtom } from "./transformRouteAtom"; +import { DefaultParams, RouteAtom } from "./types"; + +/** + * Looks a matched route's params up in an async source. `undefined` means nothing exists at that + * address, so the route doesn't match; a `Redirect` sends the app elsewhere instead, via + * `followResolvedRedirects`. + */ +export type RouteLookup = Resolver; + +/** An async route's matched values: its parent's, plus the looked-up object under `Name`. */ +export type AsyncRouteValues = T & { + readonly [key in Name]: Data; +}; + +/** One async route's settled lookup, carried from a server render into client hydration. */ +export type AsyncRouteSnapshot = { + pathname: string; + /** The looked-up object, or `undefined` for "nothing exists here". */ + data: unknown; +}; + +/** What `asyncRouteAtom` returns: a route atom, plus the seams its lookup is driven through. */ +export type AsyncRouteAtom = RouteAtom< + AsyncRouteValues +> & { + /** The lookup itself, to consume with `useAtomValue` under Suspense or pass to `followResolvedRedirects`. */ + readonly lookup: Atom>; + /** Whether the lookup for the current location has yet to settle - true only while the parent route matches. */ + readonly pending: Atom; + /** The settled lookup the match reads, written by `preloadRoutes`, `hydrateAsyncRoutes` and `followAsyncRoutes`. */ + readonly settled: WritableAtom; +}; + +const pathnameOf = (get: Getter): string => get(locationAtom).pathname ?? "/"; + +/** + * A route that exists only if an async lookup says so. `lookup` runs against the parent route's + * matched values; `undefined` back means this route doesn't match, so a `Switch` fallback or + * `notAtom` renders the not-found case, and anything else matches with that object bound to + * `name` in the route's own `values` - one lookup answering both "does this exist" and "what is + * it". + * + * ```ts + * const postRoute = asyncRouteAtom(slugRoute, "post", ({ slug }) => db.findPost(slug)); + * // {({ post }) => } + * ``` + * + * Matching stays synchronous, so the lookup must be settled into the store first: `await + * preloadRoutes(store, routes)` before a server render, `hydrateAsyncRoutes` to seed the client + * with what that render already loaded, and `followAsyncRoutes` once at startup to keep it + * settled across client navigation. + */ +export const asyncRouteAtom = ( + parentAtom: RouteAtom, + name: Name, + lookup: RouteLookup, +): AsyncRouteAtom => { + const lookupAtom = resolvedAtom(parentAtom, lookup); + const settledAtom = atom(null); + // A snapshot taken at a different pathname says nothing about this location. + const settledHere = (get: Getter): AsyncRouteSnapshot | undefined => { + const settled = get(settledAtom); + return settled && settled.pathname === pathnameOf(get) ? settled : undefined; + }; + const route = transformRouteAtom>( + parentAtom, + (values, get) => { + const settled = settledHere(get); + if (!settled || settled.data === undefined) { + return undefined; + } + return { ...values, [name]: settled.data } as unknown as AsyncRouteValues; + }, + (values) => { + const parentValues: Record = { ...values }; + delete parentValues[name]; + return parentValues as unknown as T; + }, + ); + return Object.assign(route, { + lookup: lookupAtom, + pending: atom((get) => get(parentAtom).match && !settledHere(get)), + settled: settledAtom, + }); +}; + +const load = async (store: Store, route: AsyncRouteAtom): Promise => { + const pathname = store.get(locationAtom).pathname ?? "/"; + const settled = store.get(route.settled); + if (settled?.pathname === pathname) { + return settled; + } + const value = await store.get(route.lookup); + const snapshot: AsyncRouteSnapshot = { pathname, data: isRedirect(value) ? undefined : value }; + // A navigation overtook this lookup; the load it triggered publishes instead. + if ((store.get(locationAtom).pathname ?? "/") === pathname) { + store.set(route.settled, snapshot); + } + return snapshot; +}; + +/** + * Awaits each route's lookup for the store's current location and settles it, so a render that + * follows sees the routes match, or not, synchronously - the step a server render needs before + * it can produce HTML and a status code. Returns the snapshots, in the order given, to serialise + * into the page for `hydrateAsyncRoutes`. Routes already settled for that location aren't looked + * up again. + */ +export const preloadRoutes = ( + store: Store, + routes: ReadonlyArray>, +): Promise => Promise.all(routes.map((route) => load(store, route))); + +/** + * Seeds a server render's snapshots into a client store, so hydration matches the HTML it + * received without looking anything up a second time. `snapshots` are matched to `routes` by + * position, exactly as `preloadRoutes` returned them. + */ +export const hydrateAsyncRoutes = ( + store: Store, + routes: ReadonlyArray>, + snapshots: ReadonlyArray, +): void => { + routes.forEach((route, index) => { + const snapshot = snapshots[index]; + if (snapshot) { + store.set(route.settled, snapshot); + } + }); +}; + +/** + * Keeps async routes settled as the location changes: re-runs each lookup on navigation and + * publishes what it finds. Call once near the root of a client app, after `hydrateAsyncRoutes`. + * Returns an unsubscribe function. + */ +export const followAsyncRoutes = (store: Store, routes: ReadonlyArray>): (() => void) => { + // Subscribed to the location rather than to each `lookup`: subscribing to an async atom mounts + // it, which would run every lookup up front, including ones hydration already answered. + const reload = () => routes.forEach((route) => void load(store, route)); + const unsubscribe = store.sub(locationAtom, reload); + reload(); + return unsubscribe; +}; diff --git a/packages/jarl-atoms/src/index.ts b/packages/jarl-atoms/src/index.ts index 4fe4dfcd..4ed35036 100644 --- a/packages/jarl-atoms/src/index.ts +++ b/packages/jarl-atoms/src/index.ts @@ -16,3 +16,4 @@ export * from "./href"; export * from "./queryAtom"; export * from "./redirectAtom"; export * from "./resolvedAtom"; +export * from "./asyncRouteAtom"; From 19e78b7eb9b6ba73e4d75a0f33ce5f84c59921b4 Mon Sep 17 00:00:00 2001 From: Peter Hurst Date: Sat, 15 Aug 2026 07:38:05 +0100 Subject: [PATCH 07/10] feat(docs): async-lookup demo, and a real 404 status from SSR A demo at /demos/async-lookup backed by a fake Promise-based database: a known slug renders the article the route match already loaded, an unknown one renders the demo's not-found view. The server render now awaits preloadRoutes before rendering and reports a status alongside the HTML, from a notFoundAtom listing every route the site has content for. The prerender and dev servers pass that status through, and the build fails if a path in staticPaths renders a 404 (or if 404.html doesn't). Preloaded data is embedded for the client entry to hydrate from, so a page load looks nothing up twice. DemoBox/SourceDisclosure, duplicated in both existing demo pages, move into lib/DemoPage. Co-Authored-By: Claude Fable 5 --- packages/docs/scripts/build.mjs | 21 +++-- packages/docs/scripts/dev-server.mjs | 4 +- packages/docs/src/App.tsx | 11 +++ .../docs/src/content/guides/DataLoading.md | 69 ++++++++++++++++ packages/docs/src/demos/AsyncLookupApp.tsx | 79 +++++++++++++++++++ packages/docs/src/demos/asyncArticles.ts | 33 ++++++++ packages/docs/src/entry-client.tsx | 18 ++++- packages/docs/src/entry-server.tsx | 27 ++++++- packages/docs/src/lib/DemoPage.tsx | 67 ++++++++++++++++ packages/docs/src/pages/AsyncLookupDemo.tsx | 11 +++ packages/docs/src/pages/BasicRoutingDemo.tsx | 70 +++------------- packages/docs/src/pages/BlogRoutingDemo.tsx | 58 +------------- packages/docs/src/pages/DemosIndex.tsx | 10 ++- packages/docs/src/router/routes.ts | 40 +++++++++- 14 files changed, 388 insertions(+), 130 deletions(-) create mode 100644 packages/docs/src/demos/AsyncLookupApp.tsx create mode 100644 packages/docs/src/demos/asyncArticles.ts create mode 100644 packages/docs/src/lib/DemoPage.tsx create mode 100644 packages/docs/src/pages/AsyncLookupDemo.tsx diff --git a/packages/docs/scripts/build.mjs b/packages/docs/scripts/build.mjs index f1fdea66..600116d1 100644 --- a/packages/docs/scripts/build.mjs +++ b/packages/docs/scripts/build.mjs @@ -41,29 +41,38 @@ async function main() { const template = await fs.readFile(templatePath, "utf-8"); const entryServerUrl = pathToFileURL(path.join(ssrOutDir, "entry-server.js")).href; - /** @type {{ render: (path: string) => { html: string, head: string }, staticPaths: string[] }} */ + /** @type {{ render: (path: string) => Promise<{ html: string, head: string, status: number }>, staticPaths: string[] }} */ const { render, staticPaths } = await import(entryServerUrl); // Emotion's extracted