Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions benchmarks/README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
# CPU simulation benchmarks

`client-nav` measures navigation in Node/jsdom; `ssr` measures server requests.
`client-nav` measures navigation, mounting, and React/Solid/Vue hydration in
Node/jsdom; `ssr` measures server requests.
Both suites run through the CodSpeed Vitest integration in CPU simulation mode.

## Worker runtime
Expand Down Expand Up @@ -81,7 +82,7 @@ gh workflow run client-nav-benchmarks.yml --ref <branch>
Wait for each workflow run to finish before dispatching the next repetition at
the same commit. The workflow also runs memory benchmarks; exclude those results
when assessing CPU repeatability. Check that every repetition has all expected
CPU results (currently 132), rather than inherited results from an earlier run.
CPU results (currently 135), rather than inherited results from an earlier run.
Compare each benchmark's minimum and maximum across repetitions; keep input data,
navigation/request counts, builds, dependencies and Node version fixed when
testing runtime configuration changes.
85 changes: 79 additions & 6 deletions benchmarks/client-nav/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,11 @@ Cross-framework client-side CPU benchmarks for:
The benchmarks run in jsdom against production builds of real apps, and are
tracked in CI by CodSpeed (simulation mode).

> **Scope:** these benchmarks cover the standalone client router only. The
> client side of TanStack Start (hydration of a server-rendered document,
> streamed payload consumption, server-function calls from the client, ...) is
> not covered here; the server side of Start is covered by `benchmarks/ssr`.
> **Scope:** these benchmarks cover the client router. The hydration
> scenarios also cover DOM hydration and the Router state-restoration path
> used by Start. Start-specific entry-point initialization, streamed payload
> arrival and client server-function calls are not included. Server request work
> is covered by `benchmarks/ssr`.

## Layout

Expand All @@ -38,7 +39,7 @@ scenarios/<scenario>/<framework>/
routes/
```

Scenario apps use file-based routing (`@tanstack/router-plugin`) with a
Navigation scenario apps use file-based routing (`@tanstack/router-plugin`) with a
generated `routeTree.gen.ts`, like a regular user app. Each scenario uses one
app per framework instead of sharing routes in the baseline app. This keeps
route-tree size and router options isolated so one scenario cannot shift
Expand All @@ -57,9 +58,10 @@ be attributed to a specific feature area.
| `control-flow` | Loader-thrown `redirect` (including a 2-hop chain), `notFound()` with `notFoundComponent`, loader errors with `errorComponent`, and boundary reset on recovery navigation. |
| `head` | `HeadContent` per-navigation work: nested route `head()` evaluation, title/meta/link dedupe across matches, and head tag DOM updates during navigation. |
| `history` | History push/replace/back/forward traversal, location masking, registered-but-never-blocking `useBlocker`, and `useCanGoBack`/`useLocation` subscriptions. |
| `hydration` | Initial DOM hydration: execute the SSR payload, restore `beforeLoad` context and loader data, and hydrate 192 ordinary and eight hash-sensitive Links through their follow-up effects in React, Solid, and Vue. |
| `links` | Per-navigation cost of ~200 mounted `<Link>`s: link prop building, active-state recompute across `activeOptions` variants, `activeProps` swaps, and `useMatchRoute` probes (the `MatchRoute` component is avoided: vue-router's implementation leaks one subscription per render). |
| `loaders` | Client loader dispatch: always-stale re-runs (`staleTime: 0`), cached revisits (re-run once per lap by the `invalidate` step), `loaderDeps`-keyed caching, `router.invalidate()`, and `useLoaderData` selectors. |
| `mount` | Cold start: `createRouter` (route-tree processing) + first render + initial `router.load()` + unmount, with a fresh router per mount. The only scenario measuring router creation. |
| `mount` | Cold start: `createRouter` (route-tree processing) + first render + initial `router.load()` + unmount, with a fresh router per mount. |
| `nested-params` | Deep nesting (8 dynamic levels): per-level `params.parse`/`stringify`, `beforeLoad` context accumulation across matches, and per-level `useParams`/`useRouteContext` subscriptions. Param values include characters requiring percent-encoding (as do `route-tree-scale`'s), so segment encode/decode paths run on every navigation. |
| `preload` | Intent preloading from hover events, programmatic `router.preloadRoute`, deterministic preload cache behavior (`defaultPreloadStaleTime: 0`), and commit-time cache maintenance. |
| `rewrites` | Composed client-side location rewrites: router `basepath` plus a locale input/output rewrite pair, running on every href build and location parse (the client analog of the SSR `rewrites` scenario). |
Expand All @@ -68,6 +70,9 @@ be attributed to a specific feature area.

## Conventions

The hydration scenario has the separate lifecycle described below. The other
scenarios follow these navigation/mount conventions:

- Apps are built with `NODE_ENV=production` (`minify: false`) into `dist/app.js`; benches import the built bundle, so production package builds and production JSX output are measured, not dev transforms.
- Scenarios behave like a real user app: navigation happens through `<Link>` clicks dispatched on real anchor elements (unless a scenario specifically measures the imperative API), the router uses the default browser history, and `scrollRestoration` is enabled.
- Each benchmark iteration advances a fixed, circular sequence of steps; every step awaits the router's `onRendered` event, so render work is included and steps cannot overlap. No two consecutive steps may target the same location, and the sequence ends back on the initial route.
Expand Down Expand Up @@ -116,6 +121,74 @@ Typecheck benchmark sources (baseline + scenarios):
CI=1 NX_DAEMON=false pnpm nx run @benchmarks/client-nav:test:types --outputStyle=stream --skipRemoteCache
```

## Hydration

`scenarios/hydration/{react,solid,vue}` provide the same initial-hydration
workload: 192 ordinary Links, eight hash-sensitive Links, and three matched
routes with three `beforeLoad` contexts and two loader results. The server URL
has no fragment; the client URL has `#details`, matching half of the hash-sensitive Links.

```bash
CI=1 NX_DAEMON=false pnpm nx run @benchmarks/client-nav-hydration-react:test:perf --outputStyle=stream --skipRemoteCache
CI=1 NX_DAEMON=false pnpm nx run @benchmarks/client-nav-hydration-react:test:unit --outputStyle=stream --skipRemoteCache
CI=1 NX_DAEMON=false pnpm nx run @benchmarks/client-nav-hydration-react:test:types:client --outputStyle=stream --skipRemoteCache
```

Replace `react` with `solid` or `vue` to run the other adapters.

The build generates static HTML and its real Router SSR bootstrap scripts once
with `createRequestHandler` and the adapter's streaming renderer. Generation
fully consumes the response and runs outside the CodSpeed action. The measured
worker only reads these artifacts; it does not import a server renderer or start a server.

Each invocation uses a fresh jsdom window and a fresh evaluation of the complete
production client bundle in that window's realm. HTML parsing, bundle evaluation,
and the initial document lifecycle finish in untimed setup. This resets module
state as well as the DOM, avoiding cached hydration promises and cross-realm
payload objects. The bundle and bootstrap scripts are compiled once, so this
measures a fresh application with warm code, not JavaScript download/parse cost.

The timed region executes the serialized payload, creates the router and its
small code-based route tree, calls the public client `hydrate` API, and hydrates
the existing DOM with the framework's native renderer:

- **React:** a separate completion component signals from its post-hydration
effect, after the same snapshot transition as hash-sensitive Links. The harness
awaits that signal and two idle React scheduler turns, then checks the expected
active links. Concurrent hydration can take as many turns as needed under CPU
instrumentation, with a 60-second failure watchdog.
- **Solid:** execute the native hydration bootstrap and retain the server's
component/key hierarchy. Wait for mount, the router's rendered event, active-link
effects, and two idle turns. DOM identity assertions include every workload
element so template fallback cannot silently replace server nodes.
- **Vue:** mount a `createSSRApp` into its server-rendered container and flush
post-mount updates with `nextTick`, followed by two idle turns. Production
hydration-mismatch diagnostics remain enabled so incorrect server DOM fails the
scenario. Completion is independent of the expected DOM state.

Solid and Vue bound settlement to 100 turns. Ending at the hydration call or the
first mount would miss post-hydration Link updates.
Timer turns use `setImmediate`; scrolling is a no-op
because this is CPU simulation rather than browser layout/paint measurement.

Untimed assertions verify restored contexts and every loader row, zero client
`beforeLoad`/loader calls, expected hrefs/active state, original DOM-node identity,
working event handlers, and absence of hydration errors. Root unmount, pending
task cancellation, and window disposal also run outside measurement. Diagnostic
tests count Link renders or reactive evaluations and check they stop before the
measured region ends; counting is disabled in the timed workload.

CodSpeed uses suite `beforeEach`/`afterEach` hooks. Ordinary Vitest instead
installs Tinybench's public Task iteration hooks from its stage-level `setup`.
Using only stage-level setup would let later iterations reuse an already
hydrated document. The hydration unit tests cover fresh-state setup through
both paths.

Each scenario is included in its framework's aggregate build and CodSpeed run.
Land the benchmark independently to establish main's baseline before comparing a
hydration optimization. Regenerate each revision's artifacts with identical
fixture data and dependencies; do not pin an old hydration wire format forever.

## Isolated route-tree construction

`scenarios/route-tree-scale` measures navigation over an existing tree, not
Expand Down
6 changes: 6 additions & 0 deletions benchmarks/client-nav/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@
"@benchmarks/client-nav-control-flow-react",
"@benchmarks/client-nav-head-react",
"@benchmarks/client-nav-history-react",
"@benchmarks/client-nav-hydration-react",
"@benchmarks/client-nav-links-react",
"@benchmarks/client-nav-loaders-react",
"@benchmarks/client-nav-mount-react",
Expand Down Expand Up @@ -92,6 +93,7 @@
"@benchmarks/client-nav-control-flow-solid",
"@benchmarks/client-nav-head-solid",
"@benchmarks/client-nav-history-solid",
"@benchmarks/client-nav-hydration-solid",
"@benchmarks/client-nav-links-solid",
"@benchmarks/client-nav-loaders-solid",
"@benchmarks/client-nav-mount-solid",
Expand Down Expand Up @@ -120,6 +122,7 @@
"@benchmarks/client-nav-control-flow-vue",
"@benchmarks/client-nav-head-vue",
"@benchmarks/client-nav-history-vue",
"@benchmarks/client-nav-hydration-vue",
"@benchmarks/client-nav-links-vue",
"@benchmarks/client-nav-loaders-vue",
"@benchmarks/client-nav-mount-vue",
Expand Down Expand Up @@ -201,6 +204,9 @@
"@benchmarks/client-nav-history-react",
"@benchmarks/client-nav-history-solid",
"@benchmarks/client-nav-history-vue",
"@benchmarks/client-nav-hydration-react",
"@benchmarks/client-nav-hydration-solid",
"@benchmarks/client-nav-hydration-vue",
"@benchmarks/client-nav-links-react",
"@benchmarks/client-nav-links-solid",
"@benchmarks/client-nav-links-vue",
Expand Down
31 changes: 31 additions & 0 deletions benchmarks/client-nav/scenarios/hydration/react/fixture.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
export const ordinaryLinkCount = 192
export const hashLinkCount = 8
export const serverUrl = '/teams/team-7/items/item-42?view=summary'
export const clientUrl = `${serverUrl}#details`

export interface Diagnostics {
beforeLoads: number
loaders: number
mounted: boolean
clicks: number
ordinaryRenders: number
hashRenders: number
countRenders: boolean
}

export function createDiagnostics(countRenders = false): Diagnostics {
return {
beforeLoads: 0,
loaders: 0,
mounted: false,
clicks: 0,
ordinaryRenders: 0,
hashRenders: 0,
countRenders,
}
}

export interface FixtureArtifact {
html: string
scripts: Array<string>
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import assert from 'node:assert/strict'
import { writeFile } from 'node:fs/promises'
import { JSDOM } from 'jsdom'

if (process.env.NODE_ENV !== 'production') {
throw new Error('Generate the hydration fixture in production mode')
}
const { renderFixture } = await import('./dist/server/server.js')
const html = await renderFixture()
const dom = new JSDOM(html)
try {
const scriptElements = [...dom.window.document.querySelectorAll('script')]
assert.ok(scriptElements.length > 0)
assert.ok(scriptElements.every((script) => !script.src))
const scripts = scriptElements.map((script) => script.textContent)
assert.ok(scripts.some((script) => script.includes('$_TSR')))
await writeFile(
new URL('./dist/fixture.json', import.meta.url),
JSON.stringify({ html, scripts }),
)
} finally {
dom.window.close()
}
106 changes: 106 additions & 0 deletions benchmarks/client-nav/scenarios/hydration/react/hydration.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import { expect, test } from 'vitest'
import { hashLinkCount, ordinaryLinkCount } from './fixture'
import { setup } from './setup'
import { settleHydration } from './settle'

const turn = () => new Promise<void>((resolve) => setImmediate(resolve))

test('restores data and context into fresh DOMs and includes follow-up renders', async () => {
const scenario = setup({ countRenders: true })
for (let iteration = 0; iteration < 3; iteration++) {
await scenario.before()
try {
expect(scenario.snapshot().diagnostics.mounted).toBe(false)
expect(scenario.snapshot().matches).toBeUndefined()
await scenario.run()
const completed = scenario.snapshot()
// Supports both main's extra Link update and the optimized snapshot.
expect([ordinaryLinkCount, ordinaryLinkCount * 2]).toContain(
completed.diagnostics.ordinaryRenders,
)
expect(completed.diagnostics.hashRenders).toBeGreaterThanOrEqual(
hashLinkCount + hashLinkCount / 2,
)
for (let index = 0; index < 4; index++) {
await turn()
}
expect(scenario.snapshot()).toEqual(completed)
await expect(scenario.run()).rejects.toThrow('newly prepared sample')
} finally {
await scenario.after()
}
}
})

test('installs per-iteration preparation for the ordinary Vitest runner', async () => {
const scenario = setup()
const task: Parameters<typeof scenario.installIterationHooks>[0] = {
opts: {},
}
scenario.installIterationHooks(task)
for (let iteration = 0; iteration < 2; iteration++) {
await task.opts.beforeEach!()
try {
expect(scenario.snapshot().diagnostics.mounted).toBe(false)
await scenario.run()
} finally {
await task.opts.afterEach!()
}
}
})

test('rejects an empty measurement and still releases its sample', async () => {
const scenario = setup()
await scenario.before()
await expect(scenario.after()).rejects.toThrow(
'Hydration sample was not completed',
)
await scenario.before()
try {
await scenario.run()
} finally {
await scenario.after()
}
})

test('waits for a commit and scheduler work lasting more than 100 turns', async () => {
let committed = false
const commit = (async () => {
for (let index = 0; index < 150; index++) {
await turn()
}
committed = true
})()
let remainingCallbacks = 150
await settleHydration(
commit,
() => {
expect(committed).toBe(true)
return remainingCallbacks-- <= 0
},
() => 'delayed commit',
)
expect(remainingCallbacks).toBeLessThan(0)
})

test('retains a watchdog for a commit that never completes', async () => {
await expect(
settleHydration(
new Promise<void>(() => {}),
() => true,
() => 'commit pending',
10,
),
).rejects.toThrow('Hydration did not settle within 10ms: commit pending')
})

test('retains a watchdog when scheduler work never becomes idle', async () => {
await expect(
settleHydration(
Promise.resolve(),
() => false,
() => 'callbacks pending',
10,
),
).rejects.toThrow('Hydration did not settle within 10ms: callbacks pending')
})
40 changes: 40 additions & 0 deletions benchmarks/client-nav/scenarios/hydration/react/project.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
{
"name": "@benchmarks/client-nav-hydration-react",
"projectType": "application",
"targets": {
"build:client": {
"executor": "nx:run-commands",
"cache": false,
"dependsOn": [
{ "projects": ["@tanstack/react-router"], "target": "build" }
],
"options": {
"command": "NODE_ENV=production vite build --config {projectRoot}/vite.server.config.ts && NODE_ENV=production node {projectRoot}/generate-fixture.mjs && NODE_ENV=production vite build --config {projectRoot}/vite.config.ts"
}
},
"test:unit": {
"executor": "nx:run-commands",
"dependsOn": ["build:client"],
"options": {
"command": "vitest run --config ./scenarios/hydration/react/vite.config.ts",
"cwd": "benchmarks/client-nav"
}
},
"test:perf": {
"executor": "nx:run-commands",
"cache": false,
"dependsOn": ["build:client"],
"options": {
"command": "NODE_ENV=production vitest bench --run --config ./scenarios/hydration/react/vite.config.ts",
"cwd": "benchmarks/client-nav"
}
},
"test:types:client": {
"executor": "nx:run-commands",
"dependsOn": [
{ "projects": ["@tanstack/react-router"], "target": "build" }
],
"options": { "command": "tsc -p {projectRoot}/tsconfig.json --noEmit" }
}
}
}
Loading
Loading