Files
Tim NeutkensandGitHub 81d0e8519d Fix instant validation blocking navigations (#95939)
### What?

- Run development instant validation only after the navigation response
has finished.
- Let a newer request supersede stale validation work and yield between
validation render attempts.
- Forward the cancellation signal into React validation prerenders so
asynchronous validation work can stop promptly.
- Add focused scheduler/response tests and assert that development
validation starts after the response finishes.

### Why?

Instant validation was detached from the response promise, but its
additional React renders still ran on the same Node.js event loop. A
subsequent navigation could therefore wait hundreds of milliseconds for
stale diagnostic work from the previous request.

Instant validation still runs when the server is idle. When another
navigation arrives, foreground request work now takes priority and the
obsolete validation is discarded.

### How?

Each Cache Components development request receives a validation
generation signal. Starting a newer request aborts the previous
generation. Validation waits for the Node response `finish` event,
yields through the event-loop poll phase between attempts, and combines
the generation signal with React's existing prerender abort signals.

Tests:

- `pnpm --filter=next build`
- `pnpm --filter=next types`
- `pnpm exec jest --runTestsByPath
packages/next/src/server/app-render/dev-validation-scheduler.test.ts
packages/next/src/server/app-render/wait-for-response.test.ts`
- `NEXT_SKIP_ISOLATE=1 NEXT_TEST_PREFER_OFFLINE=1 pnpm test-dev-turbo
test/e2e/app-dir/instant-validation/suspense-boundaries.test.ts -t
"valid - static prefetch - suspense around runtime and dynamic"`

<!-- NEXT_JS_LLM_PR -->
2026-07-21 09:53:43 +02:00

200 lines
6.5 KiB
TypeScript

import { nextTestSetup, type NextInstance, type Playwright } from 'e2e-utils'
import { waitForValidation } from 'e2e-utils/instant-validation'
import { retry, waitForNoErrorToast } from '../../../lib/next-test-utils'
export interface InstantValidationCaseContext {
next: NextInstance
isNextDev: boolean
isNextStart: boolean
isTurbopack: boolean
/** Whether this case navigates via soft navigation instead of initial load */
isClientNav: boolean
/**
* Navigate to a page either via initial load or soft navigation.
* For soft nav, navigates to the index page first, then clicks the link.
*/
navigateTo: (href: string) => Promise<Playwright>
expectNoDevValidationErrors: (
browser: Playwright,
url: string
) => Promise<void>
getCliOutputSinceMark: () => string
/** Prerender a single page with `--experimental-build-mode generate` */
prerender: (pathname: string) => ReturnType<NextInstance['build']>
}
export const NO_VALIDATION_ERRORS_WAIT: Parameters<
typeof waitForNoErrorToast
>[1] = {
waitInMs: 500,
}
// This suite is far too slow to run as a single CI test file, so it's split
// into one `*.test.ts` entry file per group of sections (each with a
// `.partial-prefetching` variant), all sharing this wrapper.
// Every entry boots its own server (and, in `next start` mode, runs its own
// `--experimental-build-mode compile` build plus a `generate` build per
// test). All entries use the same describe title so test full names stay
// stable across the split. Snapshots can be updated with the sibling
// update-instant-validation-snapshots.mjs script.
export function runInstantValidationTests(
registerTests: (ctx: InstantValidationCaseContext) => void
) {
describe('instant validation', () => {
const { next, skipped, isNextDev, isNextStart, isTurbopack } =
nextTestSetup({
files: __dirname,
skipStart: true,
skipDeployment: true,
env: {
NEXT_TEST_LOG_VALIDATION: '1',
},
})
if (skipped) return
if (isNextStart && !isTurbopack) {
// TODO(instant-validation-build): snapshot tests for webpack
it.skip('TODO: snapshot tests for webpack', () => {})
return
}
if (isNextStart) {
beforeAll(async () => {
await next.build({ args: ['--experimental-build-mode', 'compile'] })
})
afterEach(async () => {
await next.stop()
})
} else {
beforeAll(async () => {
await next.start()
})
}
let currentCliOutputIndex = 0
beforeEach(() => {
currentCliOutputIndex = next.cliOutput.length
})
function getCliOutputSinceMark(): string {
if (next.cliOutput.length < currentCliOutputIndex) {
// cliOutput shrank since we started the test, so something (like a `sandbox`) reset the logs
currentCliOutputIndex = 0
}
return next.cliOutput.slice(currentCliOutputIndex)
}
const prerender = async (pathname: string) => {
const args = [
'--experimental-build-mode',
'generate',
'--debug-build-paths',
`app${pathname}/page.tsx`,
]
return await next.build({ args })
}
async function expectNoDevValidationErrors(
browser: Playwright,
url: string
): Promise<void> {
const { start } = await waitForValidation(url, getCliOutputSinceMark)
if (start.responseFinished !== undefined) {
expect(start.responseFinished).toBe(true)
}
await waitForNoErrorToast(browser, NO_VALIDATION_ERRORS_WAIT)
}
const cases = isNextDev
? [
{ isClientNav: false, description: 'dev - initial load' },
{ isClientNav: true, description: 'dev - client navigation' },
]
: [{ isClientNav: false, description: 'build' }]
describe.each(cases)('$description', ({ isClientNav }) => {
/**
* Navigate to a page either via initial load or soft navigation.
* For soft nav, navigates to the index page first, then clicks the link.
*/
async function navigateTo(href: string) {
if (!isClientNav) {
// Initial load - navigate directly
const browser = await next.browser(href)
await browser.elementByCss('main')
return browser
}
// Soft nav - go to index page first, then click link.
// We have multiple root layouts, so each needs a separate index page
// because navigating between root layouts would be an MPA nav,
// and we want to test soft navs.
const indexPage = ((): string => {
if (href.startsWith('/shells/')) {
// If this is the root params page, use the same root param value
let match: ReturnType<String['match']>
if (
(match = href.match(
/^\/shells\/with-root-param\/(?<lang>[^/]+)\/.*/
))
) {
const lang = match.groups!.lang
return `/shells/with-root-param/${lang}`
} else {
return '/shells'
}
}
for (const prefix of ['/default', '/suspense-in-root']) {
if (href.startsWith(prefix + '/')) {
return prefix
}
}
throw new Error(`Could not find index page for ${href}`)
})()
const browser = await next.browser(indexPage)
const initialRootLayoutTimestamp = await browser
.elementById('root-layout-timestamp')
.text()
await browser
.elementByCss(`[data-link-type="soft"][href="${href}"]`)
.click()
await retry(
async () => {
expect(await browser.url()).toContain(href)
},
// Webpack can be slow to compile new routes in CI, which blocks the
// navigation. Instant Validation itself is non-blocking and does not
// affect this, so this is only covering for compilation speed.
isTurbopack ? 5_000 : 10_000,
100,
'wait for url to change'
)
// Sanity check: we shouldn't have switched or otherwise refetched the root layout
const finalRootLayoutTimestamp = await browser
.elementById('root-layout-timestamp')
.text()
expect(initialRootLayoutTimestamp).toBe(finalRootLayoutTimestamp)
return browser
}
registerTests({
next,
isNextDev,
isNextStart,
isTurbopack,
isClientNav,
navigateTo,
expectNoDevValidationErrors,
getCliOutputSinceMark,
prerender,
})
})
})
}