Files
Hendrik LiebauandGitHub 3c4afc1a3a Run Cache Components dev validation on a worker thread (#96153)
This moves the dev-mode Cache Components validation renders off the dev
server's main thread onto a worker thread, so rapid navigation no longer
starves the event loop. The worker crosses into the app-page bundle
exactly once, calling a new
`ComponentMod.routeModule.runValidationInDev` entry that rebuilds the
render context, work store, and request store from a serializable
snapshot and runs the whole validation there, so the client prerender
and the user's client components resolve the single app-page React
instance rather than a second copy.

A thin worker shell (`dev-validation-worker.ts`) plus a single-worker
pool (`dev-validation-worker-pool.ts`) load the user bundle via
`loadComponents`, install code-frame support for CLI output, and forward
the validation errors back as Flight bytes; the main thread only
delivers them to the overlay through `sendErrorsToBrowser`. The
snapshot, the globalThis-symbol handoff, and the shared error-delivery
helpers live in their own modules. The dev server installs the worker
when `experimental.devValidationWorker` is not `false`, and
`runDevValidationInBackground` uses it when present, falling back to the
in-process path otherwise. A one-slot `SharedArrayBuffer` propagates a
supersede abort into the worker so a newer navigation cancels an
in-flight validation.

The runtime bundle gains an `app-worker` entry for the worker with the
`build/swc` boundary externalized for the code-frame native binding, and
`patch-error-inspect.ts` now backs its code-frame renderer with a
globalThis symbol so all copies of the module share it across the
thread.

The synthetic `bench/dev-validation` benchmark navigates back-to-back,
so every click lands inside the validation window — the worst case for
main-thread contention. Browser-observed navigation TTFB in that window,
worker vs in-process:

| Route  | Worker (p50/p95/max) | In-process (p50/p95/max) |
| ------ | -------------------- | ------------------------ |
| client | 19 / 24 / 27 ms      | 40 / 66 / 7762 ms        |
| server | 42 / 45 / 46 ms      | 122 / 158 / 252 ms       |
| sprite | 109 / 117 / 169 ms   | 196 / 208 / 299 ms       |

The steady-state difference is only tens of milliseconds; the effect
that matters is the tail. In-process, a navigation that collides with an
in-flight validation render can stall for seconds (this run peaked at
~7.8s on the client route, and the peak varies run to run) because a
staged render does not yield until it finishes; off-thread the main
thread stays free and that stall disappears.

Read these as an upper bound, not a speedup that generalizes. The work
moved off-thread is the validation render's CPU — bounded,
route-dependent, and free of IO — so it does not grow with the main
render's cost. In an app whose main render is dominated by IO the same
absolute saving is a small fraction of the request, and it only appears
when a navigation lands in the brief validation window; at ordinary
click speed it is largely invisible. This is a dev-only responsiveness
improvement whose benefit varies widely with the app and the navigation
pattern.

As a follow-up, the validation work that still runs on the main thread
could move to the worker as well. When the main render can't be reused
for validation (for example after a cache miss), the validation
re-renders on the main thread to produce its inputs, resuming from the
Resume Data Cache (RDC) the main render already filled, and only then
hands the resulting Flight chunks to the worker. A later iteration could
run those renders inside the worker too, transporting the (serializable)
RDC so the worker can resume from the filled caches rather than reading
them back on the main thread.

closes NAR-895
2026-07-25 07:21:03 +02:00

331 lines
12 KiB
TypeScript

import { nextTestSetup } from 'e2e-utils'
import { retry, assertNoConsoleErrors } from 'next-test-utils'
import {
parseValidationMessages,
waitForValidation,
} from 'e2e-utils/instant-validation'
import type { Playwright } from 'next-webdriver'
const cacheComponentsEnabled = process.env.__NEXT_CACHE_COMPONENTS === 'true'
describe('hmr-rsc-cancellation', () => {
const { next } = nextTestSetup({
files: __dirname,
skipStart: true,
// Emit `<VALIDATION_MESSAGE>` markers so the server-side validation-skip
// can be asserted.
env: { NEXT_TEST_LOG_VALIDATION: '1' },
})
let pristinePage: string
let pristineConfig: string
beforeAll(async () => {
pristinePage = await next.readFile('app/page.tsx')
pristineConfig = await next.readFile('next.config.js')
})
afterEach(async () => {
await next.stop()
// Restore the fixture so each test starts from the same baseline.
await next.patchFile('app/page.tsx', pristinePage)
await next.patchFile('next.config.js', pristineConfig)
})
// Records each HMR RSC request's aborted/settled state, and a sentinel that
// is cleared if the page hard-reloads (so we can assert cancellation never
// triggers a reload).
async function instrumentBrowser(browser: Playwright) {
await browser.eval(() => {
const requests: Array<{ aborted: boolean; settled: boolean }> = []
;(window as any).__hmrRequests = requests
;(window as any).__reloadSentinel = true
const originalFetch = window.fetch
window.fetch = (input: any, init: any) => {
const headers = new Headers(init?.headers)
if (headers.get('next-hmr-refresh') === '1') {
const record = {
aborted: init?.signal?.aborted ?? false,
settled: false,
}
requests.push(record)
init?.signal?.addEventListener(
'abort',
() => {
record.aborted = true
},
{ once: true }
)
const result = originalFetch(input, init)
result.then(
() => {
record.settled = true
},
() => {
record.settled = true
}
)
return result
}
return originalFetch(input, init)
}
})
}
function readResult(browser: Playwright) {
return browser.eval(() => ({
requests: (window as any).__hmrRequests as Array<{
aborted: boolean
settled: boolean
}>,
reloaded: (window as any).__reloadSentinel !== true,
}))
}
function expectCleanCliOutput(output: string) {
expect(output).not.toContain('Failed to fetch RSC payload')
expect(output).not.toContain('Cannot write to a closing writable stream')
expect(output).not.toContain('unhandledRejection')
}
// The markers `DynamicMarker` logs when React renders it. The dev server
// forwards server-component logs to the browser console, so we read them from
// there. A render aborted before React reaches the child logs nothing for its
// marker.
async function renderedMarkers(browser: Playwright): Promise<string[]> {
const logs = await browser.log()
return logs.flatMap((entry) => {
const match = entry.message.match(
/\[hmr-rsc-cancellation\] rendered (\w+)/
)
return match ? [match[1]] : []
})
}
it('cancels a superseded Server Components HMR request', async () => {
await next.start()
const browser = await next.browser('/', { pushErrorAsConsoleLog: true })
await retry(async () => {
expect(await browser.elementById('dynamic').text()).toBe('initial')
})
await instrumentBrowser(browser)
const cliStart = next.cliOutput.length
// Request A: a slow refresh whose dynamic content keeps streaming.
await next.patchFile('app/page.tsx', (src) =>
src
.replace(
/const dynamicMarker = '[^']*'/,
"const dynamicMarker = 'slow'"
)
.replace(/const dynamicDelayMs = \d+/, 'const dynamicDelayMs = 5000')
)
await retry(async () => {
expect(
await browser.eval(() => (window as any).__hmrRequests.length)
).toBe(1)
})
// Request B supersedes A, aborting it.
await next.patchFile('app/page.tsx', (src) =>
src
.replace(
/const dynamicMarker = '[^']*'/,
"const dynamicMarker = 'latest'"
)
.replace(/const dynamicDelayMs = \d+/, 'const dynamicDelayMs = 0')
)
await retry(async () => {
expect(await browser.elementById('dynamic').text()).toBe('latest')
})
await retry(async () => {
const { requests, reloaded } = await readResult(browser)
expect(reloaded).toBe(false)
// Two refreshes: the superseded one was aborted, the newest committed.
expect(requests).toMatchObject([
{ aborted: true, settled: true },
{ aborted: false, settled: true },
])
})
await assertNoConsoleErrors(browser)
expectCleanCliOutput(next.cliOutput.slice(cliStart))
})
it('does not surface an error when a partially committed render is superseded', async () => {
await next.start()
const browser = await next.browser('/', { pushErrorAsConsoleLog: true })
await retry(async () => {
expect(await browser.elementById('dynamic').text()).toBe('initial')
})
await instrumentBrowser(browser)
const cliStart = next.cliOutput.length
// Request A: remount the Suspense boundary (new key) with a slow dynamic.
// The remounted boundary has no prior content, so React commits it showing
// its fallback while the dynamic row streams — the tree is partially
// committed and on screen.
await next.patchFile('app/page.tsx', (src) =>
src
.replace(/const boundaryKey = '[^']*'/, "const boundaryKey = 'a'")
.replace(
/const dynamicMarker = '[^']*'/,
"const dynamicMarker = 'slow'"
)
.replace(/const dynamicDelayMs = \d+/, 'const dynamicDelayMs = 5000')
)
await retry(async () => {
expect(await browser.elementById('dynamic').text()).toBe('loading')
})
// Request B supersedes A, keeping the same boundary key so B reuses the
// committed boundary that is showing A's fallback. A's aborted dynamic row
// must halt (suspend) rather than reject into that mounted boundary.
await next.patchFile('app/page.tsx', (src) =>
src
.replace(
/const dynamicMarker = '[^']*'/,
"const dynamicMarker = 'latest'"
)
.replace(/const dynamicDelayMs = \d+/, 'const dynamicDelayMs = 0')
)
await retry(async () => {
expect(await browser.elementById('dynamic').text()).toBe('latest')
})
await retry(async () => {
const { requests, reloaded } = await readResult(browser)
expect(reloaded).toBe(false)
// Two refreshes: the superseded one was aborted, the newest committed.
expect(requests).toMatchObject([
{ aborted: true, settled: true },
{ aborted: false, settled: true },
])
})
await assertNoConsoleErrors(browser)
expectCleanCliOutput(next.cliOutput.slice(cliStart))
})
it('preserves existing behavior when cancellation is disabled', async () => {
await next.patchFile('next.config.js', (src) =>
src.replace(
'serverComponentsHmrCancellation: true',
'serverComponentsHmrCancellation: false'
)
)
await next.start()
const browser = await next.browser('/', { pushErrorAsConsoleLog: true })
await retry(async () => {
expect(await browser.elementById('dynamic').text()).toBe('initial')
})
await instrumentBrowser(browser)
await next.patchFile('app/page.tsx', (src) =>
src
.replace(
/const dynamicMarker = '[^']*'/,
"const dynamicMarker = 'slow'"
)
.replace(/const dynamicDelayMs = \d+/, 'const dynamicDelayMs = 1000')
)
await retry(async () => {
expect(
await browser.eval(() => (window as any).__hmrRequests.length)
).toBe(1)
})
await next.patchFile('app/page.tsx', (src) =>
src
.replace(
/const dynamicMarker = '[^']*'/,
"const dynamicMarker = 'latest'"
)
.replace(/const dynamicDelayMs = \d+/, 'const dynamicDelayMs = 0')
)
// With cancellation disabled, neither refresh is aborted; both run to
// completion. Which marker ends up committed is a race (the superseded
// refresh can still finish and clobber the newest), so we assert only that
// both requests ran, unaborted, rather than the final DOM.
await retry(async () => {
const { requests } = await readResult(browser)
expect(requests).toMatchObject([
{ aborted: false, settled: true },
{ aborted: false, settled: true },
])
})
})
it("aborts the superseded refresh's render server-side", async () => {
await next.start()
const browser = await next.browser('/', { pushErrorAsConsoleLog: true })
await retry(async () => {
expect(await browser.elementById('dynamic').text()).toBe('initial')
})
if (cacheComponentsEnabled) {
// Loading the page triggers a validation, which is detached from the
// response and can write its markers long after the page has rendered.
// Wait for it to finish before recording the CLI position below,
// otherwise those markers can land in the window the assertions read and
// the expected sequence of refresh events no longer matches.
await waitForValidation(await browser.url(), () => next.cliOutput)
}
await instrumentBrowser(browser)
const cliStart = next.cliOutput.length
// Request A: a slow refresh that stays in flight until B supersedes it.
await next.patchFile('app/page.tsx', (src) =>
src
.replace(
/const dynamicMarker = '[^']*'/,
"const dynamicMarker = 'slow'"
)
.replace(/const dynamicDelayMs = \d+/, 'const dynamicDelayMs = 5000')
)
await retry(async () => {
expect(
await browser.eval(() => (window as any).__hmrRequests.length)
).toBe(1)
})
// Request B changes only the marker, so it keeps A's slow delay while
// superseding it. By the time B commits, A's delay has elapsed: had A's
// render not been aborted, `DynamicMarker` would already have rendered and
// logged `slow`.
await next.patchFile('app/page.tsx', (src) =>
src.replace(
/const dynamicMarker = '[^']*'/,
"const dynamicMarker = 'latest'"
)
)
await retry(async () => {
expect(await browser.elementById('dynamic').text()).toBe('latest')
}, 10000)
await retry(async () => {
const markers = await renderedMarkers(browser)
// The superseding refresh rendered; the superseded refresh's render was
// aborted before React reached `DynamicMarker`, so it never logged.
expect(markers).toContain('latest')
expect(markers).not.toContain('slow')
}, 10000)
if (cacheComponentsEnabled) {
// On the Cache Components staged render the superseded refresh also skips
// its detached validation: it emits `validation_aborted` and never a
// start/end pair, while the committed refresh validates normally.
await retry(async () => {
const events = parseValidationMessages(next.cliOutput.slice(cliStart))
expect(events).toMatchObject([
{ type: 'validation_aborted' },
{ type: 'validation_start' },
{ type: 'validation_end' },
])
expect(events[0].requestId).not.toBe(events[1].requestId)
}, 10000)
}
await assertNoConsoleErrors(browser)
expectCleanCliOutput(next.cliOutput.slice(cliStart))
})
})