Skip to content

Commit 2d19dc6

Browse files
authored
[dynamicIO] Use owner stacks for dynamic validation errors (#81277)
With recent improvements in React, and after switching to the React builds for Node.js in #81048, we can now generate better dynamic validation error stacks in dev mode by utilizing owner stacks instead of component stacks. For async I/O, we're using the owner stack exclusively. For sync I/O we're appending the owner stack to the call stack. In a future iteration we might instead log the original error as-is where it occurs, and let `patchErrorInspectNodeJS` handle the owner stack appending as a general solution. Additionally, we're now guiding users in the build-time error messages to using `next dev` for further debugging of dynamic validation errors – or, alternatively, using `next build --debug-prerender`. closes NAR-149
1 parent 8d78992 commit 2d19dc6

File tree

16 files changed

+339
-157
lines changed

16 files changed

+339
-157
lines changed

packages/next/src/export/index.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -410,6 +410,14 @@ async function exportAppImpl(
410410
authInterrupts: !!nextConfig.experimental.authInterrupts,
411411
},
412412
reactMaxHeadersLength: nextConfig.reactMaxHeadersLength,
413+
hasReadableErrorStacks:
414+
nextConfig.experimental.serverSourceMaps === true &&
415+
// TODO(NDX-531): Checking (and setting) the minify flags should be
416+
// unnecessary once name mapping is fixed.
417+
(process.env.TURBOPACK
418+
? nextConfig.experimental.turbopackMinify === false
419+
: nextConfig.experimental.serverMinification === false) &&
420+
nextConfig.experimental.enablePrerenderSourceMaps === true,
413421
}
414422

415423
const { publicRuntimeConfig } = nextConfig

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

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -664,7 +664,7 @@ async function warmupDevRender(
664664
): Promise<RenderResult> {
665665
const {
666666
clientReferenceManifest,
667-
componentMod,
667+
componentMod: ComponentMod,
668668
getDynamicParamFromSegment,
669669
implicitTags,
670670
renderOpts,
@@ -684,7 +684,7 @@ async function warmupDevRender(
684684
}
685685

686686
const rootParams = getRootParams(
687-
componentMod.tree,
687+
ComponentMod.tree,
688688
getDynamicParamFromSegment
689689
)
690690

@@ -725,6 +725,7 @@ async function warmupDevRender(
725725
prerenderResumeDataCache,
726726
renderResumeDataCache: null,
727727
hmrRefreshHash: req.cookies[NEXT_HMR_REFRESH_HASH_COOKIE],
728+
captureOwnerStack: ComponentMod.captureOwnerStack,
728729
}
729730

730731
const rscPayload = await workUnitAsyncStorage.run(
@@ -737,7 +738,7 @@ async function warmupDevRender(
737738
// which contains the subset React.
738739
workUnitAsyncStorage.run(
739740
prerenderStore,
740-
componentMod.renderToReadableStream,
741+
ComponentMod.renderToReadableStream,
741742
rscPayload,
742743
clientReferenceManifest.clientModules,
743744
{
@@ -2309,6 +2310,9 @@ async function spawnDynamicValidationInDev(
23092310
// to cut the render off.
23102311
const cacheSignal = new CacheSignal()
23112312

2313+
const captureOwnerStackClient = React.captureOwnerStack
2314+
const captureOwnerStackServer = ComponentMod.captureOwnerStack
2315+
23122316
// The resume data cache here should use a fresh instance as it's
23132317
// performing a fresh prerender. If we get to implementing the
23142318
// prerendering of an already prerendered page, we should use the passed
@@ -2334,6 +2338,7 @@ async function spawnDynamicValidationInDev(
23342338
prerenderResumeDataCache,
23352339
renderResumeDataCache: null,
23362340
hmrRefreshHash,
2341+
captureOwnerStack: captureOwnerStackServer,
23372342
}
23382343

23392344
// We're not going to use the result of this render because the only time it could be used
@@ -2448,6 +2453,7 @@ async function spawnDynamicValidationInDev(
24482453
prerenderResumeDataCache,
24492454
renderResumeDataCache: null,
24502455
hmrRefreshHash: undefined,
2456+
captureOwnerStack: captureOwnerStackClient,
24512457
}
24522458

24532459
const prerender = (
@@ -2541,6 +2547,7 @@ async function spawnDynamicValidationInDev(
25412547
prerenderResumeDataCache,
25422548
renderResumeDataCache: null,
25432549
hmrRefreshHash,
2550+
captureOwnerStack: captureOwnerStackServer,
25442551
}
25452552

25462553
const finalAttemptRSCPayload = await workUnitAsyncStorage.run(
@@ -2611,6 +2618,7 @@ async function spawnDynamicValidationInDev(
26112618
prerenderResumeDataCache,
26122619
renderResumeDataCache: null,
26132620
hmrRefreshHash,
2621+
captureOwnerStack: captureOwnerStackClient,
26142622
}
26152623

26162624
let dynamicValidation = createDynamicValidationState()
@@ -2643,7 +2651,7 @@ async function spawnDynamicValidationInDev(
26432651
const componentStack = errorInfo.componentStack
26442652
if (typeof componentStack === 'string') {
26452653
trackAllowedDynamicAccess(
2646-
workStore.route,
2654+
workStore,
26472655
componentStack,
26482656
dynamicValidation,
26492657
clientDynamicTracking
@@ -2957,6 +2965,7 @@ async function prerenderToStream(
29572965
prerenderResumeDataCache,
29582966
renderResumeDataCache,
29592967
hmrRefreshHash: undefined,
2968+
captureOwnerStack: undefined, // Not available in production.
29602969
})
29612970

29622971
// We're not going to use the result of this render because the only time it could be used
@@ -3064,6 +3073,7 @@ async function prerenderToStream(
30643073
prerenderResumeDataCache,
30653074
renderResumeDataCache,
30663075
hmrRefreshHash: undefined,
3076+
captureOwnerStack: undefined, // Not available in production.
30673077
}
30683078

30693079
const prerender = (
@@ -3157,6 +3167,7 @@ async function prerenderToStream(
31573167
prerenderResumeDataCache,
31583168
renderResumeDataCache,
31593169
hmrRefreshHash: undefined,
3170+
captureOwnerStack: undefined, // Not available in production.
31603171
})
31613172

31623173
const finalAttemptRSCPayload = await workUnitAsyncStorage.run(
@@ -3229,6 +3240,7 @@ async function prerenderToStream(
32293240
prerenderResumeDataCache,
32303241
renderResumeDataCache,
32313242
hmrRefreshHash: undefined,
3243+
captureOwnerStack: undefined, // Not available in production.
32323244
}
32333245

32343246
let clientIsDynamic = false
@@ -3265,7 +3277,7 @@ async function prerenderToStream(
32653277
).componentStack
32663278
if (typeof componentStack === 'string') {
32673279
trackAllowedDynamicAccess(
3268-
workStore.route,
3280+
workStore,
32693281
componentStack,
32703282
dynamicValidation,
32713283
clientDynamicTracking

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

Lines changed: 34 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -616,7 +616,7 @@ const hasViewportRegex = new RegExp(
616616
const hasOutletRegex = new RegExp(`\\n\\s+at ${OUTLET_BOUNDARY_NAME}[\\n\\s]`)
617617

618618
export function trackAllowedDynamicAccess(
619-
route: string,
619+
workStore: WorkStore,
620620
componentStack: string,
621621
dynamicValidation: DynamicValidationState,
622622
clientDynamic: DynamicTrackingState
@@ -648,19 +648,25 @@ export function trackAllowedDynamicAccess(
648648
)
649649
return
650650
} else {
651-
const message = `Route "${route}": A component accessed data, headers, params, searchParams, or a short-lived cache without a Suspense boundary nor a "use cache" above it. We don't have the exact line number added to error messages yet but you can see which component in the stack below. See more info: https://nextjs.org/docs/messages/next-prerender-missing-suspense`
652-
const error = createErrorWithComponentStack(message, componentStack)
651+
const message = `Route "${workStore.route}": A component accessed data, headers, params, searchParams, or a short-lived cache without a Suspense boundary nor a "use cache" above it. See more info: https://nextjs.org/docs/messages/next-prerender-missing-suspense`
652+
const error = createErrorWithComponentOrOwnerStack(message, componentStack)
653653
dynamicValidation.dynamicErrors.push(error)
654654
return
655655
}
656656
}
657657

658-
function createErrorWithComponentStack(
658+
/**
659+
* In dev mode, we prefer using the owner stack, otherwise the provided
660+
* component stack is used.
661+
*/
662+
function createErrorWithComponentOrOwnerStack(
659663
message: string,
660664
componentStack: string
661665
) {
666+
const ownerStack =
667+
process.env.NODE_ENV !== 'production' ? React.captureOwnerStack() : null
662668
const error = new Error(message)
663-
error.stack = 'Error: ' + message + componentStack
669+
error.stack = error.name + ': ' + message + (ownerStack ?? componentStack)
664670
return error
665671
}
666672

@@ -670,14 +676,30 @@ export enum PreludeState {
670676
Errored = 2,
671677
}
672678

679+
function logDisallowedDynamicError(workStore: WorkStore, error: Error): void {
680+
console.error(error)
681+
682+
if (!workStore.dev) {
683+
if (workStore.hasReadableErrorStacks) {
684+
console.error(
685+
`To get a more detailed stack trace and pinpoint the issue, start the app in development mode by running \`next dev\`, then open "${workStore.route}" in your browser to investigate the error.`
686+
)
687+
} else {
688+
console.error(`To get a more detailed stack trace and pinpoint the issue, try one of the following:
689+
- Start the app in development mode by running \`next dev\`, then open "${workStore.route}" in your browser to investigate the error.
690+
- Rerun the production build with \`next build --debug-prerender\` to generate better stack traces.`)
691+
}
692+
}
693+
}
694+
673695
export function throwIfDisallowedDynamic(
674696
workStore: WorkStore,
675697
prelude: PreludeState,
676698
dynamicValidation: DynamicValidationState,
677699
serverDynamic: DynamicTrackingState
678700
): void {
679701
if (workStore.invalidDynamicUsageError) {
680-
console.error(workStore.invalidDynamicUsageError)
702+
logDisallowedDynamicError(workStore, workStore.invalidDynamicUsageError)
681703
throw new StaticGenBailoutError()
682704
}
683705

@@ -692,9 +714,11 @@ export function throwIfDisallowedDynamic(
692714
if (serverDynamic.syncDynamicErrorWithStack) {
693715
// There is no shell and the server did something sync dynamic likely
694716
// leading to an early termination of the prerender before the shell
695-
// could be completed.
696-
console.error(serverDynamic.syncDynamicErrorWithStack)
697-
// We terminate the build/validating render
717+
// could be completed. We terminate the build/validating render.
718+
logDisallowedDynamicError(
719+
workStore,
720+
serverDynamic.syncDynamicErrorWithStack
721+
)
698722
throw new StaticGenBailoutError()
699723
}
700724

@@ -704,7 +728,7 @@ export function throwIfDisallowedDynamic(
704728
const dynamicErrors = dynamicValidation.dynamicErrors
705729
if (dynamicErrors.length > 0) {
706730
for (let i = 0; i < dynamicErrors.length; i++) {
707-
console.error(dynamicErrors[i])
731+
logDisallowedDynamicError(workStore, dynamicErrors[i])
708732
}
709733

710734
throw new StaticGenBailoutError()

packages/next/src/server/app-render/entry-base.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,9 @@ export {
1010
// eslint-disable-next-line import/no-extraneous-dependencies
1111
export { unstable_prerender as prerender } from 'react-server-dom-webpack/static'
1212

13+
// eslint-disable-next-line import/no-extraneous-dependencies
14+
export { captureOwnerStack } from 'react'
15+
1316
export { default as LayoutRouter } from '../../client/components/layout-router'
1417
export { default as RenderFromTemplateContext } from '../../client/components/render-from-template-context'
1518
export { workAsyncStorage } from '../app-render/work-async-storage.external'

packages/next/src/server/app-render/types.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -263,6 +263,14 @@ export interface RenderOptsPartial {
263263
*/
264264
isDebugDynamicAccesses?: boolean
265265

266+
/**
267+
* This is true when:
268+
* - source maps are generated
269+
* - source maps are applied
270+
* - minification is disabled
271+
*/
272+
hasReadableErrorStacks?: boolean
273+
266274
/**
267275
* The maximum length of the headers that are emitted by React and added to
268276
* the response.

packages/next/src/server/app-render/work-async-storage.external.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,15 @@ export interface WorkStore {
3636

3737
readonly isOnDemandRevalidate?: boolean
3838
readonly isBuildTimePrerendering?: boolean
39+
40+
/**
41+
* This is true when:
42+
* - source maps are generated
43+
* - source maps are applied
44+
* - minification is disabled
45+
*/
46+
readonly hasReadableErrorStacks?: boolean
47+
3948
readonly isRevalidate?: boolean
4049

4150
forceDynamic?: boolean

packages/next/src/server/app-render/work-unit-async-storage.external.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,11 @@ export interface PrerenderStoreModern extends CommonWorkUnitStore {
142142
* subsequent dynamic render.
143143
*/
144144
readonly hmrRefreshHash: string | undefined
145+
146+
/**
147+
* Only available in dev mode.
148+
*/
149+
readonly captureOwnerStack: undefined | (() => string | null)
145150
}
146151

147152
export interface PrerenderStorePPR extends CommonWorkUnitStore {

packages/next/src/server/async-storage/work-store.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ export type WorkStoreContext = {
6565
| 'isDraftMode'
6666
| 'isDebugDynamicAccesses'
6767
| 'dev'
68+
| 'hasReadableErrorStacks'
6869
> &
6970
RequestLifecycleOpts &
7071
Partial<Pick<RenderOpts, 'reactLoadableManifest'>>
@@ -123,6 +124,7 @@ export function createWorkStore({
123124
cacheLifeProfiles: renderOpts.cacheLifeProfiles,
124125
isRevalidate: renderOpts.isRevalidate,
125126
isBuildTimePrerendering: renderOpts.nextExport,
127+
hasReadableErrorStacks: renderOpts.hasReadableErrorStacks,
126128
fetchCache: renderOpts.fetchCache,
127129
isOnDemandRevalidate: renderOpts.isOnDemandRevalidate,
128130

packages/next/src/server/node-environment-extensions/utils.tsx

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,8 +44,22 @@ export function io(expression: string, type: ApiType) {
4444
'Unknown expression type in abortOnSynchronousPlatformIOAccess.'
4545
)
4646
}
47+
4748
const errorWithStack = new Error(message)
4849

50+
if (process.env.NODE_ENV !== 'production') {
51+
const ownerStack = workUnitStore.captureOwnerStack!()
52+
53+
if (ownerStack) {
54+
// TODO: Instead of stitching the stacks here, we should log the
55+
// original error as-is when it occurs (i.e. here), and let
56+
// `patchErrorInspect` handle adding the owner stack, instead of
57+
// logging it deferred in the `LogSafely` component via
58+
// `throwIfDisallowedDynamic`.
59+
applyOwnerStack(errorWithStack, ownerStack)
60+
}
61+
}
62+
4963
abortOnSynchronousPlatformIOAccess(
5064
workStore.route,
5165
expression,
@@ -63,3 +77,23 @@ export function io(expression: string, type: ApiType) {
6377
}
6478
}
6579
}
80+
81+
function applyOwnerStack(error: Error, ownerStack: string) {
82+
let stack = ownerStack
83+
84+
if (error.stack) {
85+
const frames: string[] = []
86+
87+
for (const frame of error.stack.split('\n').slice(1)) {
88+
if (frame.includes('react_stack_bottom_frame')) {
89+
break
90+
}
91+
92+
frames.push(frame)
93+
}
94+
95+
stack = '\n' + frames.join('\n') + stack
96+
}
97+
98+
error.stack = error.name + ': ' + error.message + stack
99+
}

packages/next/src/server/route-modules/app-route/module.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -401,6 +401,7 @@ export class AppRouteRouteModule extends RouteModule<
401401
prerenderResumeDataCache: null,
402402
renderResumeDataCache: null,
403403
hmrRefreshHash: undefined,
404+
captureOwnerStack: undefined,
404405
})
405406

406407
let prospectiveResult
@@ -492,6 +493,7 @@ export class AppRouteRouteModule extends RouteModule<
492493
prerenderResumeDataCache: null,
493494
renderResumeDataCache: null,
494495
hmrRefreshHash: undefined,
496+
captureOwnerStack: undefined,
495497
})
496498

497499
let responseHandled = false

0 commit comments

Comments
 (0)