mirror of
https://github.com/hanzoai/next.js.git
synced 2026-09-24 17:00:51 +00:00
This is a behavior-preserving refactor of the dev-mode Cache Components validation, ahead of the change that moves it onto a worker thread. In-process behavior is unchanged. It narrows the context the validation functions require. `runValidationInDev` and its helpers (`validateStaticShell`, `warmupClientModulesForStagedValidation`, `validateStagedShell`, `validateInstantConfigs`) now take a `ValidationRenderContext` (a `Pick` of `AppRenderContext` plus the two `renderOpts` fields and the debug-channel flag they actually read) instead of the full render context, with `toValidationRenderContext` mapping the in-process and build-time callers. The worker cannot reconstruct a full `AppRenderContext` from a serialized snapshot, so narrowing the contract to what validation genuinely consumes is what lets the same code run there. It also separates computing the validation errors from delivering them. `runValidationInDev` now returns the errors instead of sending them to the dev overlay inline, and the caller `runDevValidationInBackground` delivers them via `logMessagesAndSendErrorsToBrowser`. The test-mode lifecycle markers and delay move into `runWithDevValidationLogging`, which brackets both the render and the delivery. When validation runs on the worker, the worker computes the errors but the main thread still delivers them (delivery needs the response object), so splitting the two halves is a prerequisite. Finally, it extracts the test-only validation lifecycle markers into a shared `dev-validation-events` module. The `<VALIDATION_MESSAGE>` wrapping and the event shape had been hand-rolled at each emission site in `app-render.tsx` and duplicated again in the test harness; they now live in one place as `formatValidationEvent` and an exported `ValidationEvent` union that the in-process emitters call and the test utilities import rather than redeclare. The worker emits the same markers through the same helper once it is added, so a single definition keeps every producer and the test parser in sync. Build validation's marker `requestId`, previously the numeric `Date.now()`, is stringified to fit the shared `string` type; it appears only in the logged marker payload, which nothing matches on, so this too is unobservable.
221 lines
6.9 KiB
TypeScript
221 lines
6.9 KiB
TypeScript
import { nextTestSetup } from 'e2e-utils'
|
|
import { retry } from 'next-test-utils'
|
|
import type { ValidationEvent } from 'next/dist/server/app-render/dev-validation-events'
|
|
|
|
describe('instant validation causes', () => {
|
|
const { next, skipped, isNextDev } = nextTestSetup({
|
|
files: __dirname,
|
|
skipDeployment: true,
|
|
env: {
|
|
NEXT_TEST_LOG_VALIDATION: '1',
|
|
},
|
|
})
|
|
if (skipped) return
|
|
if (!isNextDev) {
|
|
it.skip('Only implemented in dev', () => {})
|
|
return
|
|
}
|
|
|
|
let currentCliOutputIndex = 0
|
|
beforeEach(() => {
|
|
currentCliOutputIndex = next.cliOutput.length
|
|
})
|
|
|
|
function getCliOutputSinceMark(): string {
|
|
if (next.cliOutput.length < currentCliOutputIndex) {
|
|
currentCliOutputIndex = 0
|
|
}
|
|
return next.cliOutput.slice(currentCliOutputIndex)
|
|
}
|
|
|
|
function parseValidationMessages(output: string): ValidationEvent[] {
|
|
const messageRe = /<VALIDATION_MESSAGE>(.*?)<\/VALIDATION_MESSAGE>/g
|
|
const events: ValidationEvent[] = []
|
|
let match: RegExpExecArray | null
|
|
while ((match = messageRe.exec(output)) !== null) {
|
|
try {
|
|
events.push(JSON.parse(match[1]))
|
|
} catch (err) {
|
|
throw new Error(`Failed to parse message '${match[1]}'`, {
|
|
cause: err,
|
|
})
|
|
}
|
|
}
|
|
return events
|
|
}
|
|
|
|
function normalizeValidationUrl(url: string): string {
|
|
const parsed = new URL(url, 'http://n')
|
|
parsed.searchParams.delete('_rsc')
|
|
return parsed.pathname + parsed.search + parsed.hash
|
|
}
|
|
|
|
async function waitForValidation(targetUrl: string) {
|
|
const parsedTargetUrl = new URL(targetUrl)
|
|
const relativeTargetUrl =
|
|
parsedTargetUrl.pathname + parsedTargetUrl.search + parsedTargetUrl.hash
|
|
|
|
const requestId = await retry(
|
|
async () => {
|
|
const events = parseValidationMessages(getCliOutputSinceMark())
|
|
const start = events.find(
|
|
(e) =>
|
|
e.type === 'validation_start' &&
|
|
normalizeValidationUrl(e.url) === relativeTargetUrl
|
|
)
|
|
expect(start).toBeDefined()
|
|
return start!.requestId
|
|
},
|
|
undefined,
|
|
undefined,
|
|
`wait for validation of '${relativeTargetUrl}' to start`
|
|
)
|
|
|
|
await retry(
|
|
async () => {
|
|
const events = parseValidationMessages(getCliOutputSinceMark())
|
|
const end = events.find(
|
|
(e) => e.type === 'validation_end' && e.requestId === requestId
|
|
)
|
|
expect(end).toBeDefined()
|
|
},
|
|
undefined,
|
|
undefined,
|
|
'wait for validation to end'
|
|
)
|
|
}
|
|
|
|
it('named export - export { instant }', async () => {
|
|
const browser = await next.browser('/named-export')
|
|
await waitForValidation(await browser.url())
|
|
await expect(browser).toDisplayCollapsedRedbox(`
|
|
{
|
|
"cause": [
|
|
{
|
|
"label": "Caused by: Instant Validation",
|
|
"source": "app/named-export/page.tsx (3:17) @ instant
|
|
> 3 | const instant = true
|
|
| ^",
|
|
"stack": [
|
|
"instant app/named-export/page.tsx (3:17)",
|
|
"Set.forEach <anonymous>",
|
|
],
|
|
},
|
|
],
|
|
"code": "E1437",
|
|
"description": "Next.js encountered uncached data during a navigation.",
|
|
"environmentLabel": "Server",
|
|
"label": "Instant",
|
|
"source": "app/named-export/page.tsx (7:19) @ Page
|
|
> 7 | await connection()
|
|
| ^",
|
|
"stack": [
|
|
"Page app/named-export/page.tsx (7:19)",
|
|
],
|
|
}
|
|
`)
|
|
})
|
|
|
|
it('aliased export - export { instantConfig as instant }', async () => {
|
|
const browser = await next.browser('/aliased-export')
|
|
await waitForValidation(await browser.url())
|
|
await expect(browser).toDisplayCollapsedRedbox(`
|
|
{
|
|
"cause": [
|
|
{
|
|
"label": "Caused by: Instant Validation",
|
|
"source": "app/aliased-export/page.tsx (3:23) @ instant
|
|
> 3 | const instantConfig = true
|
|
| ^",
|
|
"stack": [
|
|
"instant app/aliased-export/page.tsx (3:23)",
|
|
"Set.forEach <anonymous>",
|
|
],
|
|
},
|
|
],
|
|
"code": "E1437",
|
|
"description": "Next.js encountered uncached data during a navigation.",
|
|
"environmentLabel": "Server",
|
|
"label": "Instant",
|
|
"source": "app/aliased-export/page.tsx (7:19) @ Page
|
|
> 7 | await connection()
|
|
| ^",
|
|
"stack": [
|
|
"Page app/aliased-export/page.tsx (7:19)",
|
|
],
|
|
}
|
|
`)
|
|
})
|
|
|
|
it('re-export - export { instant } from "./config"', async () => {
|
|
const browser = await next.browser('/reexport')
|
|
await waitForValidation(await browser.url())
|
|
await expect(browser).toDisplayCollapsedRedbox(`
|
|
{
|
|
"cause": [
|
|
{
|
|
"label": "Caused by: Instant Validation",
|
|
"source": "app/reexport/page.tsx (3:10) @ instant
|
|
> 3 | export { instant } from './config'
|
|
| ^",
|
|
"stack": [
|
|
"instant app/reexport/page.tsx (3:10)",
|
|
"Set.forEach <anonymous>",
|
|
],
|
|
},
|
|
],
|
|
"code": "E1437",
|
|
"description": "Next.js encountered uncached data during a navigation.",
|
|
"environmentLabel": "Server",
|
|
"label": "Instant",
|
|
"source": "app/reexport/page.tsx (6:19) @ Page
|
|
> 6 | await connection()
|
|
| ^",
|
|
"stack": [
|
|
"Page app/reexport/page.tsx (6:19)",
|
|
],
|
|
}
|
|
`)
|
|
})
|
|
|
|
it('indirect export - const instantConfig = _instant; export { instantConfig as instant }', async () => {
|
|
const browser = await next.browser('/indirect-export')
|
|
await waitForValidation(await browser.url())
|
|
// Ideally we'd be pointing at the original value declaration.
|
|
// We're not following declarations recursively mostly to keep the implementation simpler
|
|
// presuming that almost all configs are just `export const instant = ...`
|
|
await expect(browser).toDisplayCollapsedRedbox(`
|
|
{
|
|
"cause": [
|
|
{
|
|
"label": "Caused by: Instant Validation",
|
|
"source": "app/indirect-export/page.tsx (4:23) @ instant
|
|
> 4 | const instantConfig = _instant
|
|
| ^",
|
|
"stack": [
|
|
"instant app/indirect-export/page.tsx (4:23)",
|
|
"Set.forEach <anonymous>",
|
|
],
|
|
},
|
|
],
|
|
"code": "E1437",
|
|
"description": "Next.js encountered uncached data during a navigation.",
|
|
"environmentLabel": "Server",
|
|
"label": "Instant",
|
|
"source": "app/indirect-export/page.tsx (8:19) @ Page
|
|
> 8 | await connection()
|
|
| ^",
|
|
"stack": [
|
|
"Page app/indirect-export/page.tsx (8:19)",
|
|
],
|
|
}
|
|
`)
|
|
})
|
|
|
|
it('does not add an instant stack for random instant exports', async () => {
|
|
const browser = await next.browser('/not-actual-instant')
|
|
const config = await browser.waitForElementByCss('[data-testid="config"]')
|
|
expect(await config.innerText()).toBe(JSON.stringify({ instant: false }))
|
|
})
|
|
})
|