From d293e8f3d0971c8a2a9bbbc28efdde15bce484cb Mon Sep 17 00:00:00 2001 From: Peter Hurst Date: Tue, 18 Aug 2026 05:09:30 +0100 Subject: [PATCH 1/5] feat(bench): jarl vs react-router benchmark workspace Reproducible comparison harness: per-navigation re-render counts with a byte-identical-HTML parity assertion between the two apps, matching/resolve and navigation throughput under plain Node, and min+gzip bundle size from the published dist builds. Run from the repo root with `npm run bench`; the deterministic parity/count test also runs under `npm test` in CI. Ticket: 674 --- bench/README.md | 78 ++++++++++++++++++++ bench/package.json | 29 ++++++++ bench/src/apps/JarlApp.tsx | 92 +++++++++++++++++++++++ bench/src/apps/ReactRouterApp.tsx | 76 +++++++++++++++++++ bench/src/apps/sharedComponents.tsx | 48 ++++++++++++ bench/src/apps/types.ts | 7 ++ bench/src/bundle-size.benchmark.ts | 44 +++++++++++ bench/src/matching.benchmark.ts | 77 ++++++++++++++++++++ bench/src/renderCounter.ts | 18 +++++ bench/src/renders.test.tsx | 101 ++++++++++++++++++++++++++ bench/src/shape.ts | 6 ++ bench/src/size/jarl-entry.tsx | 40 ++++++++++ bench/src/size/react-router-entry.tsx | 31 ++++++++ bench/src/stats.ts | 80 ++++++++++++++++++++ bench/tsconfig.json | 7 ++ bench/vitest.bench.config.ts | 17 +++++ bench/vitest.config.ts | 13 ++++ package-lock.json | 55 +++++++++++++- package.json | 4 +- 19 files changed, 821 insertions(+), 2 deletions(-) create mode 100644 bench/README.md create mode 100644 bench/package.json create mode 100644 bench/src/apps/JarlApp.tsx create mode 100644 bench/src/apps/ReactRouterApp.tsx create mode 100644 bench/src/apps/sharedComponents.tsx create mode 100644 bench/src/apps/types.ts create mode 100644 bench/src/bundle-size.benchmark.ts create mode 100644 bench/src/matching.benchmark.ts create mode 100644 bench/src/renderCounter.ts create mode 100644 bench/src/renders.test.tsx create mode 100644 bench/src/shape.ts create mode 100644 bench/src/size/jarl-entry.tsx create mode 100644 bench/src/size/react-router-entry.tsx create mode 100644 bench/src/stats.ts create mode 100644 bench/tsconfig.json create mode 100644 bench/vitest.bench.config.ts create mode 100644 bench/vitest.config.ts diff --git a/bench/README.md b/bench/README.md new file mode 100644 index 0000000..e855efa --- /dev/null +++ b/bench/README.md @@ -0,0 +1,78 @@ +# jarl vs react-router benchmark + +A reproducible comparison of jarl (`jarl-atoms` + `jarl-react`) and react-router. Results and +interpretation are published in the docs site's [Benchmarks guide](../packages/docs/src/content/guides/Benchmarks.md); +this README defines exactly what is measured and how to re-run it. + +```bash +# from the repo root: builds jarl-atoms + jarl-react, then runs everything +npm run bench +``` + +`npm test` in this workspace runs only the deterministic render-count comparison (no timing), so it +is safe in CI; the timed benchmarks run under `NODE_ENV=production` in a forked Node process with +`--expose-gc`. + +## What is measured + +### Re-renders per navigation (`src/renders.test.tsx`) + +Both routers drive the same app — a layout with 13 active-styled nav links, 10 components that read +no route state ("widgets"), and four routed pages — defined once in `src/shape.ts` and +`src/apps/sharedComponents.tsx`, with only the router integration differing per app. Navigation is +performed by clicking the rendered links under jsdom, and every component tallies its own renders. +Counts are deterministic, so there is no sampling; the run also asserts that both apps produce +**byte-identical HTML** after mount and after every single navigation, which is what makes the +comparison like-for-like. + +Nav links are built from each router's public hook primitives (`useAtom` over the route atom for +jarl; `useMatch`/`useHref`/`useLinkClickHandler` for react-router) so both render the same anchor +markup. One deliberate deviation, discovered by the parity assertion: jarl's own `active` flag +(`useLink`/`activeClassName`) is route-level — every link to a route atom reports active whatever +its param values — so the jarl nav link narrows it to href-level by also comparing param values, +matching react-router's semantics. + +React's development build is used here; without StrictMode it renders each component once per +update, the same as production, and no timing is taken from this file. + +### Matching/resolve throughput (`src/matching.benchmark.ts`) + +Pure library cost with React excluded, over a 100-route table (50 static sections, each with a +`:id` param child), run under plain Node. Neither side touches history or the DOM there: jarl's +`locationAtom` falls back to its server path, and react-router is given a config or a memory +router. The two libraries match with different machinery, so the workloads are defined by +outcome rather than mechanics: + +- **resolve** — one URL string in, the matched leaf out. jarl writes `locationAtom` and reads leaf + route atoms in order until one matches, which is exactly what a mounted `Switch` does — its + `findIndex` short-circuits too, so a hit early in the table costs less than a late one or a + miss. The measured URLs cycle an early, middle and late hit plus a miss so neither library is + measured only at its best. react-router calls `matchRoutes` over the equivalent config, which + ranks the whole table on every call. The "cold" jarl variant pays a fresh jotai store per + resolve, as each SSR request would; `matchRoutes` is stateless, so its cold and warm costs are + the same call. +- **navigate** — one client-side navigation through each library's own API: a param-value write to + a route atom plus re-reading the leaves, versus `router.navigate()` on a memory router + (awaited — its API is promise-based). Not equivalent work: `router.navigate()` runs + react-router's full data-router state machine, where the atom write only re-derives state. jarl + is slower here regardless, so the gap this understates is jarl's own. + +Each number is 30 retained samples of 1000 operations, after 10 discarded warm-up samples, with GC +forced between samples; reported as median with p25/p75 and min/max. + +### Bundle size (`src/bundle-size.benchmark.ts`) + +The two entries in `src/size/` implement the same minimal app using each router's typical surface. +Each is bundled from the packages' published dist builds with rolldown (minified, +`NODE_ENV=production` defined, `react`/`react-dom` external) and reported minified and gzipped +(zlib level 9). jarl is reported both with its full dependency cost (jotai + jotai-location +bundled) and with jotai external, for apps already using jotai. + +## What the numbers do not show + +- No real-browser timings: navigation cost here is library work only, not layout/paint, and jsdom + is used solely for deterministic render counting. +- No data APIs: react-router's loaders/actions/lazy-route machinery and jarl's async route atoms + are unexercised, though the react-router bundle necessarily carries that code. +- One app shape and one route-table shape; different shapes (deep nesting, splats, query-heavy + routing) may rank differently. diff --git a/bench/package.json b/bench/package.json new file mode 100644 index 0000000..d1ff0ca --- /dev/null +++ b/bench/package.json @@ -0,0 +1,29 @@ +{ + "name": "jarl-bench", + "version": "0.0.0", + "private": true, + "description": "Reproducible performance comparison of jarl vs react-router: re-render counts, matching throughput and bundle size. See README.md for methodology.", + "type": "module", + "scripts": { + "test": "vitest run", + "bench": "vitest run && NODE_ENV=production vitest run --config vitest.bench.config.ts", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "jarl-atoms": "2.6.0", + "jarl-react": "2.6.0", + "jotai": "^2.20.2", + "react": "^19.2.8", + "react-dom": "^19.2.8", + "react-router": "^8.3.0" + }, + "devDependencies": { + "@types/react": "^19.2.18", + "@types/react-dom": "^19.2.4", + "@vitejs/plugin-react": "^6.0.5", + "jsdom": "^30.0.1", + "rolldown": "^1.2.3", + "typescript": "^7.0.2", + "vitest": "^4.1.10" + } +} diff --git a/bench/src/apps/JarlApp.tsx b/bench/src/apps/JarlApp.tsx new file mode 100644 index 0000000..8923a0f --- /dev/null +++ b/bench/src/apps/JarlApp.tsx @@ -0,0 +1,92 @@ +import { DefaultParams, RouteAtom, paramRouteAtom, rootAtom, staticRouteAtom } from "jarl-atoms"; +import { Route, Switch, useAtom } from "jarl-react"; +import { Provider, createStore } from "jotai"; +import { countRender } from "../renderCounter"; +import { itemIds } from "../shape"; +import { AboutPage, HomePage, ItemDetail, ItemsPage, NotFoundPage, Widgets } from "./sharedComponents"; +import type { BenchApp } from "./types"; + +export const homeRoute = rootAtom; +export const aboutRoute = staticRouteAtom("about"); +export const itemsRoute = staticRouteAtom("items"); +export const itemRoute = paramRouteAtom("itemId", { parent: itemsRoute }); + +// Built from hook primitives rather than , so both apps render identical anchor markup. +const NavItem = ({ + route, + to, + label, + exact, +}: { + route: RouteAtom; + to: T; + label: string; + exact?: boolean; +}) => { + countRender("nav link"); + const [state, setRoute] = useAtom(route); + // jarl's own `active` is route-level, so two links to different items both light up. + // Narrowed to href-level here to match react-router's semantics. + const matched = exact ? state.exact : state.match; + const active = + matched && Object.entries(to).every(([key, value]) => (state.values as Record)[key] === value); + return ( + { + event.preventDefault(); + setRoute(to); + }} + > + {label} + + ); +}; + +const Nav = () => ( + +); + +const Layout = () => { + countRender("layout"); + return ( +
+
+ ); +}; + +/** Fresh store per mount, so consecutive scenario runs can't leak location state into each other. */ +export const createJarlApp = (): BenchApp => { + const store = createStore(); + return { + element: ( + + + + ), + }; +}; diff --git a/bench/src/apps/ReactRouterApp.tsx b/bench/src/apps/ReactRouterApp.tsx new file mode 100644 index 0000000..d39d570 --- /dev/null +++ b/bench/src/apps/ReactRouterApp.tsx @@ -0,0 +1,76 @@ +import { + Outlet, + RouterProvider, + createBrowserRouter, + useHref, + useLinkClickHandler, + useMatch, + useParams, +} from "react-router"; +import { countRender } from "../renderCounter"; +import { itemIds } from "../shape"; +import { AboutPage, HomePage, ItemDetail, ItemsPage, NotFoundPage, Widgets } from "./sharedComponents"; +import type { BenchApp } from "./types"; + +// Built from the hook primitives NavLink itself uses, since NavLink adds an +// aria-current jarl's Link has no analogue of and the markup must match. +const NavItem = ({ href, label, exact }: { href: string; label: string; exact?: boolean }) => { + countRender("nav link"); + const active = useMatch(exact ? href : `${href}/*`) != null; + const linkHref = useHref(href); + const onClick = useLinkClickHandler(href); + return ( + + {label} + + ); +}; + +const Nav = () => ( + +); + +const Layout = () => { + countRender("layout"); + return ( +
+
+ ); +}; + +const RoutedItemDetail = () => { + const { itemId } = useParams<"itemId">(); + return ; +}; + +export const createReactRouterApp = (): BenchApp => { + const router = createBrowserRouter([ + { + path: "/", + element: , + children: [ + { index: true, element: }, + { path: "about", element: }, + { path: "items", element: }, + { path: "items/:itemId", element: }, + { path: "*", element: }, + ], + }, + ]); + return { + element: , + dispose: () => router.dispose(), + }; +}; diff --git a/bench/src/apps/sharedComponents.tsx b/bench/src/apps/sharedComponents.tsx new file mode 100644 index 0000000..04eaa19 --- /dev/null +++ b/bench/src/apps/sharedComponents.tsx @@ -0,0 +1,48 @@ +// Shared verbatim by both apps, so any render-count difference comes from the router alone. +import { countRender } from "../renderCounter"; +import { WIDGET_COUNT, itemIds } from "../shape"; + +/** Reads nothing from any router: the control group that should never re-render on navigation. */ +export const Widget = ({ index }: { index: number }) => { + countRender("widget"); + return
Widget {index}
; +}; + +export const Widgets = () => ( + +); + +export const HomePage = () => { + countRender("home page"); + return

Home

; +}; + +export const AboutPage = () => { + countRender("about page"); + return

About

; +}; + +export const ItemsPage = () => { + countRender("items page"); + return ( + <> +

Items

+
    + {itemIds.map((id) => ( +
  • Item {id}
  • + ))} +
+ + ); +}; + +export const ItemDetail = ({ itemId }: { itemId: string }) => { + countRender("item page"); + return

Item {itemId}

; +}; + +export const NotFoundPage = () =>

Not found

; diff --git a/bench/src/apps/types.ts b/bench/src/apps/types.ts new file mode 100644 index 0000000..8a3e8c4 --- /dev/null +++ b/bench/src/apps/types.ts @@ -0,0 +1,7 @@ +import type { ReactElement } from "react"; + +/** One mountable benchmark app; `dispose` tears down anything living outside the React tree. */ +export type BenchApp = { + element: ReactElement; + dispose?: () => void; +}; diff --git a/bench/src/bundle-size.benchmark.ts b/bench/src/bundle-size.benchmark.ts new file mode 100644 index 0000000..fecf340 --- /dev/null +++ b/bench/src/bundle-size.benchmark.ts @@ -0,0 +1,44 @@ +// Router cost on the wire, bundled from each package's published dist build — +// what an npm consumer actually ships. See ../README.md. +import { gzipSync } from "node:zlib"; +import { fileURLToPath } from "node:url"; +import { rolldown } from "rolldown"; + +const entry = (name: string) => fileURLToPath(new URL(`./size/${name}`, import.meta.url)); + +const reactExternals = [/^react($|\/)/, /^react-dom($|\/)/, /^scheduler($|\/)/]; + +const bundleSize = async (input: string, external: RegExp[]) => { + const bundle = await rolldown({ + input, + external, + transform: { define: { "process.env.NODE_ENV": '"production"' } }, + }); + const { output } = await bundle.generate({ format: "esm", minify: true }); + const code = output + .filter((chunk) => chunk.type === "chunk") + .map((chunk) => chunk.code) + .join(""); + await bundle.close(); + return { min: Buffer.byteLength(code), gzip: gzipSync(Buffer.from(code), { level: 9 }).byteLength }; +}; + +const kb = (bytes: number) => `${(bytes / 1024).toFixed(1)} kB`; + +test("bundle size (min / min+gzip), react and react-dom external", async () => { + const jarl = await bundleSize(entry("jarl-entry.tsx"), reactExternals); + const jarlExternalJotai = await bundleSize(entry("jarl-entry.tsx"), [...reactExternals, /^jotai($|\/)/]); + const reactRouter = await bundleSize(entry("react-router-entry.tsx"), reactExternals); + + console.log("\nBundle size of a minimal routed app (router code only, react/react-dom external):"); + console.log(` jarl (jarl-atoms + jarl-react + jotai + jotai-location) min ${kb(jarl.min)} gzip ${kb(jarl.gzip)}`); + console.log( + ` jarl, app already using jotai (jotai external) min ${kb(jarlExternalJotai.min)} gzip ${kb(jarlExternalJotai.gzip)}`, + ); + console.log( + ` react-router min ${kb(reactRouter.min)} gzip ${kb(reactRouter.gzip)}`, + ); + + expect(jarl.min).toBeGreaterThan(0); + expect(reactRouter.min).toBeGreaterThan(0); +}); diff --git a/bench/src/matching.benchmark.ts b/bench/src/matching.benchmark.ts new file mode 100644 index 0000000..1722a08 --- /dev/null +++ b/bench/src/matching.benchmark.ts @@ -0,0 +1,77 @@ +// Raw matching throughput, React excluded. Workloads are defined by outcome +// rather than mechanics; see ../README.md for what each one covers and why. +import { createStore } from "jotai/vanilla"; +import { locationAtom, paramRouteAtom, staticRouteAtom } from "jarl-atoms"; +import { createMemoryRouter, matchRoutes } from "react-router"; +import { formatSummary, measure, measureAsync } from "./stats"; + +const SECTION_COUNT = 50; + +const sections = Array.from({ length: SECTION_COUNT }, (_, i) => staticRouteAtom(`s${i}`)); +const leaves = sections.map((section) => paramRouteAtom("id", { parent: section })); + +const routeConfig = Array.from({ length: SECTION_COUNT }, (_, i) => ({ + path: `/s${i}`, + children: [{ path: ":id" }], +})); + +// Early, mid and late table hits plus a miss, cycled so neither library can +// specialise on one branch position. +const paths = ["/s0/1", "/s25/123", "/s49/9", "/no-such-section/404"]; + +type Store = ReturnType; + +// Reads leaves in order and stops at the first match, as Switch's own findIndex does. +const resolveJarl = (store: Store, pathname: string): number => { + store.set(locationAtom, { pathname, searchParams: new URLSearchParams() }); + for (let i = 0; i < SECTION_COUNT; i++) { + if (store.get(leaves[i]).match) return i; + } + return -1; +}; + +test(`resolve: URL string in, matched leaf out (${SECTION_COUNT} sections × static + :id param)`, () => { + let cursor = 0; + const nextPath = () => paths[cursor++ % paths.length]; + + const warmStore = createStore(); + const jarlWarm = measure(() => { + resolveJarl(warmStore, nextPath()); + }); + const jarlCold = measure(() => { + resolveJarl(createStore(), nextPath()); + }); + const reactRouter = measure(() => { + matchRoutes(routeConfig, nextPath()); + }); + + console.log(`\nResolve, per URL (${SECTION_COUNT * 2}-route table):`); + console.log(` jarl (warm store) ${formatSummary(jarlWarm)}`); + console.log(` jarl (cold store) ${formatSummary(jarlCold)}`); + console.log(` react-router ${formatSummary(reactRouter)}`); +}); + +test("navigate: one client-side navigation through each library's API", async () => { + const store = createStore(); + let n = 0; + const jarl = measure(() => { + n++; + store.set(leaves[n % SECTION_COUNT], { id: String(n) }); + for (let i = 0; i < SECTION_COUNT; i++) { + if (store.get(leaves[i]).match) break; + } + }); + + const router = createMemoryRouter(routeConfig, { initialEntries: ["/s0/1"] }); + let m = 0; + const reactRouter = await measureAsync(async () => { + m++; + await router.navigate(`/s${m % SECTION_COUNT}/${m}`); + if (router.state.matches.length === 0) throw new Error("no match"); + }); + router.dispose(); + + console.log(`\nNavigate, per navigation (${SECTION_COUNT * 2}-route table):`); + console.log(` jarl (atom write + re-read) ${formatSummary(jarl)}`); + console.log(` react-router (router.navigate) ${formatSummary(reactRouter)}`); +}); diff --git a/bench/src/renderCounter.ts b/bench/src/renderCounter.ts new file mode 100644 index 0000000..437f80e --- /dev/null +++ b/bench/src/renderCounter.ts @@ -0,0 +1,18 @@ +const counts = new Map(); + +export const countRender = (group: string) => { + counts.set(group, (counts.get(group) ?? 0) + 1); +}; + +export const resetCounts = () => counts.clear(); + +export const snapshotCounts = (): ReadonlyMap => new Map(counts); + +/** Deltas since `before`, keeping zeroes: "did not re-render" is a result, not noise. */ +export const diffCounts = (before: ReadonlyMap): Map => { + const diff = new Map(); + for (const [group, count] of counts) { + diff.set(group, count - (before.get(group) ?? 0)); + } + return diff; +}; diff --git a/bench/src/renders.test.tsx b/bench/src/renders.test.tsx new file mode 100644 index 0000000..c0456c6 --- /dev/null +++ b/bench/src/renders.test.tsx @@ -0,0 +1,101 @@ +// @vitest-environment jsdom + +// React's development build is fine here: without StrictMode it renders each +// component once per update, the same as production, and nothing is timed. +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import { createJarlApp } from "./apps/JarlApp"; +import { createReactRouterApp } from "./apps/ReactRouterApp"; +import type { BenchApp } from "./apps/types"; +import { diffCounts, resetCounts, snapshotCounts } from "./renderCounter"; +import { ITEM_COUNT, itemIds } from "./shape"; +import { printTable } from "./stats"; + +(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +type Scenario = { + name: string; + /** Navigations that put the app in the scenario's starting state, excluded from its counts. */ + setup: string[]; + navigations: string[]; +}; + +const scenarios: Scenario[] = [ + { + name: `Param-only navigation: /items/1 → … → /items/${ITEM_COUNT} (${ITEM_COUNT - 1} navigations)`, + setup: ["/items/1"], + navigations: itemIds.slice(1).map((id) => `/items/${id}`), + }, + { + name: "Section navigation: /about ↔ /items (10 navigations)", + setup: [], + navigations: Array.from({ length: 10 }, (_, i) => (i % 2 === 0 ? "/about" : "/items")), + }, + { + name: "Re-click the already-active link: /about (10 navigations)", + setup: ["/about"], + navigations: Array.from({ length: 10 }, () => "/about"), + }, +]; + +type AppRun = { + mountCounts: Map; + scenarioCounts: Map>; + /** innerHTML after mount and after every navigation, in order, for cross-app parity checks. */ + html: string[]; +}; + +const runApp = async (create: () => BenchApp): Promise => { + window.history.replaceState(null, "", "/"); + resetCounts(); + const container = document.createElement("div"); + document.body.appendChild(container); + const { element, dispose } = create(); + const root = createRoot(container); + + const html: string[] = []; + const clickHref = async (href: string) => { + const anchor = container.querySelector(`a[href="${href}"]`); + if (!anchor) throw new Error(`no link with href ${href}`); + await act(async () => anchor.click()); + html.push(container.innerHTML); + }; + + const before = snapshotCounts(); + await act(async () => root.render(element)); + html.push(container.innerHTML); + const mountCounts = diffCounts(before); + + const scenarioCounts = new Map>(); + for (const scenario of scenarios) { + for (const href of scenario.setup) await clickHref(href); + const beforeScenario = snapshotCounts(); + for (const href of scenario.navigations) await clickHref(href); + scenarioCounts.set(scenario.name, diffCounts(beforeScenario)); + } + + await act(async () => root.unmount()); + dispose?.(); + container.remove(); + return { mountCounts, scenarioCounts, html }; +}; + +const comparisonRows = (jarl: Map, reactRouter: Map) => { + const groups = [...new Set([...jarl.keys(), ...reactRouter.keys()])].sort(); + return groups.map((group) => [group, jarl.get(group) ?? 0, reactRouter.get(group) ?? 0]); +}; + +test("re-render counts per navigation, with identical rendered HTML throughout", async () => { + const jarl = await runApp(createJarlApp); + const reactRouter = await runApp(createReactRouterApp); + + expect(jarl.html.length).toBe(reactRouter.html.length); + jarl.html.forEach((markup, step) => expect(markup).toBe(reactRouter.html[step])); + expect(jarl.html.at(-1)).toContain("

About

"); + + const header = ["component group", "jarl renders", "react-router renders"]; + printTable("Initial mount at /", header, comparisonRows(jarl.mountCounts, reactRouter.mountCounts)); + for (const { name } of scenarios) { + printTable(name, header, comparisonRows(jarl.scenarioCounts.get(name)!, reactRouter.scenarioCounts.get(name)!)); + } +}); diff --git a/bench/src/shape.ts b/bench/src/shape.ts new file mode 100644 index 0000000..f5e001e --- /dev/null +++ b/bench/src/shape.ts @@ -0,0 +1,6 @@ +// The one app shape both routers implement, so every measurement compares the same UI. + +export const ITEM_COUNT = 10; +export const WIDGET_COUNT = 10; + +export const itemIds = Array.from({ length: ITEM_COUNT }, (_, i) => String(i + 1)); diff --git a/bench/src/size/jarl-entry.tsx b/bench/src/size/jarl-entry.tsx new file mode 100644 index 0000000..2dd1dcd --- /dev/null +++ b/bench/src/size/jarl-entry.tsx @@ -0,0 +1,40 @@ +// Bundle-size entry: a minimal jarl app touching the surface the benchmark app uses. +import { paramRouteAtom, rootAtom, staticRouteAtom } from "jarl-atoms"; +import { Link, Route, Switch, useNavigate, useRoute } from "jarl-react"; +import { Provider, createStore } from "jotai"; +import { createRoot } from "react-dom/client"; + +const aboutRoute = staticRouteAtom("about"); +const itemsRoute = staticRouteAtom("items"); +const itemRoute = paramRouteAtom("itemId", { parent: itemsRoute }); + +const Item = () => { + const route = useRoute(itemRoute); + const goAbout = useNavigate(aboutRoute); + return ; +}; + +const App = () => ( +
+ + Item 1 + + Not found}> + +

Home

+
+ +

About

+
+ + + +
+
+); + +createRoot(document.getElementById("root")!).render( + + + , +); diff --git a/bench/src/size/react-router-entry.tsx b/bench/src/size/react-router-entry.tsx new file mode 100644 index 0000000..bb0bfb8 --- /dev/null +++ b/bench/src/size/react-router-entry.tsx @@ -0,0 +1,31 @@ +// Bundle-size entry: the react-router equivalent of jarl-entry.tsx. +import { NavLink, Outlet, RouterProvider, createBrowserRouter, useNavigate, useParams } from "react-router"; +import { createRoot } from "react-dom/client"; + +const Item = () => { + const { itemId } = useParams<"itemId">(); + const navigate = useNavigate(); + return ; +}; + +const Layout = () => ( +
+ Item 1 + +
+); + +const router = createBrowserRouter([ + { + path: "/", + element: , + children: [ + { index: true, element:

Home

}, + { path: "about", element:

About

}, + { path: "items/:itemId", element: }, + { path: "*", element:

Not found

}, + ], + }, +]); + +createRoot(document.getElementById("root")!).render(); diff --git a/bench/src/stats.ts b/bench/src/stats.ts new file mode 100644 index 0000000..618aa2f --- /dev/null +++ b/bench/src/stats.ts @@ -0,0 +1,80 @@ +/** Summary of one timed measurement: quartiles over the retained (post-warm-up) samples. */ +export type Summary = { + samples: number; + median: number; + p25: number; + p75: number; + min: number; + max: number; +}; + +const quantile = (sorted: number[], q: number): number => { + const pos = (sorted.length - 1) * q; + const lower = Math.floor(pos); + const upper = Math.ceil(pos); + return sorted[lower] + (sorted[upper] - sorted[lower]) * (pos - lower); +}; + +export const summarise = (samples: number[]): Summary => { + const sorted = [...samples].sort((a, b) => a - b); + return { + samples: sorted.length, + median: quantile(sorted, 0.5), + p25: quantile(sorted, 0.25), + p75: quantile(sorted, 0.75), + min: sorted[0], + max: sorted[sorted.length - 1], + }; +}; + +/** + * Times `warmup + samples` samples of `iterations` calls, discards the warm-up + * ones, and summarises the rest in microseconds per call. Forces GC before each + * sample where `--expose-gc` allows it. + */ +export const measure = ( + work: () => void, + { samples = 30, warmup = 10, iterations = 1000 }: { samples?: number; warmup?: number; iterations?: number } = {}, +): Summary => { + const times: number[] = []; + for (let s = 0; s < warmup + samples; s++) { + globalThis.gc?.(); + const start = performance.now(); + for (let i = 0; i < iterations; i++) work(); + const elapsed = performance.now() - start; + if (s >= warmup) times.push((elapsed * 1000) / iterations); + } + return summarise(times); +}; + +/** Async variant of `measure`, for workloads whose API is promise-based. */ +export const measureAsync = async ( + work: () => Promise, + { samples = 30, warmup = 10, iterations = 1000 }: { samples?: number; warmup?: number; iterations?: number } = {}, +): Promise => { + const times: number[] = []; + for (let s = 0; s < warmup + samples; s++) { + globalThis.gc?.(); + const start = performance.now(); + for (let i = 0; i < iterations; i++) await work(); + const elapsed = performance.now() - start; + if (s >= warmup) times.push((elapsed * 1000) / iterations); + } + return summarise(times); +}; + +const fmt = (value: number) => (value >= 100 ? value.toFixed(0) : value >= 10 ? value.toFixed(1) : value.toFixed(2)); + +export const formatSummary = ({ samples, median, p25, p75, min, max }: Summary): string => + `median ${fmt(median)}µs p25 ${fmt(p25)}µs p75 ${fmt(p75)}µs min ${fmt(min)}µs max ${fmt(max)}µs (n=${samples})`; + +/** Prints rows as a column-aligned table, first column left-justified. */ +export const printTable = (title: string, header: string[], rows: (string | number)[][]) => { + const all = [header, ...rows.map((row) => row.map(String))]; + const widths = header.map((_, col) => Math.max(...all.map((row) => row[col].length))); + const line = (row: string[]) => + row.map((cell, col) => (col === 0 ? cell.padEnd(widths[col]) : cell.padStart(widths[col]))).join(" "); + console.log(`\n${title}`); + console.log(line(header)); + for (const row of all.slice(1)) console.log(line(row)); +}; diff --git a/bench/tsconfig.json b/bench/tsconfig.json new file mode 100644 index 0000000..e056dae --- /dev/null +++ b/bench/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../tsconfig.base.json", + "compilerOptions": { + "types": ["node", "vitest/globals"] + }, + "include": ["src", "vitest.config.ts", "vitest.bench.config.ts"] +} diff --git a/bench/vitest.bench.config.ts b/bench/vitest.bench.config.ts new file mode 100644 index 0000000..ce3094f --- /dev/null +++ b/bench/vitest.bench.config.ts @@ -0,0 +1,17 @@ +import react from "@vitejs/plugin-react"; +import { defineConfig } from "vitest/config"; + +// Timed benchmarks. Needs NODE_ENV=production (the `bench` script sets it) for the +// libraries' production builds, and a serial forked process with --expose-gc. +export default defineConfig({ + plugins: [react()], + test: { + globals: true, + include: ["src/**/*.benchmark.{ts,tsx}"], + fileParallelism: false, + pool: "forks", + maxWorkers: 1, + execArgv: ["--expose-gc"], + testTimeout: 300_000, + }, +}); diff --git a/bench/vitest.config.ts b/bench/vitest.config.ts new file mode 100644 index 0000000..59ffe89 --- /dev/null +++ b/bench/vitest.config.ts @@ -0,0 +1,13 @@ +import react from "@vitejs/plugin-react"; +import { defineConfig } from "vitest/config"; + +// Render-count comparison only, so it is deterministic under CI; the timed +// benchmarks are behind vitest.bench.config.ts and `npm run bench`. +export default defineConfig({ + plugins: [react()], + test: { + globals: true, + include: ["src/**/*.test.{ts,tsx}"], + fileParallelism: false, + }, +}); diff --git a/package-lock.json b/package-lock.json index 91f673e..b4ea558 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,7 +8,8 @@ "workspaces": [ "packages/jarl-atoms", "packages/jarl-react", - "packages/docs" + "packages/docs", + "bench" ], "devDependencies": { "@babel/plugin-syntax-jsx": "^8.0.1", @@ -52,6 +53,27 @@ "node": "^22.22.2 || ^24.15.0 || >=26.0.0" } }, + "bench": { + "name": "jarl-bench", + "version": "0.0.0", + "dependencies": { + "jarl-atoms": "2.6.0", + "jarl-react": "2.6.0", + "jotai": "^2.20.2", + "react": "^19.2.8", + "react-dom": "^19.2.8", + "react-router": "^8.3.0" + }, + "devDependencies": { + "@types/react": "^19.2.18", + "@types/react-dom": "^19.2.4", + "@vitejs/plugin-react": "^6.0.5", + "jsdom": "^30.0.1", + "rolldown": "^1.2.3", + "typescript": "^7.0.2", + "vitest": "^4.1.10" + } + }, "node_modules/@actions/core": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/@actions/core/-/core-3.0.1.tgz", @@ -5700,6 +5722,12 @@ "dev": true, "license": "MIT" }, + "node_modules/cookie-es": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/cookie-es/-/cookie-es-3.1.1.tgz", + "integrity": "sha512-UaXxwISYJPTr9hwQxMFYZ7kNhSXboMXP+Z3TRX6f1/NyaGPfuNUZOWP1pUEb75B2HjfklIYLVRfWiFZJyC6Npg==", + "license": "MIT" + }, "node_modules/core-js-compat": { "version": "3.50.0", "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.50.0.tgz", @@ -6993,6 +7021,10 @@ "resolved": "packages/jarl-atoms", "link": true }, + "node_modules/jarl-bench": { + "resolved": "bench", + "link": true + }, "node_modules/jarl-react": { "resolved": "packages/jarl-react", "link": true @@ -10397,6 +10429,27 @@ "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", "license": "MIT" }, + "node_modules/react-router": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-8.3.0.tgz", + "integrity": "sha512-qyPMvW83jGIct3yiieisxdk9M745anqhpIMKN5m1t6yBMfgVPpt77aHOqs5fUlEJRMCGffg9BaQLH9oPVOL7xQ==", + "license": "MIT", + "dependencies": { + "cookie-es": "^3.1.1" + }, + "engines": { + "node": ">=22.22.0" + }, + "peerDependencies": { + "react": ">=19.2.7", + "react-dom": ">=19.2.7" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, "node_modules/read-package-up": { "version": "11.0.0", "resolved": "https://registry.npmjs.org/read-package-up/-/read-package-up-11.0.0.tgz", diff --git a/package.json b/package.json index d7d90ec..766e884 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,8 @@ "workspaces": [ "packages/jarl-atoms", "packages/jarl-react", - "packages/docs" + "packages/docs", + "bench" ], "scripts": { "build": "npm run build --workspaces --if-present", @@ -12,6 +13,7 @@ "start": "npm run dev --workspace packages/docs", "dev": "npm run dev --workspace packages/docs", "test": "npm run test --workspaces --if-present", + "bench": "npm run build --workspace packages/jarl-atoms --workspace packages/jarl-react && npm run bench --workspace bench", "ci-test": "npm run ci-test --workspaces --if-present", "test:e2e:install": "npm --prefix e2e install && npx --prefix e2e playwright install --with-deps chromium", "test:e2e": "npm --prefix e2e run test", From 654d517e2aabb83e131eb032b7e0454a4c05465c Mon Sep 17 00:00:00 2001 From: Peter Hurst Date: Wed, 19 Aug 2026 02:16:29 +0100 Subject: [PATCH 2/5] feat(bench): decompose where matchRoutes' time goes A rank-0-hit workload over a growing table shows the public matchRoutes re-flattening and ranking the config on every call, while a data router ranks once at creation and navigates against the cached branches - which is why react-router navigates faster than it resolves. Ticket: 674 --- bench/src/matching.benchmark.ts | 34 +++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/bench/src/matching.benchmark.ts b/bench/src/matching.benchmark.ts index 1722a08..1d0fe98 100644 --- a/bench/src/matching.benchmark.ts +++ b/bench/src/matching.benchmark.ts @@ -51,6 +51,40 @@ test(`resolve: URL string in, matched leaf out (${SECTION_COUNT} sections × sta console.log(` react-router ${formatSummary(reactRouter)}`); }); +// Where matchRoutes' time goes: the URL always hits the first-ranked branch, so +// per-call match work is constant while the table grows. A data router instead +// ranks once at creation, so its per-navigation cost must not scale. +test("resolve cost decomposition: table size scaling at a fixed rank-0 hit", async () => { + const rows: string[] = []; + for (const size of [1, 10, SECTION_COUNT]) { + const config = routeConfig.slice(0, size); + const rr = measure(() => { + matchRoutes(config, "/s0/1"); + }); + + const store = createStore(); + const jarl = measure(() => { + resolveJarl(store, "/s0/1"); + }); + + const router = createMemoryRouter(config, { initialEntries: ["/s0/1"] }); + let k = 0; + const nav = await measureAsync(async () => { + k++; + await router.navigate(`/s0/${k}`); + }); + router.dispose(); + + rows.push( + ` ${String(size * 2).padStart(3)} routes: matchRoutes ${formatSummary(rr)}`, + ` jarl ${formatSummary(jarl)}`, + ` navigate ${formatSummary(nav)}`, + ); + } + console.log(`\nRank-0 hit, growing table:`); + for (const row of rows) console.log(row); +}); + test("navigate: one client-side navigation through each library's API", async () => { const store = createStore(); let n = 0; From 236920c1ccedb51b3de6f4bbed6d59006ddb9a43 Mon Sep 17 00:00:00 2001 From: Peter Hurst Date: Wed, 19 Aug 2026 02:16:29 +0100 Subject: [PATCH 3/5] feat(bench): five-level deep nesting scenario jarl's nested Switch/Route atoms against react-router's data router and declarative forms: render counts per level for leaf, mid and root param changes (byte-identical HTML asserted across all three), and a timed leaf toggle with React render and commit included. Ticket: 674 --- bench/src/apps/deep/JarlDeepApp.tsx | 83 ++++++++++++++ bench/src/apps/deep/ReactRouterDeepApps.tsx | 113 +++++++++++++++++++ bench/src/apps/deep/sharedDeepComponents.tsx | 27 +++++ bench/src/deepNavigation.benchmark.tsx | 83 ++++++++++++++ bench/src/deepRenders.test.tsx | 57 ++++++++++ bench/src/renderHarness.tsx | 71 ++++++++++++ bench/src/renders.test.tsx | 72 +----------- bench/src/shapeDeep.ts | 29 +++++ 8 files changed, 469 insertions(+), 66 deletions(-) create mode 100644 bench/src/apps/deep/JarlDeepApp.tsx create mode 100644 bench/src/apps/deep/ReactRouterDeepApps.tsx create mode 100644 bench/src/apps/deep/sharedDeepComponents.tsx create mode 100644 bench/src/deepNavigation.benchmark.tsx create mode 100644 bench/src/deepRenders.test.tsx create mode 100644 bench/src/renderHarness.tsx create mode 100644 bench/src/shapeDeep.ts diff --git a/bench/src/apps/deep/JarlDeepApp.tsx b/bench/src/apps/deep/JarlDeepApp.tsx new file mode 100644 index 0000000..355b801 --- /dev/null +++ b/bench/src/apps/deep/JarlDeepApp.tsx @@ -0,0 +1,83 @@ +import { DefaultParams, RouteAtom, paramRouteAtom, rootAtom, staticRouteAtom } from "jarl-atoms"; +import { Route, Switch, useAtom } from "jarl-react"; +import { Provider, createStore } from "jotai"; +import { ReactNode } from "react"; +import { countRender } from "../../renderCounter"; +import { DeepParams, deepLinks, depths } from "../../shapeDeep"; +import type { BenchApp } from "../types"; +import { DeepHome, DeepNotFound, Level } from "./sharedDeepComponents"; + +// One route atom per level, each chained on the one above: /d1/:p1/d2/:p2/… +export const levelRoutes = depths.reduce[]>((chain, depth) => { + const parent = chain.at(-1); + const section = staticRouteAtom(`d${depth}`, parent && { parent }); + return [...chain, paramRouteAtom(`p${depth}`, { parent: section })]; +}, []); + +const leafRoute = levelRoutes.at(-1)!; + +const NavItem = ({ to, label }: { to: DeepParams; label: string }) => { + countRender("nav link"); + const [state, setRoute] = useAtom(leafRoute); + // Route-level `active` narrowed to href-level, as in JarlApp. + const active = state.exact && depths.every((d) => (state.values as DeepParams)[`p${d}`] === to[`p${d}`]); + return ( + { + event.preventDefault(); + setRoute(to); + }} + > + {label} + + ); +}; + +const Nav = () => ( + +); + +// Innermost first: each level's element wraps the next in a single-route Switch. +const nested = depths.reduceRight( + (children, depth) => ( + + {(values) => ( + + {children && }>{children}} + + )} + + ), + null, +); + +const Shell = () => { + countRender("shell"); + return ( +
+
+ ); +}; + +export const createJarlDeepApp = (): BenchApp => ({ + element: ( + + + + ), +}); diff --git a/bench/src/apps/deep/ReactRouterDeepApps.tsx b/bench/src/apps/deep/ReactRouterDeepApps.tsx new file mode 100644 index 0000000..fa4fa12 --- /dev/null +++ b/bench/src/apps/deep/ReactRouterDeepApps.tsx @@ -0,0 +1,113 @@ +// Both react-router forms of the deep app: the data router (route config, +// branches ranked once at creation) and the declarative form (route +// tree rebuilt from JSX children on every render). +import { ReactNode } from "react"; +import { + BrowserRouter, + Outlet, + Route, + RouterProvider, + Routes, + createBrowserRouter, + useHref, + useLinkClickHandler, + useMatch, + useParams, +} from "react-router"; +import { countRender } from "../../renderCounter"; +import { deepLinks, depths } from "../../shapeDeep"; +import type { BenchApp } from "../types"; +import { DeepHome, DeepNotFound, Level } from "./sharedDeepComponents"; + +const NavItem = ({ href, label }: { href: string; label: string }) => { + countRender("nav link"); + const active = useMatch(href) != null; + const linkHref = useHref(href); + const onClick = useLinkClickHandler(href); + return ( + + {label} + + ); +}; + +const Nav = () => ( + +); + +const Shell = () => { + countRender("shell"); + return ( +
+
+ ); +}; + +const RoutedLevel = ({ depth }: { depth: number }) => { + const params = useParams(); + return ( + + + + ); +}; + +// Innermost first, mirroring the jarl app's nesting. +const routeConfig = [ + { + path: "/", + element: , + children: [ + { index: true, element: }, + depths.reduceRight( + (child, depth) => ({ + path: `d${depth}/:p${depth}`, + element: , + children: child ? [child] : undefined, + }), + undefined as object | undefined, + )!, + { path: "*", element: }, + ], + }, +]; + +export const createReactRouterDeepApp = (): BenchApp => { + const router = createBrowserRouter(routeConfig as Parameters[0]); + return { + element: , + dispose: () => router.dispose(), + }; +}; + +// The same routes as JSX elements under a plain . +const declarativeNested = depths.reduceRight( + (child, depth) => ( + }> + {child} + + ), + null, +); + +export const createReactRouterDeclarativeDeepApp = (): BenchApp => ({ + element: ( + + + }> + } /> + {declarativeNested} + } /> + + + + ), +}); diff --git a/bench/src/apps/deep/sharedDeepComponents.tsx b/bench/src/apps/deep/sharedDeepComponents.tsx new file mode 100644 index 0000000..5a70553 --- /dev/null +++ b/bench/src/apps/deep/sharedDeepComponents.tsx @@ -0,0 +1,27 @@ +// Shared verbatim by all three deep apps, so any difference comes from the router alone. +import { ReactNode } from "react"; +import { countRender } from "../../renderCounter"; + +/** Reads nothing from any router: should never re-render on navigation. */ +const LevelStatic = ({ depth }: { depth: number }) => { + countRender("per-level static"); + return static {depth}; +}; + +export const Level = ({ depth, value, children }: { depth: number; value: string; children?: ReactNode }) => { + countRender(`level ${depth} layout`); + return ( +
+

{`L${depth}:${value}`}

+ + {children} +
+ ); +}; + +export const DeepHome = () => { + countRender("home page"); + return

Home

; +}; + +export const DeepNotFound = () =>

Not found

; diff --git a/bench/src/deepNavigation.benchmark.tsx b/bench/src/deepNavigation.benchmark.tsx new file mode 100644 index 0000000..a5d7e67 --- /dev/null +++ b/bench/src/deepNavigation.benchmark.tsx @@ -0,0 +1,83 @@ +// @vitest-environment jsdom + +// Wall-clock cost of one deep navigation with React included: click a link, +// re-render and commit the five-level tree. Same three apps as +// deepRenders.test.tsx, which also asserts their HTML is identical. React's +// production build has no `act`, so each click is wrapped in `flushSync`, +// which commits the resulting render before returning; passive effects +// (jotai subscribes in one) need a macrotask turn, granted between samples. +import { createRoot } from "react-dom/client"; +import { flushSync } from "react-dom"; +import { createJarlDeepApp } from "./apps/deep/JarlDeepApp"; +import { createReactRouterDeclarativeDeepApp, createReactRouterDeepApp } from "./apps/deep/ReactRouterDeepApps"; +import type { BenchApp } from "./apps/types"; +import { deepPath } from "./shapeDeep"; +import { Summary, formatSummary, summarise } from "./stats"; + +const hrefA = deepPath({ p5: "a" }); +const hrefB = deepPath({ p5: "b" }); + +const settle = () => new Promise((resolve) => setTimeout(resolve)); + +/** Passive effects (jotai subscribes in one) may need several macrotask turns under load. */ +const settleUntil = async (committed: () => boolean) => { + const deadline = performance.now() + 2000; + while (!committed()) { + if (performance.now() > deadline) throw new Error("navigation did not commit"); + await settle(); + } +}; + +const timeApp = async (create: () => BenchApp): Promise => { + window.history.replaceState(null, "", "/"); + const container = document.createElement("div"); + document.body.appendChild(container); + const { element, dispose } = create(); + const root = createRoot(container); + flushSync(() => root.render(element)); + await settle(); + + const anchorFor = (href: string) => { + const anchor = container.querySelector(`a[href="${href}"]`); + if (!anchor) throw new Error(`no link with href ${href}`); + return anchor; + }; + flushSync(() => anchorFor(hrefA).click()); + await settleUntil(() => container.innerHTML.includes("L5:a")); + + const anchors = [anchorFor(hrefB), anchorFor(hrefA)]; + const samples = 20; + const warmup = 5; + const iterations = 200; + let flip = 0; + const times: number[] = []; + for (let s = 0; s < warmup + samples; s++) { + await settle(); + globalThis.gc?.(); + const start = performance.now(); + for (let i = 0; i < iterations; i++) { + flushSync(() => anchors[flip++ % 2].click()); + } + const elapsed = performance.now() - start; + // The last click was to B when `flip` is odd: proves every click really navigated. + const expected = flip % 2 === 1 ? "L5:b" : "L5:a"; + if (!container.innerHTML.includes(expected)) throw new Error(`expected ${expected} after sample`); + if (s >= warmup) times.push((elapsed * 1000) / iterations); + } + + flushSync(() => root.unmount()); + dispose?.(); + container.remove(); + return summarise(times); +}; + +test("deep navigation, React render and commit included (leaf param toggle)", async () => { + const jarl = await timeApp(createJarlDeepApp); + const dataRouter = await timeApp(createReactRouterDeepApp); + const declarative = await timeApp(createReactRouterDeclarativeDeepApp); + + console.log("\nDeep navigation, per click (5 levels, React included):"); + console.log(` jarl ${formatSummary(jarl)}`); + console.log(` react-router (data) ${formatSummary(dataRouter)}`); + console.log(` react-router ${formatSummary(declarative)}`); +}); diff --git a/bench/src/deepRenders.test.tsx b/bench/src/deepRenders.test.tsx new file mode 100644 index 0000000..6c60f8a --- /dev/null +++ b/bench/src/deepRenders.test.tsx @@ -0,0 +1,57 @@ +// @vitest-environment jsdom + +// Five-level nested routing: jarl's Switch/Route atoms against both +// react-router forms. Deterministic render counts, no timing. +import { createJarlDeepApp } from "./apps/deep/JarlDeepApp"; +import { createReactRouterDeclarativeDeepApp, createReactRouterDeepApp } from "./apps/deep/ReactRouterDeepApps"; +import { Scenario, assertHtmlParity, comparisonRows, runApp } from "./renderHarness"; +import { deepPath } from "./shapeDeep"; +import { printTable } from "./stats"; + +const toggle = (overridesA: Record, overridesB: Record) => + Array.from({ length: 10 }, (_, i) => deepPath(i % 2 === 0 ? overridesA : overridesB)); + +const scenarios: Scenario[] = [ + { + name: "Leaf param toggle: only :p5 changes (10 navigations)", + setup: [deepPath({ p5: "a" })], + navigations: toggle({ p5: "b" }, { p5: "a" }), + }, + { + name: "Mid param toggle: only :p3 changes (10 navigations)", + setup: [deepPath({ p3: "a" })], + navigations: toggle({ p3: "b" }, { p3: "a" }), + }, + { + name: "Root param toggle: only :p1 changes (10 navigations)", + setup: [deepPath({ p1: "a" })], + navigations: toggle({ p1: "b" }, { p1: "a" }), + }, +]; + +test("deep nesting: re-render counts per navigation, with identical rendered HTML throughout", async () => { + const jarl = await runApp(createJarlDeepApp, scenarios); + const dataRouter = await runApp(createReactRouterDeepApp, scenarios); + const declarative = await runApp(createReactRouterDeclarativeDeepApp, scenarios); + + assertHtmlParity([jarl, dataRouter, declarative]); + expect(jarl.html.at(-1)).toContain("L5:x"); + + const header = ["component group", "jarl", "rr data router", "rr "]; + printTable( + "Initial mount at /", + header, + comparisonRows([jarl.mountCounts, dataRouter.mountCounts, declarative.mountCounts]), + ); + for (const { name } of scenarios) { + printTable( + name, + header, + comparisonRows([ + jarl.scenarioCounts.get(name)!, + dataRouter.scenarioCounts.get(name)!, + declarative.scenarioCounts.get(name)!, + ]), + ); + } +}); diff --git a/bench/src/renderHarness.tsx b/bench/src/renderHarness.tsx new file mode 100644 index 0000000..94cfdbd --- /dev/null +++ b/bench/src/renderHarness.tsx @@ -0,0 +1,71 @@ +// Shared jsdom render-count harness: mounts an app, navigates it by clicking +// its own links, and tallies renders per component group and scenario. +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import type { BenchApp } from "./apps/types"; +import { diffCounts, resetCounts, snapshotCounts } from "./renderCounter"; + +(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +export type Scenario = { + name: string; + /** Navigations that put the app in the scenario's starting state, excluded from its counts. */ + setup: string[]; + navigations: string[]; +}; + +export type AppRun = { + mountCounts: Map; + scenarioCounts: Map>; + /** innerHTML after mount and after every navigation, in order, for cross-app parity checks. */ + html: string[]; +}; + +export const runApp = async (create: () => BenchApp, scenarios: Scenario[]): Promise => { + window.history.replaceState(null, "", "/"); + resetCounts(); + const container = document.createElement("div"); + document.body.appendChild(container); + const { element, dispose } = create(); + const root = createRoot(container); + + const html: string[] = []; + const clickHref = async (href: string) => { + const anchor = container.querySelector(`a[href="${href}"]`); + if (!anchor) throw new Error(`no link with href ${href}`); + await act(async () => anchor.click()); + html.push(container.innerHTML); + }; + + const before = snapshotCounts(); + await act(async () => root.render(element)); + html.push(container.innerHTML); + const mountCounts = diffCounts(before); + + const scenarioCounts = new Map>(); + for (const scenario of scenarios) { + for (const href of scenario.setup) await clickHref(href); + const beforeScenario = snapshotCounts(); + for (const href of scenario.navigations) await clickHref(href); + scenarioCounts.set(scenario.name, diffCounts(beforeScenario)); + } + + await act(async () => root.unmount()); + dispose?.(); + container.remove(); + return { mountCounts, scenarioCounts, html }; +}; + +/** One row per component group: the group name, then each run's count in order. */ +export const comparisonRows = (runs: Map[]): (string | number)[][] => { + const groups = [...new Set(runs.flatMap((run) => [...run.keys()]))].sort(); + return groups.map((group) => [group, ...runs.map((run) => run.get(group) ?? 0)]); +}; + +export const assertHtmlParity = (runs: AppRun[]) => { + const [first, ...rest] = runs; + for (const run of rest) { + expect(run.html.length).toBe(first.html.length); + first.html.forEach((markup, step) => expect(run.html[step]).toBe(markup)); + } +}; diff --git a/bench/src/renders.test.tsx b/bench/src/renders.test.tsx index c0456c6..7f3b6c1 100644 --- a/bench/src/renders.test.tsx +++ b/bench/src/renders.test.tsx @@ -2,24 +2,12 @@ // React's development build is fine here: without StrictMode it renders each // component once per update, the same as production, and nothing is timed. -import { act } from "react"; -import { createRoot } from "react-dom/client"; import { createJarlApp } from "./apps/JarlApp"; import { createReactRouterApp } from "./apps/ReactRouterApp"; -import type { BenchApp } from "./apps/types"; -import { diffCounts, resetCounts, snapshotCounts } from "./renderCounter"; +import { Scenario, assertHtmlParity, comparisonRows, runApp } from "./renderHarness"; import { ITEM_COUNT, itemIds } from "./shape"; import { printTable } from "./stats"; -(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; - -type Scenario = { - name: string; - /** Navigations that put the app in the scenario's starting state, excluded from its counts. */ - setup: string[]; - navigations: string[]; -}; - const scenarios: Scenario[] = [ { name: `Param-only navigation: /items/1 → … → /items/${ITEM_COUNT} (${ITEM_COUNT - 1} navigations)`, @@ -38,64 +26,16 @@ const scenarios: Scenario[] = [ }, ]; -type AppRun = { - mountCounts: Map; - scenarioCounts: Map>; - /** innerHTML after mount and after every navigation, in order, for cross-app parity checks. */ - html: string[]; -}; - -const runApp = async (create: () => BenchApp): Promise => { - window.history.replaceState(null, "", "/"); - resetCounts(); - const container = document.createElement("div"); - document.body.appendChild(container); - const { element, dispose } = create(); - const root = createRoot(container); - - const html: string[] = []; - const clickHref = async (href: string) => { - const anchor = container.querySelector(`a[href="${href}"]`); - if (!anchor) throw new Error(`no link with href ${href}`); - await act(async () => anchor.click()); - html.push(container.innerHTML); - }; - - const before = snapshotCounts(); - await act(async () => root.render(element)); - html.push(container.innerHTML); - const mountCounts = diffCounts(before); - - const scenarioCounts = new Map>(); - for (const scenario of scenarios) { - for (const href of scenario.setup) await clickHref(href); - const beforeScenario = snapshotCounts(); - for (const href of scenario.navigations) await clickHref(href); - scenarioCounts.set(scenario.name, diffCounts(beforeScenario)); - } - - await act(async () => root.unmount()); - dispose?.(); - container.remove(); - return { mountCounts, scenarioCounts, html }; -}; - -const comparisonRows = (jarl: Map, reactRouter: Map) => { - const groups = [...new Set([...jarl.keys(), ...reactRouter.keys()])].sort(); - return groups.map((group) => [group, jarl.get(group) ?? 0, reactRouter.get(group) ?? 0]); -}; - test("re-render counts per navigation, with identical rendered HTML throughout", async () => { - const jarl = await runApp(createJarlApp); - const reactRouter = await runApp(createReactRouterApp); + const jarl = await runApp(createJarlApp, scenarios); + const reactRouter = await runApp(createReactRouterApp, scenarios); - expect(jarl.html.length).toBe(reactRouter.html.length); - jarl.html.forEach((markup, step) => expect(markup).toBe(reactRouter.html[step])); + assertHtmlParity([jarl, reactRouter]); expect(jarl.html.at(-1)).toContain("

About

"); const header = ["component group", "jarl renders", "react-router renders"]; - printTable("Initial mount at /", header, comparisonRows(jarl.mountCounts, reactRouter.mountCounts)); + printTable("Initial mount at /", header, comparisonRows([jarl.mountCounts, reactRouter.mountCounts])); for (const { name } of scenarios) { - printTable(name, header, comparisonRows(jarl.scenarioCounts.get(name)!, reactRouter.scenarioCounts.get(name)!)); + printTable(name, header, comparisonRows([jarl.scenarioCounts.get(name)!, reactRouter.scenarioCounts.get(name)!])); } }); diff --git a/bench/src/shapeDeep.ts b/bench/src/shapeDeep.ts new file mode 100644 index 0000000..d3b69af --- /dev/null +++ b/bench/src/shapeDeep.ts @@ -0,0 +1,29 @@ +// The deep app shape: five nested levels, each a static segment plus a param +// (/d1/:p1/d2/:p2/…), so one URL exercises the full chain. + +export const DEPTH = 5; + +export const depths = Array.from({ length: DEPTH }, (_, i) => i + 1); + +export type DeepParams = Record; + +type DeepOverrides = Partial; + +/** Params for one deep URL: every level at "x" except the overrides given. */ +export const deepParams = (overrides: DeepOverrides = {}): DeepParams => + Object.fromEntries(depths.map((d) => [`p${d}`, overrides[`p${d}`] ?? "x"])); + +export const deepPath = (overrides: DeepOverrides = {}): string => { + const params = deepParams(overrides); + return depths.map((d) => `/d${d}/${params[`p${d}`]}`).join(""); +}; + +/** The six nav links: an A/B pair toggling one level's param at leaf, mid and root. */ +export const deepLinks = [ + { label: "Leaf A", overrides: { p5: "a" } }, + { label: "Leaf B", overrides: { p5: "b" } }, + { label: "Mid A", overrides: { p3: "a" } }, + { label: "Mid B", overrides: { p3: "b" } }, + { label: "Root A", overrides: { p1: "a" } }, + { label: "Root B", overrides: { p1: "b" } }, +].map(({ label, overrides }) => ({ label, href: deepPath(overrides), params: deepParams(overrides) })); From 46383721ea8bab1b0b31f660e22c3504e5da317a Mon Sep 17 00:00:00 2001 From: Peter Hurst Date: Wed, 19 Aug 2026 02:16:29 +0100 Subject: [PATCH 4/5] feat(bench): nested async data scenario Three-level chain, one 25ms lookup per level, navigation to deepest data on screen: jarl's followAsyncRoutes parallel pre-resolution vs react-router loaders vs a per-component Suspense cascade. Ticket: 674 --- bench/src/asyncData.benchmark.tsx | 219 ++++++++++++++++++++++++++++++ bench/src/stats.ts | 4 +- 2 files changed, 221 insertions(+), 2 deletions(-) create mode 100644 bench/src/asyncData.benchmark.tsx diff --git a/bench/src/asyncData.benchmark.tsx b/bench/src/asyncData.benchmark.tsx new file mode 100644 index 0000000..918ecdc --- /dev/null +++ b/bench/src/asyncData.benchmark.tsx @@ -0,0 +1,219 @@ +// @vitest-environment jsdom + +// Async data down a three-level route chain (/a/:pa/b/:pb/c/:pc), each level +// needing one 25ms lookup. Measures wall-clock time from navigation to the +// deepest level's data being in the DOM, with fresh param values per run so +// no cache is ever warm: +// - jarl: asyncRouteAtom per level + followAsyncRoutes, which starts every +// lookup on the location change - the levels load in parallel. +// - react-router loaders: its own parallel mechanism, awaited by +// router.navigate before the new tree commits. +// - react-router Suspense cascade: each level's component use()s its own +// fetch, so a level's lookup cannot start until its parent has rendered. +import { Suspense, use } from "react"; +import { createRoot } from "react-dom/client"; +import { flushSync } from "react-dom"; +import { asyncRouteAtom, followAsyncRoutes, locationAtom, paramRouteAtom, staticRouteAtom } from "jarl-atoms"; +import { Route, Switch } from "jarl-react"; +import { Provider, createStore } from "jotai"; +import { Outlet, RouterProvider, createBrowserRouter, useLoaderData, useParams } from "react-router"; +import { Summary, formatSummary, summarise } from "./stats"; + +const DELAY_MS = 25; +const SAMPLES = 25; +const WARMUP = 5; + +const fetchData = (key: string): Promise => + new Promise((resolve) => setTimeout(() => resolve(`data(${key})`), DELAY_MS)); + +const pathFor = (run: number) => `/a/u${run}/b/v${run}/c/w${run}`; +const leafMarker = (run: number) => `C:data(c:w${run})`; + +type Harness = { + element: React.ReactElement; + navigate: (path: string) => void; + dispose?: () => void; +}; + +const timeApp = async (create: () => Harness): Promise => { + window.history.replaceState(null, "", "/"); + const container = document.createElement("div"); + document.body.appendChild(container); + const { element, navigate, dispose } = create(); + const root = createRoot(container); + flushSync(() => root.render(element)); + + const tick = () => new Promise((resolve) => setTimeout(resolve, 1)); + const times: number[] = []; + for (let run = 0; run < WARMUP + SAMPLES; run++) { + const start = performance.now(); + navigate(pathFor(run)); + const deadline = start + 50 * DELAY_MS; + while (!container.innerHTML.includes(leafMarker(run))) { + if (performance.now() > deadline) throw new Error(`no ${leafMarker(run)} within deadline`); + await tick(); + } + if (run >= WARMUP) times.push(performance.now() - start); + } + + flushSync(() => root.unmount()); + dispose?.(); + container.remove(); + return summarise(times); +}; + +const createJarlAsyncApp = (): Harness => { + // Param routes chain on each other, never on the async atoms, so every + // level's lookup depends only on the URL and all three start together. + const aRoute = paramRouteAtom("pa", { parent: staticRouteAtom("a") }); + const bRoute = paramRouteAtom("pb", { parent: staticRouteAtom("b", { parent: aRoute }) }); + const cRoute = paramRouteAtom("pc", { parent: staticRouteAtom("c", { parent: bRoute }) }); + const aAsync = asyncRouteAtom(aRoute, "dataA", ({ pa }) => fetchData(`a:${pa}`)); + const bAsync = asyncRouteAtom(bRoute, "dataB", ({ pb }) => fetchData(`b:${pb}`)); + const cAsync = asyncRouteAtom(cRoute, "dataC", ({ pc }) => fetchData(`c:${pc}`)); + + const store = createStore(); + const unfollow = followAsyncRoutes(store, [aAsync, bAsync, cAsync]); + return { + element: ( + + Loading

}> + + {({ dataA }) => ( +
+

A:{dataA}

+ Loading

}> + + {({ dataB }) => ( +
+

B:{dataB}

+ Loading

}> + + {({ dataC }) =>

C:{dataC}

} +
+
+
+ )} +
+
+
+ )} +
+
+
+ ), + navigate: (path) => store.set(locationAtom, { pathname: path, searchParams: new URLSearchParams() }), + dispose: unfollow, + }; +}; + +const createLoaderApp = (): Harness => { + const router = createBrowserRouter([ + { + path: "a/:pa", + loader: ({ params }) => fetchData(`a:${params.pa}`), + Component: () => ( +
+

A:{useLoaderData()}

+ +
+ ), + children: [ + { + path: "b/:pb", + loader: ({ params }) => fetchData(`b:${params.pb}`), + Component: () => ( +
+

B:{useLoaderData()}

+ +
+ ), + children: [ + { + path: "c/:pc", + loader: ({ params }) => fetchData(`c:${params.pc}`), + Component: () =>

C:{useLoaderData()}

, + }, + ], + }, + ], + }, + ]); + return { + element: , + navigate: (path) => void router.navigate(path), + dispose: () => router.dispose(), + }; +}; + +const createCascadeApp = (): Harness => { + // One promise per key, held across renders so use() can settle. + const cache = new Map>(); + const cachedFetch = (key: string): Promise => { + let promise = cache.get(key); + if (!promise) { + promise = fetchData(key); + cache.set(key, promise); + } + return promise; + }; + + const CascadeLevel = ({ level, children }: { level: "a" | "b" | "c"; children?: React.ReactNode }) => { + const params = useParams(); + const data = use(cachedFetch(`${level}:${params[`p${level}`]}`)); + return children ? ( +
+

+ {level.toUpperCase()}:{data} +

+ {children} +
+ ) : ( +

+ {level.toUpperCase()}:{data} +

+ ); + }; + + const router = createBrowserRouter([ + { + path: "a/:pa", + element: ( + + + + ), + children: [ + { + path: "b/:pb", + element: ( + + + + ), + children: [{ path: "c/:pc", element: }], + }, + ], + }, + ]); + return { + element: ( + Loading

}> + +
+ ), + navigate: (path) => void router.navigate(path), + dispose: () => router.dispose(), + }; +}; + +test("nested async data: parallel pre-resolution vs loaders vs a Suspense cascade", async () => { + const jarl = await timeApp(createJarlAsyncApp); + const loaders = await timeApp(createLoaderApp); + const cascade = await timeApp(createCascadeApp); + + console.log(`\nNested async data, 3 levels × ${DELAY_MS}ms lookup, navigation → deepest data on screen (ms):`); + console.log(` jarl (followAsyncRoutes) ${formatSummary(jarl, "ms")}`); + console.log(` react-router (loaders) ${formatSummary(loaders, "ms")}`); + console.log(` react-router (Suspense cascade) ${formatSummary(cascade, "ms")}`); +}); diff --git a/bench/src/stats.ts b/bench/src/stats.ts index 618aa2f..c42cbbc 100644 --- a/bench/src/stats.ts +++ b/bench/src/stats.ts @@ -65,8 +65,8 @@ export const measureAsync = async ( const fmt = (value: number) => (value >= 100 ? value.toFixed(0) : value >= 10 ? value.toFixed(1) : value.toFixed(2)); -export const formatSummary = ({ samples, median, p25, p75, min, max }: Summary): string => - `median ${fmt(median)}µs p25 ${fmt(p25)}µs p75 ${fmt(p75)}µs min ${fmt(min)}µs max ${fmt(max)}µs (n=${samples})`; +export const formatSummary = ({ samples, median, p25, p75, min, max }: Summary, unit = "µs"): string => + `median ${fmt(median)}${unit} p25 ${fmt(p25)}${unit} p75 ${fmt(p75)}${unit} min ${fmt(min)}${unit} max ${fmt(max)}${unit} (n=${samples})`; /** Prints rows as a column-aligned table, first column left-justified. */ export const printTable = (title: string, header: string[], rows: (string | number)[][]) => { From 72e7e323d9e6193ebfe172503fcd3537f93f24ac Mon Sep 17 00:00:00 2001 From: Peter Hurst Date: Tue, 18 Aug 2026 05:09:30 +0100 Subject: [PATCH 5/5] docs: publish benchmark results and point the README claim at them Adds the Benchmarks guide (results, methodology, honest caveats: re-render ties, the mount double-render, and react-router's faster stateful navigate) and replaces the README's unmeasured "incredibly efficient" line with a link to the measured comparison. Ticket: 674 --- README.md | 8 +- bench/README.md | 42 +++- .../docs/src/content/guides/Benchmarks.md | 182 ++++++++++++++++++ packages/docs/src/pages/Docs.tsx | 2 + packages/docs/src/router/routes.ts | 3 +- 5 files changed, 227 insertions(+), 10 deletions(-) create mode 100644 packages/docs/src/content/guides/Benchmarks.md diff --git a/README.md b/README.md index f9a27d7..790a7ab 100644 --- a/README.md +++ b/README.md @@ -27,9 +27,11 @@ the ubiquitous [``](/api/jarl-react#link) are of course provided in the Re you want to build more compositionally; they all just accept atoms for parameters instead of type-unsafe strings.) -Because each route atom is an independent, subscribable unit of jotai state, a component that -reads one only re-renders when *that atom's* derived value actually changes - it turns out this -is incredibly efficient. +Because each route atom is an independent, subscribable unit of jotai state, only components +that actually read route state are re-rendered by navigation, resolving a URL against the whole +route table is fast, and the bundle stays small. Those claims are +[measured against react-router](/docs/benchmarks) rather than asserted - including the workloads +where the two routers tie, and the ones where react-router is quicker. ## Features diff --git a/bench/README.md b/bench/README.md index e855efa..b96d599 100644 --- a/bench/README.md +++ b/bench/README.md @@ -56,10 +56,41 @@ outcome rather than mechanics: (awaited — its API is promise-based). Not equivalent work: `router.navigate()` runs react-router's full data-router state machine, where the atom write only re-derives state. jarl is slower here regardless, so the gap this understates is jarl's own. +- **resolve cost decomposition** — why react-router can navigate faster than it resolves: the + public `matchRoutes()` flattens and ranks the whole config on *every call*, where a data router + does that once at creation and each navigation matches against the cached ranking + (`precomputedBranches` in react-router's `router.ts`). The workload holds the matched URL at the + first-ranked branch while the table grows, so per-call match work is constant: `matchRoutes` + scales linearly with table size (it is dominated by per-call table preparation), while + `router.navigate` and jarl stay near-flat. Navigation does still resolve the route — it just + never re-pays the preparation the stateless number includes. Each number is 30 retained samples of 1000 operations, after 10 discarded warm-up samples, with GC forced between samples; reported as median with p25/p75 and min/max. +### Deep nesting (`src/deepRenders.test.tsx`, `src/deepNavigation.benchmark.tsx`) + +Five nested levels (`/d1/:p1/d2/:p2/…/d5/:p5`), each level a layout that renders its own param +and a static child, against three implementations: jarl's nested `Switch`/`Route` atoms, +react-router's data router (route config), and react-router's declarative `` component +form. `deepRenders.test.tsx` counts renders per level for a leaf-only, mid-level and root-level +param change, with the same byte-identical-HTML assertion across all three apps. +`deepNavigation.benchmark.tsx` times the same leaf toggle with React included — click to +committed DOM, via `flushSync` — since render counts alone can't rank routers that re-render the +same components at different per-render cost. + +### Nested async data (`src/asyncData.benchmark.tsx`) + +A three-level route chain where every level needs one 25ms async lookup, measured from +navigation to the deepest level's data being in the DOM, with fresh param values per run so no +cache is ever warm. Three loading strategies: jarl's `asyncRouteAtom` + `followAsyncRoutes` +(every lookup starts on the location change, in parallel — the param routes chain on each other, +not on the async atoms, so no lookup waits for another's data), react-router loaders (its own +parallel mechanism), and a react-router Suspense cascade (each level's component `use()`s its own +fetch, so a level's lookup cannot start until its parent has rendered). The cascade is what +fetch-on-render components give you, not a limitation of react-router — loaders exist precisely +to avoid it and are included as the fair comparison. + ### Bundle size (`src/bundle-size.benchmark.ts`) The two entries in `src/size/` implement the same minimal app using each router's typical surface. @@ -70,9 +101,8 @@ bundled) and with jotai external, for apps already using jotai. ## What the numbers do not show -- No real-browser timings: navigation cost here is library work only, not layout/paint, and jsdom - is used solely for deterministic render counting. -- No data APIs: react-router's loaders/actions/lazy-route machinery and jarl's async route atoms - are unexercised, though the react-router bundle necessarily carries that code. -- One app shape and one route-table shape; different shapes (deep nesting, splats, query-heavy - routing) may rank differently. +- No real-browser timings: no layout, paint or input latency. jsdom timings cover library and + React render/commit work only. +- react-router's actions and lazy-route machinery are unexercised. +- Two app shapes and two route-table shapes; others (splats, query-heavy routing) may rank + differently. diff --git a/packages/docs/src/content/guides/Benchmarks.md b/packages/docs/src/content/guides/Benchmarks.md new file mode 100644 index 0000000..8fe591c --- /dev/null +++ b/packages/docs/src/content/guides/Benchmarks.md @@ -0,0 +1,182 @@ +# Benchmarks + +The README claims routing with subscribable atoms is efficient; this page holds the actual +measurements behind that claim, compared against react-router — including the places where JARL +ties or loses. The full harness is checked into the repo under +[`bench/`](https://github.com/randomdevpete/jarl/tree/master/bench) and reproducible with a +single command from the repo root: + +```bash +npm run bench +``` + +## Setup + +| | | +| ----------------- | ------------------------------------------------------------------------------------------------------------------ | +| jarl | `jarl-atoms` 2.6.0 + `jarl-react` 2.6.0 (jotai 2.20.2, jotai-location 0.6.2) | +| react-router | 8.3.0 — data router (`createBrowserRouter`); the deep-nesting scenario also covers the declarative `` form | +| react / react-dom | 19.2.8 (identical for both) | +| environment | Node 24.15.0, Intel i7-1165G7, Linux (WSL2) | + +Two app shapes are driven by every router under comparison: a flat app (a layout with 13 +active-styled nav links, 10 components that read no route state, and four routed pages) and a +five-level nested app. The harness asserts all implementations of a shape produce +**byte-identical HTML** after every navigation, so every number below compares the same rendered +output. Timed results are medians with quartiles over repeated samples, GC forced between +samples and warm-up samples discarded; render counts are deterministic and need no sampling. +The timed numbers below are one full run of the suite; the orderings and ratios were stable +across five repeat runs under varying background load, absolute medians within about ±20%. + +## Re-renders per navigation (flat app) + +Renders per component group, navigating by clicking links (jsdom, counts identical across runs): + +| component group | jarl | react-router | +| ------------------------------------------- | --------------------- | ------------- | +| nav links (13), per navigation | 13 | 13 | +| changed page, per navigation | 1 | 1 | +| layout, per navigation | 0 | 0 | +| non-routing components (10), per navigation | 0 | 0 | +| everything above, at initial mount | nav links ×2, rest ×1 | everything ×1 | + +**This is a tie, not a win.** Components that read no route state are never re-rendered by +navigation in either router; react-router's context-based model is more precise here than it is +usually given credit for. Every active-styled link re-renders on every navigation in both +routers — each subscribes to location state to know whether it is active — including when +re-clicking the already-active link. JARL's one measured deficit: each route-atom subscriber +renders twice at initial mount, once for the tree and once when `atomWithLocation` first syncs. + +Two caveats the harness surfaced: + +- A route atom's value is a fresh object on every location change, so _every_ subscriber of _any_ + route atom re-renders on _any_ navigation — atom-level subscription narrows _which components + subscribe_, it does not currently skip unaffected routes. +- JARL's `active` flag is route-level: two links to the same route atom with different param + values both report active. The harness narrows it to href-level by comparing param values, to + match react-router's semantics. + +## Matching and navigation throughput + +Pure library cost, React excluded, over a 100-route table (50 static sections each with a +`:id` param child), per operation: + +| workload | jarl | react-router | +| ------------------------------------------ | ------------------------------ | --------------------------- | +| resolve URL → matched leaf (warm) | **120 µs** (p25 110 / p75 131) | 407 µs (p25 380 / p75 471) | +| resolve, cold store per URL (as SSR would) | **164 µs** (p25 143 / p75 180) | 407 µs (stateless) | +| client navigation via each API | 138 µs (p25 123 / p75 160) | **79 µs** (p25 72 / p75 96) | + +### Why is react-router's navigation faster than its resolution? + +Navigating does resolve the route — these two rows are not contradictory, they price different +work. The public `matchRoutes()` flattens and ranks the whole route config on **every call**. A +data router does that **once at creation**, keeps the ranked branches, and each +`router.navigate()` matches against them (`precomputedBranches` in react-router's `router.ts`). +So the resolve row is what an ad-hoc `matchRoutes` caller pays per call, table preparation +included; the navigate row is what a mounted client app pays per navigation, where that +preparation is already amortised to zero. + +The benchmark demonstrates this directly by holding the matched URL at the **first-ranked +branch** while the table grows, so per-call match work is constant and any growth is per-call +preparation: + +| routes in table | `matchRoutes` | `router.navigate` | jarl (first leaf read) | +| --------------- | ------------- | ----------------- | ---------------------- | +| 2 | 17 µs | 61 µs | 12 µs | +| 20 | 112 µs | 79 µs | 18 µs | +| 100 | 421 µs | 73 µs | 18 µs | + +`matchRoutes` scales linearly with table size — its per-call cost is dominated by preparing the +table, not matching against it. `router.navigate` is near-flat: warm matching is a few µs and +the rest is its state-machine and subscriber work. jarl has no preparation step to amortise — +route atoms are their own index, so a cold resolve pays only a fresh jotai store, never a table +prep — which is why it wins the resolve row outright while losing the navigate row to a router +that has already paid resolution's expensive half up front. + +jarl's leaf reads also stop at the first match where `matchRoutes` ranks then scans, so the +resolve row's URLs deliberately cycle an early, middle and late hit plus a miss rather than +sampling only the cheap case. And the navigate row understates jarl's deficit if anything: +`router.navigate` runs react-router's whole data-router state machine, strictly more work than +the atom write it beats. + +## Deep nesting + +Five nested levels (`/d1/:p1/d2/:p2/…/d5/:p5`), each level a layout rendering its own param and +one static child, against three implementations: jarl's nested `Switch`/`Route` atoms, +react-router's data router (route config), and react-router's declarative `` form. +Render counts per navigation are **identical across all three** — a three-way tie, and not a +flattering one: + +| component group, per navigation | jarl | rr data router | rr `` | +| ------------------------------- | ---- | -------------- | ------------- | +| every level layout (5) | 5 | 5 | 5 | +| per-level static children (5) | 5 | 5 | 5 | +| nav links (6) | 6 | 6 | 6 | +| shell | 0 | 0 | 0 | + +Whether only the leaf param changes, a mid-level one or the root one, **every level re-renders +in every router**: each level reads its own param, every router hands out fresh param/values +objects per navigation, and each re-rendered layout recreates its children's elements. Atom-level +subscription does not narrow this — jarl's known caveat that route atoms produce fresh objects +per location change applies at every level at once. jarl also repeats its mount deficit here +(nav links render twice at initial mount). + +Since the same components re-render everywhere, the routers can only differ in per-render cost, +so the same leaf toggle is also timed with React included (click to committed DOM, production +builds, `flushSync`): + +| per navigation, React render + commit | median | +| ------------------------------------- | ------------------------------ | +| react-router `` | **137 µs** (p25 122 / p75 150) | +| react-router data router | 397 µs (p25 355 / p75 517) | +| jarl | 493 µs (p25 434 / p75 585) | + +A clear jarl loss, and an instructive ordering: the declarative `` form — no state +machine, a tiny route table re-matched per render — is the fastest way to do a deep navigation, +the data router pays its state machine, and jarl pays re-deriving five levels of route atoms +plus six `useAtom` subscribers. (Absolute numbers are jsdom without layout or paint; the ranking +is the result.) + +## Nested async data + +A three-level route chain where every level needs one **25ms** async lookup, measured from +navigation to the deepest level's data on screen, fresh param values per run so no cache is ever +warm: + +| strategy | median | +| ------------------------------------------------------- | --------------------------------- | +| jarl: `asyncRouteAtom` + `followAsyncRoutes` (parallel) | **26.8 ms** (p25 26.4 / p75 27.1) | +| react-router: loaders (parallel) | **26.9 ms** (p25 26.2 / p75 27.7) | +| react-router: per-component Suspense cascade | 78.1 ms (p25 77.5 / p75 78.5) | + +jarl's atom pre-resolution starts every level's lookup the moment the location changes — the +param routes chain on each other, not on the async atoms, so no lookup waits for another's +data — and lands in ~one lookup's time. A fetch-on-render Suspense cascade cannot start a +level's lookup until its parent has rendered, so it pays the full sum of the chain, 3× here and +growing with depth. The honest comparison: react-router's loaders exist precisely to avoid that +cascade and match jarl's parallel time exactly. The cascade row is the cost of _not_ using a +router-level data story on either side — jarl's atoms give you the parallel behaviour as the +idiomatic default, react-router's requires opting into loaders. + +## Bundle size + +The same minimal routed app bundled from each router's published dist build (rolldown, minified, +production, `react`/`react-dom` external): + +| | minified | min+gzip | +| ------------------------------------------------------------------ | -------- | ---------- | +| jarl, full cost (jarl-atoms + jarl-react + jotai + jotai-location) | 14.1 kB | **5.7 kB** | +| jarl, app already using jotai | 4.6 kB | **2.0 kB** | +| react-router | 90.1 kB | 28.3 kB | + +Not a like-for-like feature set: react-router's bundle carries its data APIs (loaders, actions, +lazy routes) whether or not the app uses them, where JARL's data loading is jotai's own async +atoms. It is, however, the real wire cost of "a routed app" with each library. + +## What these numbers do not show + +- No real-browser timings — no layout, paint or input latency; jsdom timings cover library and + React render/commit work only. +- react-router's actions and lazy-route machinery are unexercised. +- Two app shapes and two route-table shapes; splats or query-heavy routing may rank differently. diff --git a/packages/docs/src/pages/Docs.tsx b/packages/docs/src/pages/Docs.tsx index daa4082..bb72c95 100644 --- a/packages/docs/src/pages/Docs.tsx +++ b/packages/docs/src/pages/Docs.tsx @@ -1,6 +1,7 @@ import gettingStarted from "../content/guides/GettingStarted.md?raw"; import dataLoading from "../content/guides/DataLoading.md?raw"; import pathVariables from "../content/guides/PathVariables.md?raw"; +import benchmarks from "../content/guides/Benchmarks.md?raw"; import LinkList from "../lib/LinkList"; import Markdown from "../lib/Markdown"; import { Link } from "jarl-react"; @@ -10,6 +11,7 @@ const guides: Record = { "getting-started": gettingStarted, "data-loading": dataLoading, "path-variables": pathVariables, + benchmarks, }; export const DocsIndex = () => ( diff --git a/packages/docs/src/router/routes.ts b/packages/docs/src/router/routes.ts index f2def08..e2bf2bc 100644 --- a/packages/docs/src/router/routes.ts +++ b/packages/docs/src/router/routes.ts @@ -78,12 +78,13 @@ export const notFoundAtom = atom( (get) => get(exactRouteMissedAtom) && !get(changelogRoute).match && !get(blogRoutingDemoRoute).match, ); -export type DocName = "getting-started" | "data-loading" | "path-variables"; +export type DocName = "getting-started" | "data-loading" | "path-variables" | "benchmarks"; export const docPages: { docName: DocName; title: string }[] = [ { docName: "getting-started", title: "Getting Started" }, { docName: "data-loading", title: "Data Loading" }, { docName: "path-variables", title: "Path Variables" }, + { docName: "benchmarks", title: "Benchmarks" }, ]; export type ApiName = "jarl-atoms" | "jarl-react";