mirror of
https://github.com/vercel/next.js.git
synced 2026-09-20 02:25:18 +08:00
f70564f742
Cache Components dev validation reported stack frames that pointed at
build output whenever a module had been updated while the dev server
ran. This affected both the static shell validation and the
instant-navigation validation, since both run on the same worker. The
overlay showed a raw `file:` URL and the terminal named the chunk rather
than the page, and because the frame never resolved to a source position
there was no code frame either, so nothing indicated which line caused
the error.
Turbopack's server HMR evaluates an updated module as a script of its
own, named `<chunk>?<module id>` and carrying its source map inline
rather than on disk, so only the isolate that ran that `eval` can
resolve a frame in it. The validation worker never ran it, and the map
beside the chunk describes the chunk's lines, not the running module's,
so nothing the worker could reach described the frame. React then wrote
the frame in its form for scripts without a source map, which encodes an
already-encoded URL a second time, leaving a frame no reader reverses.
The worker now mirrors what the dev server does to its own module state
rather than being dropped whenever that state changes. The dev server
reports each applied update, the manifest cache entries it cleared, and
the paths it evicted, and the worker replays them in the same order, so
its module state is the dev server's module state by construction. That
leaves each updated module's inline source map in the worker's own
Node.js cache, which is what makes the frame resolvable there.
The worker needs no coordination around a validation in flight. It runs
one call at a time, in the order the calls were made, so an update is
replayed before any validation requested after it, and never in the
middle of one. The dev server does not hold its own updates back for a
validation running in process either. Where it gives up and re-evaluates
every module from disk the worker is dropped, so that case keeps the
behaviour it had.
Not dropping the worker helps beyond the frames. Dropping it meant the
next validation had to spawn a worker thread and run `loadComponents`
again before it could start, and it paid that on every edit, which
delayed the insight at exactly the moment the user is waiting for it.
The case in the test suite that covers this went from around 870ms to
around 240ms.
The simpler fix was to revive the transported errors on the main thread
and print them there, where the scripts already are. It works, and it is
why this PR also touches the benchmark: the fixture produced no
validation errors, so nothing in the benchmark reached the error
reporting at all, and the cost of moving it was invisible. With insights
generated, the cost showed plainly. Printing an error costs around 218ms
the first time a source map is read and about a millisecond after that,
and moving it to the main thread cut the worker's p95 advantage on the
heaviest route from around 15ms to between 2ms and 5ms. Mirroring the
updates keeps the printing on the worker and leaves that advantage
intact.
The three commits are worth reading in order. The first adds the test
with the broken output snapshotted, so its snapshots deliberately record
what a user saw, a frame naming the chunk with no code frame beneath it.
The second is the benchmark change above. The third is the fix, and its
diff turns those snapshots into resolved frames, adds cases that edit
the same module twice, edit a module the page imports, and validate a
route that another route's update did not touch, and rewrites the
suite's header comment, which described the mechanism this replaces.
Verified on both bundlers, since the worker is gated on Turbopack and
Webpack validates in process, along with
`instant-validation-scheduling`,
`instant-validation/{server-errors,parallel-slots}`,
`instant-validation-causes`, `instant-validation-level-default` and
`hmr-rsc-cancellation`. Run with `BENCH_DEV_VALIDATION_INSIGHTS=1`, the
benchmark shows no steady-state regression: the worker column matches
canary at 106ms sprite p95 against 110ms and 109ms, and keeps its margin
over in-process.
Two things are deliberately left out. The benchmark still cannot measure
the edit case, because it never edits, so the timing above comes from a
test's wall clock rather than a purpose-built measurement. And
`use-cache-probe-pool` subscribes to the same invalidation and tears
down the same way, which is the obvious follow-up if this holds up.
One known gap remains. A worker dropped by its own failure, rather than
by the dev server giving up, cannot obtain the scripts the dev server
evaluated from earlier updates, so frames naming them stay unresolved
until those modules change again. The validation itself is unaffected,
because the worker loads the current code from disk.
352 lines
12 KiB
JavaScript
352 lines
12 KiB
JavaScript
// 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.
|
|
// Set BENCH_DEV_VALIDATION_INSIGHTS=1 to give every family's leaf page an
|
|
// uncached data access. Validation then reports one insight per navigation, so
|
|
// the run also exercises the error path (encoding the errors, and printing them
|
|
// with a source-mapped stack and code frame) rather than only the validation
|
|
// render. Off by default, so the numbers the README describes stay comparable.
|
|
const WITH_INSIGHTS = process.env.BENCH_DEV_VALIDATION_INSIGHTS === '1'
|
|
const VERSION =
|
|
'v6-3families-nested4-noinstant-48leaves-d4-b3-400symbols' +
|
|
(WITH_INSIGHTS ? '-insights-below-tree' : '')
|
|
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>
|
|
}
|
|
`
|
|
}
|
|
|
|
// The uncached access that makes validation report an insight. It renders last,
|
|
// below the family's heavy subtree, so validation does that work before it
|
|
// reaches the access. It is not wrapped in a `<Suspense>`, which is what makes
|
|
// validation report the route as blocking.
|
|
const insightImport = WITH_INSIGHTS
|
|
? "import { connection } from 'next/server'\n"
|
|
: ''
|
|
const insightComponent = WITH_INSIGHTS
|
|
? `
|
|
async function Insight() {
|
|
await connection()
|
|
return null
|
|
}
|
|
`
|
|
: ''
|
|
|
|
function leafPageSource(family, depthFromApp) {
|
|
if (family === 'sprite') {
|
|
if (!WITH_INSIGHTS) {
|
|
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 `${insightImport}// 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>
|
|
<Insight />
|
|
</>
|
|
)
|
|
}
|
|
${insightComponent}`
|
|
}
|
|
return `${insightImport}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} />${WITH_INSIGHTS ? '\n <Insight />' : ''}
|
|
</section>
|
|
)
|
|
}
|
|
${insightComponent}`
|
|
}
|
|
|
|
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`
|
|
)
|