Skip to content

Commit 4e75a9c

Browse files
authored
Instant Insights: only report non-validatable if dev render is error free (vercel#93858)
When an error prevents validation from happening that same error likely shows up in the main dev render. To avoid double reporting in dev we silence notices about validation being incomplete if the main render has errors to report to the user. If no user facing errors exist then we will report the lack of validation as it's own issue to confront.
1 parent 30421ba commit 4e75a9c

8 files changed

Lines changed: 323 additions & 10 deletions

File tree

packages/next/src/server/app-render/app-render.tsx

Lines changed: 24 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1469,7 +1469,9 @@ async function generateDynamicFlightRenderResultWithStagesInDev(
14691469
isBuildTimePrerendering = false,
14701470
} = renderOpts
14711471

1472+
let didErrorObservably = false
14721473
function onFlightDataRenderError(err: DigestedError, silenceLog: boolean) {
1474+
didErrorObservably = true
14731475
return onInstrumentationRequestError?.(
14741476
err,
14751477
req,
@@ -1579,7 +1581,8 @@ async function generateDynamicFlightRenderResultWithStagesInDev(
15791581
ctx,
15801582
finalRequestStore,
15811583
fallbackParams,
1582-
validationDebugChannelClient
1584+
validationDebugChannelClient,
1585+
didErrorObservably
15831586
)
15841587
} else {
15851588
logValidationSkipped(ctx)
@@ -3401,7 +3404,11 @@ async function renderToStream(
34013404
return getTracer().withSpan(renderSpan, async () => {
34023405
// MARK: renderToStream errorHandlers
34033406
const { reactServerErrorsByDigest } = workStore
3407+
3408+
// We use this to determine if we should suppress other derivative errors
3409+
let didErrorObservably = false
34043410
function onHTMLRenderRSCError(err: DigestedError, silenceLog: boolean) {
3411+
didErrorObservably = true
34053412
return onInstrumentationRequestError?.(
34063413
err,
34073414
req,
@@ -3533,7 +3540,8 @@ async function renderToStream(
35333540
ctx,
35343541
finalRequestStore,
35353542
fallbackParams,
3536-
validationDebugChannelClient
3543+
validationDebugChannelClient,
3544+
didErrorObservably
35373545
)
35383546

35393547
reactServerResult = new ReactServerResult(serverStream)
@@ -3652,7 +3660,8 @@ async function renderToStream(
36523660
ctx,
36533661
finalRequestStore,
36543662
fallbackParams,
3655-
validationDebugChannelClient
3663+
validationDebugChannelClient,
3664+
didErrorObservably
36563665
)
36573666

36583667
reactServerResult = new ReactServerResult(serverStream)
@@ -5604,7 +5613,8 @@ async function spawnStaticShellValidationInDevImpl(
56045613
ctx: AppRenderContext,
56055614
requestStore: RequestStore,
56065615
fallbackRouteParams: OpaqueFallbackRouteParams | null,
5607-
debugChannelClient: AnyStream | undefined
5616+
debugChannelClient: AnyStream | undefined,
5617+
devRenderDidError: boolean
56085618
): Promise<void> {
56095619
const debug =
56105620
process.env.NEXT_PRIVATE_DEBUG_VALIDATION === '1' ? console.log : undefined
@@ -5735,7 +5745,8 @@ async function spawnStaticShellValidationInDevImpl(
57355745
fallbackRouteParams,
57365746
ctx,
57375747
hmrRefreshHash,
5738-
validationSamples
5748+
validationSamples,
5749+
devRenderDidError
57395750
)
57405751

57415752
if (instantConfigsResult.length > 0) {
@@ -6093,7 +6104,8 @@ async function validateInstantConfigs(
60936104
fallbackRouteParams: OpaqueFallbackRouteParams | null,
60946105
ctx: AppRenderContext,
60956106
hmrRefreshHash: string | undefined,
6096-
validationSamples: ValidationStoreClient['validationSamples'] | null
6107+
validationSamples: ValidationStoreClient['validationSamples'] | null,
6108+
devRenderDidError: boolean
60976109
): Promise<Array<unknown>> {
60986110
const debug =
60996111
process.env.NEXT_PRIVATE_DEBUG_VALIDATION === '1' ? console.log : undefined
@@ -6340,15 +6352,17 @@ async function validateInstantConfigs(
63406352
preludeIsEmpty ? PreludeState.Empty : PreludeState.Full,
63416353
instantValidationState,
63426354
validationSampleTracking,
6343-
boundaryState
6355+
boundaryState,
6356+
devRenderDidError
63446357
)
63456358
} catch (thrownValue) {
63466359
result = getNavigationDisallowedDynamicReasons(
63476360
workStore,
63486361
PreludeState.Errored,
63496362
instantValidationState,
63506363
validationSampleTracking,
6351-
boundaryState
6364+
boundaryState,
6365+
devRenderDidError
63526366
)
63536367
}
63546368

@@ -7109,7 +7123,8 @@ async function validateInstantConfigInBuildWithSample(
71097123
fallbackRouteParams,
71107124
validationCtx,
71117125
undefined, // hmrRefreshHash,
7112-
validationSamples
7126+
validationSamples,
7127+
false // build has no shared dev render that would surface errors
71137128
)
71147129
})
71157130
}

packages/next/src/server/app-render/dynamic-rendering.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1390,7 +1390,8 @@ export function getNavigationDisallowedDynamicReasons(
13901390
prelude: PreludeState,
13911391
dynamicValidation: InstantValidationState,
13921392
validationSampleTracking: InstantValidationSampleTracking | null,
1393-
boundaryState: ValidationBoundaryTracking
1393+
boundaryState: ValidationBoundaryTracking,
1394+
devRenderDidError: boolean
13941395
): NavigationValidationResult {
13951396
// If we have errors related to missing samples, those should take precedence over everything else.
13961397
if (validationSampleTracking) {
@@ -1402,6 +1403,12 @@ export function getNavigationDisallowedDynamicReasons(
14021403

14031404
const { validationPreventingErrors } = dynamicValidation
14041405
if (validationPreventingErrors.length > 0) {
1406+
if (process.env.__NEXT_DEV_SERVER && devRenderDidError) {
1407+
// The dev render already surfaced server errors to the user.
1408+
// The same errors likely caused validation to be inconclusive,
1409+
// so reporting them again as validation failures would be noisy.
1410+
return []
1411+
}
14051412
return validationPreventingErrors
14061413
}
14071414

@@ -1480,6 +1487,11 @@ export function getNavigationDisallowedDynamicReasons(
14801487
}
14811488
const error = new Error(message)
14821489
return error
1490+
} else if (process.env.__NEXT_DEV_SERVER && devRenderDidError) {
1491+
// Errors outside the boundary likely blocked it from rendering,
1492+
// but they're already being reported to the user via the dev
1493+
// render. Suppress the validation failure to avoid noise.
1494+
return []
14831495
} else if (thrownErrorsOutsideBoundary.length === 1) {
14841496
const message = `Route "${workStore.route}": Could not validate \`unstable_instant\` because the target segment was prevented from rendering, likely due to the following error.`
14851497
const error = rootInstantStack !== null ? rootInstantStack() : new Error()

test/e2e/app-dir/instant-validation/app/suspense-in-root/page.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,12 @@ export default async function Page() {
141141
<li>
142142
<DebugLinks href="/suspense-in-root/static/valid-client-error-in-parent-does-not-block-validation" />
143143
</li>
144+
<li>
145+
<DebugLinks href="/suspense-in-root/static/server-error-blocks-children" />
146+
</li>
147+
<li>
148+
<DebugLinks href="/suspense-in-root/static/server-error-inside-boundary" />
149+
</li>
144150
<li>
145151
<DebugLinks href="/suspense-in-root/static/false-below-static" />
146152
</li>
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import { Suspense, type ReactNode } from 'react'
2+
import { connection } from 'next/server'
3+
4+
export const unstable_instant = false
5+
6+
function ServerError() {
7+
throw new Error('Server component error')
8+
}
9+
10+
export default async function Layout({ children }: { children: ReactNode }) {
11+
await connection()
12+
return (
13+
<>
14+
<p>
15+
This layout renders a server component that throws. The error is caught
16+
by a Suspense boundary but wraps the children slot, preventing the
17+
instant page from rendering.
18+
</p>
19+
<Suspense>
20+
<ServerError />
21+
{children}
22+
</Suspense>
23+
</>
24+
)
25+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
export const unstable_instant = { level: 'experimental-error' }
2+
3+
export default function Page() {
4+
return (
5+
<main>
6+
<p>
7+
This page has instant validation but a server error in the parent layout
8+
blocks it from rendering.
9+
</p>
10+
</main>
11+
)
12+
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import { type ReactNode } from 'react'
2+
import { connection } from 'next/server'
3+
4+
export const unstable_instant = false
5+
6+
function ServerError() {
7+
throw new Error('Server component error inside boundary')
8+
}
9+
10+
export default async function Layout({ children }: { children: ReactNode }) {
11+
await connection()
12+
return (
13+
<>
14+
<p>
15+
This layout renders a server component that throws without a Suspense
16+
boundary. The error is inside the validation boundary so it prevents
17+
validation from completing.
18+
</p>
19+
<ServerError />
20+
{children}
21+
</>
22+
)
23+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
export const unstable_instant = { level: 'experimental-error' }
2+
3+
export default function Page() {
4+
return (
5+
<main>
6+
<p>
7+
This page has instant validation but a server error in the parent layout
8+
is inside the boundary without a Suspense guard.
9+
</p>
10+
</main>
11+
)
12+
}

0 commit comments

Comments
 (0)