mirror of
https://github.com/hanzoai/next.js.git
synced 2026-10-30 14:46:38 +00:00
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
331 lines
12 KiB
TypeScript
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))
|
|
})
|
|
})
|