mirror of
https://github.com/hanzoai/next.js.git
synced 2026-09-24 17:00:51 +00:00
Add a benchmark for dev Cache Components validation on a worker thread (#96152)
This adds `bench/dev-validation/`, wired as `pnpm bench:dev-validation`, which measures how much dev-mode Cache Components validation contends for the dev server's event loop during rapid navigation, and how much running it on a worker thread relieves that. It toggles `experimental.devValidationWorker` (added in the previous commit) to A/B the two configurations on the same build. Until the worker implementation lands the flag is inert and the A/B shows no delta. The fixture generates one route per family (`client`, `server`, `sprite`), each nested several layout segments deep under a `(routes)` route group. Validation renders a combined payload at every URL depth, so a deeper route means more validation work per navigation, which mirrors a realistically deep app rather than a single flat segment. The runner clicks a family's `<Link>` repeatedly, since navigating to the current route re-renders and re-validates it on every click. The routes carry no `instant` config because dev validation applies to page segments by default at the warning level. The three families isolate the client prerender, the Flight re-encode plus owner-stack work, and the Flight payload size, respectively. The signal is browser-observed TTFB taken from Playwright's own network timing, because it includes the time a request waits for the event loop while validation monopolizes it. We deliberately do not use the CLI's logged request durations: the dev server starts that clock inside the request handler, after the loop has already yielded to the request, so the queue wait is invisible to it. The runner prints each configuration's absolute TTFB (p50/p95/max) side by side rather than a ratio. The time the worker frees is the validation render's CPU, which is bounded, route-dependent, and does no IO, so a ratio would overstate a win that does not scale with total request time. Because the clicks are back-to-back the numbers are a worst case — navigations that land inside the validation window — and the `max` tail is the honest headline: it is the main-thread stall the worker removes.
This commit is contained in:
@@ -0,0 +1,14 @@
|
||||
# Build output
|
||||
.next/
|
||||
|
||||
# Written by `next dev` on each run
|
||||
next-env.d.ts
|
||||
AGENTS.md
|
||||
CLAUDE.md
|
||||
|
||||
# Generated by scripts/generate.mjs (run automatically by the benchmark runner)
|
||||
app/_generated/
|
||||
app/(routes)/
|
||||
|
||||
# Benchmark results
|
||||
artifacts/
|
||||
@@ -0,0 +1,81 @@
|
||||
# Dev-validation benchmark
|
||||
|
||||
Measures how much dev-mode **Cache Components validation** contends for the dev
|
||||
server's event loop during rapid navigation, and how much running it on a worker
|
||||
thread (`experimental.devValidationWorker`, default on) relieves that.
|
||||
|
||||
In `next dev` with Cache Components, every navigation runs a staged validation
|
||||
render (this covers static-shell validation, which runs on initial load and HMR
|
||||
refresh, as well as instant-navigation validation when `instant` is configured).
|
||||
These renders run on the dev server's event loop. When you navigate rapidly (the
|
||||
canonical "click the same nav item over and over" case), the validation from
|
||||
earlier navigations piles up and starves the loop, so later requests wait behind
|
||||
it. This benchmark reproduces that and reports the browser-observed impact.
|
||||
|
||||
## Running
|
||||
|
||||
```bash
|
||||
pnpm bench:dev-validation
|
||||
```
|
||||
|
||||
By default it runs an A/B on the same build: validation on a worker thread (the
|
||||
default) versus in-process (`experimental.devValidationWorker: false`), and
|
||||
prints the speedup. Options:
|
||||
|
||||
- `--worker=true|false` — run a single configuration instead of the A/B.
|
||||
- `--bundler=turbopack|webpack` — default `turbopack`.
|
||||
- `--clicks=<n>` — measured navigations per family (default 48).
|
||||
- `--port=<n>`, `--headless=false`, `--settle-ms=<n>`.
|
||||
- `--json-out=<path>` — write the raw stats as JSON.
|
||||
|
||||
This depends on `experimental.devValidationWorker` existing, so it stacks on the
|
||||
PR that adds the flag. Until the worker implementation lands, both
|
||||
configurations run in-process and the A/B shows no delta; once it lands, the
|
||||
worker column drops.
|
||||
|
||||
The fixture's heavy routes are generated (and gitignored); the runner
|
||||
regenerates them before booting. To generate by hand: `node
|
||||
scripts/generate.mjs`.
|
||||
|
||||
## What it measures
|
||||
|
||||
For each route **family** the runner clicks the family's `<Link>` repeatedly.
|
||||
Navigating to the current route re-renders and re-validates it, so every click
|
||||
triggers a fresh validation. The routes carry no `instant` config; dev
|
||||
validation applies to page segments by default at the warning level.
|
||||
|
||||
Each family's route is nested several layout segments deep (under a `(routes)`
|
||||
route group that keeps the URL clean). Validation renders a combined payload at
|
||||
every URL depth, so a deeper route means more validation renders per navigation.
|
||||
This mirrors a realistically deep app rather than a single flat segment; a flat
|
||||
route barely exercises the depth loop. The three families isolate different
|
||||
per-render costs:
|
||||
|
||||
- **client** — the leaf is a large tree of distinct `use client` components.
|
||||
Stresses validation's client prerender (`react-dom/static`).
|
||||
- **server** — the same recursive tree, but server components. Stresses the
|
||||
Flight re-encode plus the React owner-stack / `createTask` work validation
|
||||
re-processes per depth, which scales with component count.
|
||||
- **sprite** — one very large SVG server component (many `<symbol>`s), like a
|
||||
shared icon sprite, rendered in the family's shared layout so it is part of
|
||||
the payload at every depth. Stresses Flight payload size rather than
|
||||
component count.
|
||||
|
||||
**Primary signal: browser-observed TTFB.** The runner reads Playwright's own
|
||||
network timing (`request.timing()`) for each navigation and reports TTFB
|
||||
(`responseStart - requestStart`), the time the browser waits for the server.
|
||||
That wait includes the event-loop queue time while validation monopolizes the
|
||||
loop, which is exactly the contention we care about.
|
||||
|
||||
The CLI's logged request durations (`GET … in Xms (…, application-code: Yms)`)
|
||||
are **not** used as the signal. The dev server starts that clock inside the
|
||||
request handler, after the event loop has already yielded to the request, so the
|
||||
time a request spends queued behind validation is invisible to it.
|
||||
|
||||
## Interpreting results
|
||||
|
||||
Running validation on a worker thread frees the event loop between navigations,
|
||||
so TTFB drops and, more importantly, the long tail (the multi-second stalls
|
||||
where the loop is fully starved) disappears. The sprite family shows the largest
|
||||
per-render cost. Absolute numbers vary by machine; run
|
||||
the A/B on one machine and compare the two columns.
|
||||
@@ -0,0 +1,18 @@
|
||||
// The benchmark's route families and the nested segments each route sits under.
|
||||
// Keep in sync with scripts/generate.mjs, which generates the matching routes
|
||||
// and heavy components. Committed (not generated) so the layout and page
|
||||
// type-check on their own.
|
||||
//
|
||||
// Dev validation renders a combined payload at every URL depth (see
|
||||
// `validateInstantConfigs`), so its cost scales with route depth. The routes
|
||||
// nest several layout segments to mirror a realistically deep app rather than a
|
||||
// single flat segment.
|
||||
export const FAMILIES = ['client', 'server', 'sprite'] as const
|
||||
|
||||
export const NEST_SEGMENTS = ['s1', 's2', 's3', 's4'] as const
|
||||
|
||||
// The generated routes live in a `(routes)` route group, which does not appear
|
||||
// in the URL, so a family's path is just the family segment plus NEST_SEGMENTS.
|
||||
export function familyHref(family: string): string {
|
||||
return `/${family}/${NEST_SEGMENTS.join('/')}`
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import Link from 'next/link'
|
||||
import { FAMILIES, familyHref } from './families'
|
||||
|
||||
// Root layout: the static shell plus a persistent nav with one link per family
|
||||
// (client / server / sprite). Each link targets the family's deeply-nested leaf
|
||||
// route. The `<Link>`s are what the benchmark's Playwright driver clicks to
|
||||
// produce real soft navigations. Clicking a family's link repeatedly re-renders
|
||||
// and re-validates that route, each click triggering a dev Cache Components
|
||||
// validation.
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body>
|
||||
<nav>
|
||||
{FAMILIES.map((family) => (
|
||||
<Link key={family} href={familyHref(family)} data-nav={family}>
|
||||
{family}
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
{children}
|
||||
</body>
|
||||
</html>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { FAMILIES } from './families'
|
||||
|
||||
// Landing page. The benchmark starts here, then drives navigation into each
|
||||
// family's route. It renders no heavy tree of its own so the initial load stays
|
||||
// cheap.
|
||||
export default function Page() {
|
||||
return (
|
||||
<main>
|
||||
<h1 id="route">home</h1>
|
||||
<p>Dev-validation benchmark fixture.</p>
|
||||
<ul>
|
||||
{FAMILIES.map((family) => (
|
||||
<li key={family}>{family}</li>
|
||||
))}
|
||||
</ul>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,386 @@
|
||||
// Dev-validation benchmark. Must be run with tsx (via `pnpm
|
||||
// bench:dev-validation`).
|
||||
//
|
||||
// Measures how much dev-mode Cache Components validation contends for the dev
|
||||
// server's event loop during rapid navigation, and how much running it on a
|
||||
// worker thread (`experimental.devValidationWorker`) relieves that.
|
||||
//
|
||||
// Workload: for each route family (client / server / sprite) Playwright clicks
|
||||
// the family's `<Link>` repeatedly. Navigating to the current route re-renders
|
||||
// and re-validates it, so every click triggers a fresh dev validation (the
|
||||
// reloop "click Overview repeatedly" case). The routes carry no `instant`
|
||||
// config; dev validation applies to page segments by default.
|
||||
//
|
||||
// Signal: the browser-observed request latency of those navigations, taken from
|
||||
// Playwright's own network timing (`request.timing()`). We use TTFB
|
||||
// (`responseStart - requestStart`) because that is the time the browser waits
|
||||
// for the server, which includes the event-loop queue wait while validation
|
||||
// monopolizes the loop. The CLI's logged request durations are NOT usable for
|
||||
// this: the dev server starts that clock inside the request handler (after the
|
||||
// event loop has already yielded to the request), so the queue wait is
|
||||
// invisible to it.
|
||||
//
|
||||
// By default it runs the A/B on the same build: validation on a worker thread
|
||||
// (the default) vs in-process (`experimental.devValidationWorker: false`), and
|
||||
// prints each configuration's absolute TTFB side by side (not a ratio). Pass
|
||||
// `--worker=true|false` to run a single configuration.
|
||||
//
|
||||
// The clicks are back-to-back, so this is a worst case: navigations that land
|
||||
// inside the validation window, where the in-process path makes them wait for
|
||||
// the current validation render to finish. The time the worker frees is that
|
||||
// render's CPU, which is bounded, route-dependent, and does no IO, so it does
|
||||
// not scale with total request time and largely disappears at human click
|
||||
// speed. Read these numbers (especially the `max` tail) as an upper bound on a
|
||||
// dev-only responsiveness gain, not a speedup that generalizes to real apps.
|
||||
|
||||
import { spawn, type ChildProcess } from 'node:child_process'
|
||||
import { mkdir, writeFile } from 'node:fs/promises'
|
||||
import { resolve } from 'node:path'
|
||||
import { performance } from 'node:perf_hooks'
|
||||
import { setTimeout as sleep } from 'node:timers/promises'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { chromium, type Browser, type Page } from 'playwright'
|
||||
import { FAMILIES, familyHref } from './app/families'
|
||||
|
||||
const APP_DIR = fileURLToPath(new URL('.', import.meta.url))
|
||||
const REPO_ROOT = resolve(APP_DIR, '..', '..')
|
||||
const NEXT_BIN = resolve(REPO_ROOT, 'packages/next/dist/bin/next')
|
||||
const GENERATE = resolve(APP_DIR, 'scripts', 'generate.mjs')
|
||||
|
||||
type Stats = {
|
||||
n: number
|
||||
p50: number
|
||||
p95: number
|
||||
max: number
|
||||
mean: number
|
||||
}
|
||||
|
||||
type FamilyResult = {
|
||||
family: string
|
||||
ttfb: Stats
|
||||
total: Stats
|
||||
}
|
||||
|
||||
type RunResult = {
|
||||
worker: boolean
|
||||
families: FamilyResult[]
|
||||
}
|
||||
|
||||
type CliOptions = {
|
||||
compare: boolean
|
||||
worker: boolean
|
||||
bundler: 'turbopack' | 'webpack'
|
||||
clicks: number
|
||||
port: number
|
||||
headless: boolean
|
||||
settleMs: number
|
||||
jsonOut?: string
|
||||
}
|
||||
|
||||
function parseArgs(argv: string[]): CliOptions {
|
||||
const opts: CliOptions = {
|
||||
compare: true,
|
||||
worker: true,
|
||||
bundler: 'turbopack',
|
||||
clicks: 48,
|
||||
port: 3210,
|
||||
headless: true,
|
||||
settleMs: 3000,
|
||||
}
|
||||
for (const arg of argv) {
|
||||
const [rawKey, rawValue] = arg.replace(/^--/, '').split('=')
|
||||
const value = rawValue ?? 'true'
|
||||
switch (rawKey) {
|
||||
case 'compare':
|
||||
opts.compare = value !== 'false'
|
||||
break
|
||||
case 'worker':
|
||||
// A single explicit --worker run turns off the A/B comparison.
|
||||
opts.worker = value !== 'false'
|
||||
opts.compare = false
|
||||
break
|
||||
case 'bundler':
|
||||
opts.bundler = value === 'webpack' ? 'webpack' : 'turbopack'
|
||||
break
|
||||
case 'clicks':
|
||||
opts.clicks = Number(value)
|
||||
break
|
||||
case 'port':
|
||||
opts.port = Number(value)
|
||||
break
|
||||
case 'headless':
|
||||
opts.headless = value !== 'false'
|
||||
break
|
||||
case 'settle-ms':
|
||||
opts.settleMs = Number(value)
|
||||
break
|
||||
case 'json-out':
|
||||
opts.jsonOut = value
|
||||
break
|
||||
default:
|
||||
throw new Error(`Unknown option: --${rawKey}`)
|
||||
}
|
||||
}
|
||||
return opts
|
||||
}
|
||||
|
||||
function stats(samples: number[]): Stats {
|
||||
if (samples.length === 0) {
|
||||
return { n: 0, p50: NaN, p95: NaN, max: NaN, mean: NaN }
|
||||
}
|
||||
const sorted = [...samples].sort((a, b) => a - b)
|
||||
const at = (p: number) =>
|
||||
sorted[Math.min(sorted.length - 1, Math.floor((p / 100) * sorted.length))]
|
||||
const mean = sorted.reduce((a, b) => a + b, 0) / sorted.length
|
||||
return {
|
||||
n: sorted.length,
|
||||
p50: Math.round(at(50)),
|
||||
p95: Math.round(at(95)),
|
||||
max: Math.round(sorted[sorted.length - 1]),
|
||||
mean: Math.round(mean),
|
||||
}
|
||||
}
|
||||
|
||||
function run(command: string, args: string[]): Promise<void> {
|
||||
return new Promise((resolvePromise, reject) => {
|
||||
const child = spawn(command, args, { stdio: 'inherit' })
|
||||
child.on('error', reject)
|
||||
child.on('exit', (code) =>
|
||||
code === 0
|
||||
? resolvePromise()
|
||||
: reject(new Error(`${command} exited with ${code}`))
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
async function waitForReady(port: number, timeoutMs: number): Promise<void> {
|
||||
const deadline = performance.now() + timeoutMs
|
||||
while (performance.now() < deadline) {
|
||||
try {
|
||||
const res = await fetch(`http://localhost:${port}/`)
|
||||
await res.text()
|
||||
if (res.status < 500) {
|
||||
return
|
||||
}
|
||||
} catch {
|
||||
// Server not up yet.
|
||||
}
|
||||
await sleep(500)
|
||||
}
|
||||
throw new Error(`Dev server did not become ready within ${timeoutMs}ms`)
|
||||
}
|
||||
|
||||
async function stopServer(server: ChildProcess): Promise<void> {
|
||||
if (server.exitCode !== null) {
|
||||
return
|
||||
}
|
||||
const exited = new Promise<void>((resolvePromise) =>
|
||||
server.once('exit', () => resolvePromise())
|
||||
)
|
||||
server.kill('SIGTERM')
|
||||
const killed = await Promise.race([
|
||||
exited.then(() => true),
|
||||
sleep(5000).then(() => false),
|
||||
])
|
||||
if (!killed) {
|
||||
server.kill('SIGKILL')
|
||||
await exited
|
||||
}
|
||||
}
|
||||
|
||||
async function measureFamily(
|
||||
page: Page,
|
||||
family: string,
|
||||
opts: CliOptions
|
||||
): Promise<FamilyResult> {
|
||||
const routePath = familyHref(family)
|
||||
const linkSel = `nav a[data-nav="${family}"]`
|
||||
|
||||
// Fire the family's link and resolve once its RSC navigation request has come
|
||||
// back, so clicks stay one-per-navigation and measurable.
|
||||
const clickOnce = async () => {
|
||||
await Promise.all([
|
||||
page.waitForResponse(
|
||||
(r) => r.url().includes(routePath) && r.request().method() === 'GET',
|
||||
{ timeout: 60_000 }
|
||||
),
|
||||
page.click(linkSel),
|
||||
])
|
||||
}
|
||||
|
||||
// Collect Playwright's own network timing for the family's navigation
|
||||
// requests while measuring. TTFB (responseStart - requestStart) is the
|
||||
// browser-observed server wait; total (responseEnd - requestStart) adds the
|
||||
// response transfer.
|
||||
const ttfb: number[] = []
|
||||
const total: number[] = []
|
||||
let collecting = false
|
||||
const onRequestFinished = (request: import('playwright').Request) => {
|
||||
if (!collecting) {
|
||||
return
|
||||
}
|
||||
if (request.method() !== 'GET' || !request.url().includes(routePath)) {
|
||||
return
|
||||
}
|
||||
const timing = request.timing()
|
||||
if (timing.requestStart >= 0 && timing.responseStart >= 0) {
|
||||
ttfb.push(timing.responseStart - timing.requestStart)
|
||||
total.push(timing.responseEnd - timing.requestStart)
|
||||
}
|
||||
}
|
||||
page.on('requestfinished', onRequestFinished)
|
||||
|
||||
collecting = true
|
||||
for (let i = 0; i < opts.clicks; i++) {
|
||||
await clickOnce()
|
||||
}
|
||||
collecting = false
|
||||
page.off('requestfinished', onRequestFinished)
|
||||
|
||||
return {
|
||||
family,
|
||||
ttfb: stats(ttfb),
|
||||
total: stats(total),
|
||||
}
|
||||
}
|
||||
|
||||
async function runOnce(worker: boolean, opts: CliOptions): Promise<RunResult> {
|
||||
const label = worker ? 'worker' : 'in-process'
|
||||
process.stdout.write(`\n=== booting dev server: ${label} ===\n`)
|
||||
|
||||
const server = spawn(
|
||||
'node',
|
||||
[
|
||||
NEXT_BIN,
|
||||
'dev',
|
||||
opts.bundler === 'webpack' ? '--webpack' : '--turbopack',
|
||||
'--port',
|
||||
String(opts.port),
|
||||
],
|
||||
{
|
||||
cwd: APP_DIR,
|
||||
env: {
|
||||
...process.env,
|
||||
NEXT_TELEMETRY_DISABLED: '1',
|
||||
NO_COLOR: '1',
|
||||
...(worker ? {} : { BENCH_DEV_VALIDATION_WORKER: 'false' }),
|
||||
},
|
||||
stdio: ['ignore', 'ignore', 'inherit'],
|
||||
}
|
||||
)
|
||||
|
||||
let browser: Browser | undefined
|
||||
try {
|
||||
await waitForReady(opts.port, 180_000)
|
||||
const base = `http://localhost:${opts.port}`
|
||||
|
||||
browser = await chromium.launch({ headless: opts.headless })
|
||||
const page = await browser.newPage()
|
||||
await page.goto(base, { waitUntil: 'load' })
|
||||
await page.waitForSelector(`nav a[data-nav="${FAMILIES[0]}"]`)
|
||||
|
||||
// Warm every route once (compile) and let the warmup-triggered validation
|
||||
// settle, so the measured clicks reflect steady-state contention, not
|
||||
// first-compile cost.
|
||||
process.stdout.write('warming up routes...\n')
|
||||
for (const family of FAMILIES) {
|
||||
await Promise.all([
|
||||
page.waitForResponse(
|
||||
(r) =>
|
||||
r.url().includes(familyHref(family)) &&
|
||||
r.request().method() === 'GET',
|
||||
{ timeout: 180_000 }
|
||||
),
|
||||
page.click(`nav a[data-nav="${family}"]`),
|
||||
])
|
||||
}
|
||||
await sleep(opts.settleMs)
|
||||
|
||||
const families: FamilyResult[] = []
|
||||
for (const family of FAMILIES) {
|
||||
process.stdout.write(`measuring ${family} (${opts.clicks} clicks)...\n`)
|
||||
families.push(await measureFamily(page, family, opts))
|
||||
}
|
||||
|
||||
return { worker, families }
|
||||
} finally {
|
||||
if (browser) {
|
||||
await browser.close().catch(() => {})
|
||||
}
|
||||
await stopServer(server)
|
||||
// Give the OS a moment to release the port before the next configuration.
|
||||
await sleep(1000)
|
||||
}
|
||||
}
|
||||
|
||||
function formatStats(stat: Stats): string {
|
||||
return `p50=${stat.p50}ms p95=${stat.p95}ms max=${stat.max}ms (n=${stat.n})`
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const opts = parseArgs(process.argv.slice(2))
|
||||
|
||||
process.stdout.write('generating fixture...\n')
|
||||
await run('node', [GENERATE])
|
||||
|
||||
const configs = opts.compare ? [true, false] : [opts.worker]
|
||||
const results: RunResult[] = []
|
||||
for (const worker of configs) {
|
||||
results.push(await runOnce(worker, opts))
|
||||
}
|
||||
|
||||
process.stdout.write(
|
||||
'\n=== RESULTS (browser-observed TTFB, lower is better) ===\n'
|
||||
)
|
||||
for (const result of results) {
|
||||
const label = result.worker ? 'worker' : 'in-process'
|
||||
process.stdout.write(`${label}:\n`)
|
||||
for (const family of result.families) {
|
||||
process.stdout.write(
|
||||
` ${family.family.padEnd(6)} ttfb ${formatStats(family.ttfb)}\n`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (opts.compare) {
|
||||
const off = results.find((r) => r.worker)
|
||||
const inProc = results.find((r) => !r.worker)
|
||||
if (off && inProc) {
|
||||
// Report absolute TTFB side by side rather than a ratio. A ratio
|
||||
// overstates the win: the time the worker frees is the validation
|
||||
// render's CPU, which is bounded and route-dependent, and it only shows
|
||||
// up when a navigation lands in the validation window. The tail (`max`)
|
||||
// is the honest headline: it is the main-thread stall the worker removes.
|
||||
process.stdout.write(
|
||||
'\n=== in-process vs worker, browser-observed TTFB (ms, lower is better) ===\n'
|
||||
)
|
||||
for (const family of FAMILIES) {
|
||||
const o = off.families.find((f) => f.family === family)
|
||||
const p = inProc.families.find((f) => f.family === family)
|
||||
if (o && p) {
|
||||
process.stdout.write(
|
||||
` ${family.padEnd(6)} in-process ${formatStats(p.ttfb)}\n` +
|
||||
` ${' '.repeat(6)} worker ${formatStats(o.ttfb)}\n`
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (opts.jsonOut) {
|
||||
await mkdir(resolve(opts.jsonOut, '..'), { recursive: true })
|
||||
await writeFile(
|
||||
opts.jsonOut,
|
||||
JSON.stringify({ options: opts, results }, null, 2)
|
||||
)
|
||||
process.stdout.write(`\nwrote ${opts.jsonOut}\n`)
|
||||
}
|
||||
}
|
||||
|
||||
main().then(
|
||||
() => process.exit(0),
|
||||
(error) => {
|
||||
console.error(error)
|
||||
process.exit(1)
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* @type {import('next').NextConfig}
|
||||
*/
|
||||
const nextConfig = {
|
||||
// Cache Components enables the dev validation this benchmark stresses.
|
||||
cacheComponents: true,
|
||||
}
|
||||
|
||||
// The benchmark runner A/Bs worker vs in-process validation on the same build
|
||||
// by setting this env var. We only set the experimental flag when explicitly
|
||||
// disabling the worker; leaving it unset uses the default (worker), so the two
|
||||
// configurations differ in this one flag alone.
|
||||
if (process.env.BENCH_DEV_VALIDATION_WORKER === 'false') {
|
||||
nextConfig.experimental = {
|
||||
...nextConfig.experimental,
|
||||
devValidationWorker: false,
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = nextConfig
|
||||
@@ -0,0 +1,314 @@
|
||||
// Generates the dev-validation benchmark's heavy routes (under app/_generated/
|
||||
// and app/routes/, both gitignored). Each family isolates a different cost that
|
||||
// dev-mode Cache Components validation pays per navigation. The "client" family
|
||||
// is a large tree of distinct `use client` components, stressing the
|
||||
// validation's client prerender (react-dom/static). The "server" family is the
|
||||
// same recursive tree as server components, stressing the Flight re-encode plus
|
||||
// the React owner-stack / createTask work validation re-processes per depth,
|
||||
// which scales with component count. The "sprite" family is one very large SVG
|
||||
// server component (many symbols), like a shared icon sprite, stressing Flight
|
||||
// payload size rather than component count.
|
||||
//
|
||||
// The client and server families share the same recursive shape so the only
|
||||
// variable is where the work lands. Each family's route is nested several
|
||||
// layout segments deep (see NEST_SEGMENTS): dev validation renders a combined
|
||||
// payload at every URL depth, so a deeper route means more validation work per
|
||||
// navigation, mirroring a realistic app rather than a single flat segment. The
|
||||
// benchmark clicks the family's link repeatedly; navigating to the current
|
||||
// route re-renders and re-validates it (the reloop "click Overview repeatedly"
|
||||
// case), so distinct tabs are unnecessary. The routes need no `instant` config:
|
||||
// dev validation applies to page segments by default at the warning level. To
|
||||
// build by hand: node bench/dev-validation/scripts/generate.mjs
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
// Bump VERSION whenever the generation logic or shape changes; the marker file
|
||||
// short-circuits regeneration when nothing changed.
|
||||
const VERSION = 'v6-3families-nested4-noinstant-48leaves-d4-b3-400symbols'
|
||||
const LEAF_COMPONENTS = 48
|
||||
const TREE_DEPTH = 4
|
||||
const TREE_BRANCH = 3
|
||||
const LEAVES_PER_BRANCH = 3
|
||||
const SPRITE_SYMBOLS = 400
|
||||
const SPRITE_PATHS_PER_SYMBOL = 2
|
||||
|
||||
// Intermediate route segments nested under routes/<family>, with the leaf page
|
||||
// at the end. Each segment adds one URL depth, and dev validation renders a
|
||||
// combined payload per depth, so this is the primary lever for how much
|
||||
// validation work each navigation triggers. Keep in sync with app/families.ts.
|
||||
const NEST_SEGMENTS = ['s1', 's2', 's3', 's4']
|
||||
|
||||
// name → kind: 'tree' families share the recursive generator; 'sprite' is the
|
||||
// big-SVG special case.
|
||||
const FAMILIES = [
|
||||
{ name: 'client', kind: 'tree', isClient: true },
|
||||
{ name: 'server', kind: 'tree', isClient: false },
|
||||
{ name: 'sprite', kind: 'sprite' },
|
||||
]
|
||||
|
||||
const appDir = path.join(
|
||||
path.dirname(fileURLToPath(import.meta.url)),
|
||||
'..',
|
||||
'app'
|
||||
)
|
||||
const generatedDir = path.join(appDir, '_generated')
|
||||
// A route group (parenthesized), so it organizes the generated routes without
|
||||
// adding a URL segment or a validation depth of its own. The URL depth comes
|
||||
// from the family segment and NEST_SEGMENTS below it.
|
||||
const routesDir = path.join(appDir, '(routes)')
|
||||
const marker = path.join(generatedDir, '.generated')
|
||||
|
||||
if (fs.existsSync(marker) && fs.readFileSync(marker, 'utf8') === VERSION) {
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
fs.rmSync(generatedDir, { recursive: true, force: true })
|
||||
fs.rmSync(routesDir, { recursive: true, force: true })
|
||||
fs.mkdirSync(generatedDir, { recursive: true })
|
||||
fs.mkdirSync(routesDir, { recursive: true })
|
||||
|
||||
function leavesSource(isClient) {
|
||||
const parts = []
|
||||
if (isClient) {
|
||||
parts.push("'use client'", '', 'import { useMemo } from "react"', '')
|
||||
}
|
||||
parts.push('// Generated leaf components. See scripts/generate.mjs.', '')
|
||||
for (let k = 0; k < LEAF_COMPONENTS; k++) {
|
||||
const prime = 97 + k * 2
|
||||
const value = isClient
|
||||
? ` const value = useMemo(() => (n * ${prime} + ${k}) % 100003, [n])`
|
||||
: ` const value = (n * ${prime} + ${k}) % 100003`
|
||||
parts.push(
|
||||
`export function Leaf${k}({ n }: { n: number }) {`,
|
||||
value,
|
||||
` return <span className="leaf leaf-${k}" data-leaf={${k}}>{value}</span>`,
|
||||
`}`,
|
||||
''
|
||||
)
|
||||
}
|
||||
return parts.join('\n')
|
||||
}
|
||||
|
||||
function treeSource(isClient) {
|
||||
const names = Array.from({ length: LEAF_COMPONENTS }, (_, k) => `Leaf${k}`)
|
||||
const directive = isClient ? "'use client'\n\n" : ''
|
||||
return `${directive}// Generated heavy ${isClient ? 'client' : 'server'} tree. See scripts/generate.mjs.
|
||||
|
||||
import { ${names.join(', ')} } from './leaves'
|
||||
|
||||
const LEAVES = [${names.join(', ')}]
|
||||
|
||||
const DEPTH = ${TREE_DEPTH}
|
||||
const BRANCH = ${TREE_BRANCH}
|
||||
const LEAVES_PER_BRANCH = ${LEAVES_PER_BRANCH}
|
||||
|
||||
function Branch({
|
||||
seed,
|
||||
depth,
|
||||
path,
|
||||
}: {
|
||||
seed: number
|
||||
depth: number
|
||||
path: number
|
||||
}) {
|
||||
const children = []
|
||||
for (let i = 0; i < LEAVES_PER_BRANCH; i++) {
|
||||
const Leaf = LEAVES[(seed + depth * 7 + path + i) % LEAVES.length]
|
||||
children.push(<Leaf key={\`l\${i}\`} n={seed + depth * 31 + path + i} />)
|
||||
}
|
||||
if (depth > 0) {
|
||||
for (let i = 0; i < BRANCH; i++) {
|
||||
children.push(
|
||||
<Branch
|
||||
key={\`b\${i}\`}
|
||||
seed={seed * 31 + i}
|
||||
depth={depth - 1}
|
||||
path={path * BRANCH + i}
|
||||
/>
|
||||
)
|
||||
}
|
||||
}
|
||||
return (
|
||||
<div className="branch" data-depth={depth}>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function HeavyTree({ seed }: { seed: number }) {
|
||||
return <Branch seed={seed} depth={DEPTH} path={0} />
|
||||
}
|
||||
`
|
||||
}
|
||||
|
||||
function spriteSource() {
|
||||
// One large static SVG server component, shaped like a shared icon sprite:
|
||||
// many <symbol>s with a few <path>s each. This is payload-heavy but has few
|
||||
// distinct components, isolating Flight size from component count.
|
||||
const symbols = []
|
||||
for (let s = 0; s < SPRITE_SYMBOLS; s++) {
|
||||
const paths = []
|
||||
for (let p = 0; p < SPRITE_PATHS_PER_SYMBOL; p++) {
|
||||
const coords = Array.from({ length: 8 }, (_, i) => {
|
||||
const x = ((s * 7 + p * 13 + i * 3) % 24).toFixed(2)
|
||||
const y = ((s * 11 + p * 5 + i * 2) % 24).toFixed(2)
|
||||
return `${x} ${y}`
|
||||
}).join(' L ')
|
||||
paths.push(` <path d={\`M ${coords} Z\`} />`)
|
||||
}
|
||||
symbols.push(
|
||||
` <symbol id="icon-${s}" viewBox="0 0 24 24">`,
|
||||
...paths,
|
||||
` </symbol>`
|
||||
)
|
||||
}
|
||||
return `// Generated large SVG sprite server component. See scripts/generate.mjs.
|
||||
|
||||
export function BigSprite() {
|
||||
return (
|
||||
<svg
|
||||
width={0}
|
||||
height={0}
|
||||
aria-hidden="true"
|
||||
style={{ position: 'absolute' }}
|
||||
>
|
||||
${symbols.join('\n')}
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
`
|
||||
}
|
||||
|
||||
// `../` repeated n times, for import paths from a nested route file back up to
|
||||
// the app directory (which holds _generated/).
|
||||
function up(n) {
|
||||
return '../'.repeat(n)
|
||||
}
|
||||
|
||||
// The top layout for a family (at routes/<family>/). For the sprite family it
|
||||
// renders the sprite here, in a shared layout, so the sprite is part of the
|
||||
// validation payload at every route depth (mirroring the reloop case, where a
|
||||
// large shared-layout server component dominates each validation render). For
|
||||
// the tree families it is a plain wrapper; the heavy tree lives at the leaf.
|
||||
function topLayoutSource(family) {
|
||||
if (family === 'sprite') {
|
||||
return `import { BigSprite } from '${up(2)}_generated/sprite-tree'
|
||||
|
||||
// Generated. See scripts/generate.mjs.
|
||||
export default function Layout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<>
|
||||
<BigSprite />
|
||||
{children}
|
||||
</>
|
||||
)
|
||||
}
|
||||
`
|
||||
}
|
||||
return `// Generated. See scripts/generate.mjs.
|
||||
export default function Layout({ children }: { children: React.ReactNode }) {
|
||||
return <div data-family="${family}">{children}</div>
|
||||
}
|
||||
`
|
||||
}
|
||||
|
||||
function segmentLayoutSource(segment) {
|
||||
return `// Generated. See scripts/generate.mjs.
|
||||
export default function Layout({ children }: { children: React.ReactNode }) {
|
||||
return <div data-seg="${segment}">{children}</div>
|
||||
}
|
||||
`
|
||||
}
|
||||
|
||||
function leafPageSource(family, depthFromApp) {
|
||||
if (family === 'sprite') {
|
||||
return `// Generated. See scripts/generate.mjs. The sprite renders in the
|
||||
// shared layout above, so the leaf page only carries the route marker.
|
||||
export default function Page() {
|
||||
return <h1 id="route">sprite</h1>
|
||||
}
|
||||
`
|
||||
}
|
||||
return `import { HeavyTree } from '${up(depthFromApp)}_generated/${family}-tree'
|
||||
|
||||
// Generated. See scripts/generate.mjs.
|
||||
export default function Page() {
|
||||
return (
|
||||
<section>
|
||||
<h1 id="route">${family}</h1>
|
||||
<HeavyTree seed={7} />
|
||||
</section>
|
||||
)
|
||||
}
|
||||
`
|
||||
}
|
||||
|
||||
for (const family of FAMILIES) {
|
||||
const { name } = family
|
||||
fs.mkdirSync(path.join(generatedDir, name), { recursive: true })
|
||||
if (family.kind === 'tree') {
|
||||
fs.writeFileSync(
|
||||
path.join(generatedDir, name, 'leaves.tsx'),
|
||||
leavesSource(family.isClient)
|
||||
)
|
||||
fs.writeFileSync(
|
||||
path.join(generatedDir, name, 'tree.tsx'),
|
||||
treeSource(family.isClient)
|
||||
)
|
||||
fs.writeFileSync(
|
||||
path.join(generatedDir, `${name}-tree.tsx`),
|
||||
`export { HeavyTree } from './${name}/tree'\n`
|
||||
)
|
||||
} else {
|
||||
fs.writeFileSync(
|
||||
path.join(generatedDir, name, 'sprite.tsx'),
|
||||
spriteSource()
|
||||
)
|
||||
fs.writeFileSync(
|
||||
path.join(generatedDir, `${name}-tree.tsx`),
|
||||
`export { BigSprite } from './${name}/sprite'\n`
|
||||
)
|
||||
}
|
||||
|
||||
// Nested route: routes/<name>/ has the top layout, then one layout per
|
||||
// intermediate NEST_SEGMENT, and the page at the deepest segment. Each
|
||||
// segment is one more URL depth for dev validation to render.
|
||||
const familyRouteDir = path.join(routesDir, name)
|
||||
fs.mkdirSync(familyRouteDir, { recursive: true })
|
||||
fs.writeFileSync(
|
||||
path.join(familyRouteDir, 'layout.tsx'),
|
||||
topLayoutSource(name)
|
||||
)
|
||||
|
||||
// Depth of the leaf page's directory below app/: routes + <name> + segments.
|
||||
const leafDepthFromApp = 2 + NEST_SEGMENTS.length
|
||||
let dir = familyRouteDir
|
||||
NEST_SEGMENTS.forEach((segment, index) => {
|
||||
dir = path.join(dir, segment)
|
||||
fs.mkdirSync(dir, { recursive: true })
|
||||
if (index < NEST_SEGMENTS.length - 1) {
|
||||
fs.writeFileSync(
|
||||
path.join(dir, 'layout.tsx'),
|
||||
segmentLayoutSource(segment)
|
||||
)
|
||||
} else {
|
||||
fs.writeFileSync(
|
||||
path.join(dir, 'page.tsx'),
|
||||
leafPageSource(name, leafDepthFromApp)
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// The family list and nested segments are duplicated (as small, stable
|
||||
// constants) in the committed app/families.ts, so the hand-written layout/page
|
||||
// stay self-contained and type-check without depending on generated output.
|
||||
// Keep the two in sync.
|
||||
|
||||
fs.writeFileSync(marker, VERSION)
|
||||
process.stdout.write(
|
||||
`generated ${FAMILIES.length} routes nested ${NEST_SEGMENTS.length} deep ` +
|
||||
`(client/server: depth ${TREE_DEPTH} branch ${TREE_BRANCH}, ${LEAF_COMPONENTS} leaves; ` +
|
||||
`sprite: ${SPRITE_SYMBOLS} symbols)\n`
|
||||
)
|
||||
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2017",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": false,
|
||||
"noEmit": true,
|
||||
"incremental": true,
|
||||
"module": "esnext",
|
||||
"esModuleInterop": true,
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "react-jsx",
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
]
|
||||
},
|
||||
"include": [
|
||||
"next-env.d.ts",
|
||||
".next/types/**/*.ts",
|
||||
".next/dev/types/**/*.ts",
|
||||
"**/*.mts",
|
||||
"**/*.ts",
|
||||
"**/*.tsx"
|
||||
],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
@@ -16,6 +16,7 @@
|
||||
"bench:render-pipeline": "tsx bench/render-pipeline/benchmark.ts",
|
||||
"bench:render-pipeline:analyze": "tsx bench/render-pipeline/analyze-profiles.ts",
|
||||
"bench:render-pipeline:client": "tsx bench/render-pipeline/client-trace.ts",
|
||||
"bench:dev-validation": "tsx bench/dev-validation/benchmark.ts",
|
||||
"pack-next": "tsx scripts/pack-next.ts",
|
||||
"eval": "node run-evals.js",
|
||||
"test-types": "tsc",
|
||||
|
||||
Reference in New Issue
Block a user