Skip to content

Commit 8f106c7

Browse files
committed
abort and report on sync IO errors in dev
1 parent a1a25cf commit 8f106c7

File tree

5 files changed

+95
-11
lines changed

5 files changed

+95
-11
lines changed

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

Lines changed: 51 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import {
1717
} from '../app-render/work-async-storage.external'
1818
import type {
1919
DevStoreModernPartial,
20+
DevRequestStoreModern,
2021
PrerenderStoreModernRuntime,
2122
RequestStore,
2223
} from '../app-render/work-unit-async-storage.external'
@@ -2774,6 +2775,7 @@ async function renderWithRestartOnCacheMissInDev(
27742775
htmlRequestId,
27752776
renderOpts,
27762777
requestId,
2778+
workStore,
27772779
componentMod: {
27782780
routeModule: {
27792781
userland: { loaderTree },
@@ -2788,14 +2790,22 @@ async function renderWithRestartOnCacheMissInDev(
27882790
} = renderOpts
27892791
assertClientReferenceManifest(clientReferenceManifest)
27902792

2793+
const captureOwnerStack = ComponentMod.captureOwnerStack
2794+
2795+
// Check if any segment of the current page has runtime prefetching enabled.
2796+
// Note that if we're in a client navigation, this config might come from
2797+
// a shared layout parent that won't actually be rendered here.
2798+
// However, if the parent is runtime prefetchable, then all of its children
2799+
// can potentially run as part of a runtime prefetch, so it makes sense to validate them.
27912800
const hasRuntimePrefetch =
27922801
await anySegmentHasRuntimePrefetchEnabled(loaderTree)
27932802

27942803
// If the render is restarted, we'll recreate a fresh request store
27952804
let requestStore: RequestStore = initialRequestStore
27962805

27972806
const environmentName = () => {
2798-
const currentStage = requestStore.stagedRendering!.currentStage
2807+
const { stagedRendering } = requestStore as DevRequestStoreModern
2808+
const currentStage = stagedRendering.currentStage
27992809
switch (currentStage) {
28002810
case RenderStage.Static:
28012811
return 'Prerender'
@@ -2809,6 +2819,25 @@ async function renderWithRestartOnCacheMissInDev(
28092819
}
28102820
}
28112821

2822+
const throwIfInvalidDynamic = (expectedStage: RenderStage) => {
2823+
const { stagedRendering, dynamicTracking } =
2824+
requestStore as DevRequestStoreModern
2825+
if (
2826+
expectedStage !== RenderStage.Dynamic &&
2827+
// Sync IO errors advance us to the dynamic stage.
2828+
stagedRendering.currentStage === RenderStage.Dynamic
2829+
) {
2830+
// We should always have an error set, but be defensive
2831+
if (dynamicTracking.syncDynamicErrorWithStack) {
2832+
throw dynamicTracking.syncDynamicErrorWithStack
2833+
}
2834+
}
2835+
2836+
if (workStore.invalidDynamicUsageError) {
2837+
throw workStore.invalidDynamicUsageError
2838+
}
2839+
}
2840+
28122841
//===============================================
28132842
// Initial render
28142843
//===============================================
@@ -2846,6 +2875,10 @@ async function renderWithRestartOnCacheMissInDev(
28462875
),
28472876
prerenderResumeDataCache,
28482877
cacheSignal,
2878+
dynamicTracking: createDynamicTrackingState(
2879+
false // isDebugDynamicAccesses
2880+
),
2881+
captureOwnerStack,
28492882
} satisfies DevStoreModernPartial)
28502883

28512884
let debugChannel = setReactDebugChannel && createDebugChannel()
@@ -2884,6 +2917,8 @@ async function renderWithRestartOnCacheMissInDev(
28842917
return stream
28852918
},
28862919
(stream) => {
2920+
throwIfInvalidDynamic(/* expected stage */ RenderStage.Static)
2921+
28872922
// Runtime stage
28882923
initialStageController.advanceStage(RenderStage.Runtime)
28892924

@@ -2897,7 +2932,9 @@ async function renderWithRestartOnCacheMissInDev(
28972932
// and see if there's any cache misses in the runtime stage.
28982933
return stream
28992934
},
2900-
async (maybeStream) => {
2935+
(maybeStream) => {
2936+
throwIfInvalidDynamic(/* expected stage */ RenderStage.Runtime)
2937+
29012938
// Dynamic stage
29022939

29032940
// If we had cache misses in either of the previous stages,
@@ -2939,6 +2976,10 @@ async function renderWithRestartOnCacheMissInDev(
29392976

29402977
await cacheSignal.cacheReady()
29412978
initialReactController.abort()
2979+
throwIfInvalidDynamic(
2980+
// If we're warming caches, we shouldn't have advanced past the runtime stage.
2981+
RenderStage.Runtime
2982+
)
29422983

29432984
//===============================================
29442985
// Final render (restarted)
@@ -2964,6 +3005,10 @@ async function renderWithRestartOnCacheMissInDev(
29643005
),
29653006
prerenderResumeDataCache: null,
29663007
cacheSignal: null,
3008+
dynamicTracking: createDynamicTrackingState(
3009+
false // isDebugDynamicAccesses
3010+
),
3011+
captureOwnerStack: ComponentMod.captureOwnerStack,
29673012
} satisfies DevStoreModernPartial)
29683013

29693014
// The initial render already wrote to its debug channel.
@@ -2987,11 +3032,15 @@ async function renderWithRestartOnCacheMissInDev(
29873032
)
29883033
},
29893034
(stream) => {
3035+
throwIfInvalidDynamic(/* expected stage */ RenderStage.Static)
3036+
29903037
// Runtime stage
29913038
finalStageController.advanceStage(RenderStage.Runtime)
29923039
return stream
29933040
},
29943041
(stream) => {
3042+
throwIfInvalidDynamic(/* expected stage */ RenderStage.Runtime)
3043+
29953044
// Dynamic stage
29963045
finalStageController.advanceStage(RenderStage.Dynamic)
29973046
return stream

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

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -23,10 +23,10 @@
2323
import type { WorkStore } from '../app-render/work-async-storage.external'
2424
import type {
2525
WorkUnitStore,
26-
RequestStore,
2726
PrerenderStoreLegacy,
2827
PrerenderStoreModern,
2928
PrerenderStoreModernRuntime,
29+
DevRequestStoreModern,
3030
} from '../app-render/work-unit-async-storage.external'
3131

3232
// Once postpone is in stable we should switch to importing the postpone export directly
@@ -296,14 +296,18 @@ export function abortOnSynchronousPlatformIOAccess(
296296
}
297297

298298
export function trackSynchronousPlatformIOAccessInDev(
299-
requestStore: RequestStore
299+
requestStore: DevRequestStoreModern,
300+
errorWithStack: Error
300301
): void {
302+
const { stagedRendering, dynamicTracking } = requestStore
301303
// We don't actually have a controller to abort but we do the semantic equivalent by
302304
// advancing the request store out of the prerender stage
303-
if (requestStore.stagedRendering) {
304-
// TODO: error for sync IO in the runtime stage
305-
// (which is not currently covered by the validation render in `spawnDynamicValidationInDev`)
306-
requestStore.stagedRendering.advanceStage(RenderStage.Dynamic)
305+
306+
// TODO: error for sync IO in the runtime stage
307+
// (which is not currently covered by the validation render in `spawnDynamicValidationInDev`)
308+
stagedRendering.advanceStage(RenderStage.Dynamic)
309+
if (dynamicTracking.syncDynamicErrorWithStack === null) {
310+
dynamicTracking.syncDynamicErrorWithStack = errorWithStack
307311
}
308312
}
309313

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@ export type RequestStore = ProdRequestStore | DevRequestStore
7171

7272
export type ProdRequestStore = BaseRequestStore & AllMissing<DevStore>
7373
export type DevRequestStore = BaseRequestStore & DevStore
74+
export type DevRequestStoreModern = BaseRequestStore & DevStoreModern
7475

7576
// If `cacheComponents` is enabled, we add multiple extra properties on the store.
7677
// We either want all of them to be present, or all of them to be undefined.
@@ -90,6 +91,8 @@ type DevStoreCommon = {
9091
export type DevStoreModernPartial = {
9192
readonly stagedRendering: StagedRenderingController
9293
readonly asyncApiPromises: DevAsyncApiPromises
94+
readonly captureOwnerStack: () => string | null
95+
readonly dynamicTracking: DynamicTrackingState
9396
} & (
9497
| {
9598
// In the initial render, we track and fill caches

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -263,7 +263,7 @@ function createRequestStoreImpl(
263263
serverComponentsHmrCache:
264264
serverComponentsHmrCache ||
265265
(globalThis as any).__serverComponentsHmrCache,
266-
devFallbackParams,
266+
devFallbackParams: devFallbackParams ?? null,
267267
}
268268
}
269269

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

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { workAsyncStorage } from '../app-render/work-async-storage.external'
22
import {
33
workUnitAsyncStorage,
4+
type DevRequestStoreModern,
45
type PrerenderStoreModern,
56
} from '../app-render/work-unit-async-storage.external'
67
import {
@@ -87,7 +88,31 @@ export function io(expression: string, type: ApiType) {
8788
}
8889
case 'request':
8990
if (process.env.NODE_ENV === 'development') {
90-
trackSynchronousPlatformIOAccessInDev(workUnitStore)
91+
if (workUnitStore.stagedRendering) {
92+
// If the prerender signal is already aborted we don't need to construct
93+
// any stacks because something else actually terminated the prerender.
94+
let message: string
95+
switch (type) {
96+
case 'time':
97+
message = `Route "${workStore.route}" used ${expression} before accessing either uncached data (e.g. \`fetch()\`) or \`connection()\`. Accessing the current time in a Server Component requires reading one of these data sources first. Alternatively, consider moving this expression into a Client Component or Cache Component. See more info here: https://nextjs.org/docs/messages/next-prerender-current-time`
98+
break
99+
case 'random':
100+
message = `Route "${workStore.route}" used ${expression} before accessing either uncached data (e.g. \`fetch()\`) or \`connection()\`. Accessing random values synchronously in a Server Component requires reading one of these data sources first. Alternatively, consider moving this expression into a Client Component or Cache Component. See more info here: https://nextjs.org/docs/messages/next-prerender-random`
101+
break
102+
case 'crypto':
103+
message = `Route "${workStore.route}" used ${expression} before accessing either uncached data (e.g. \`fetch()\`) or \`connection()\`. Accessing random cryptographic values synchronously in a Server Component requires reading one of these data sources first. Alternatively, consider moving this expression into a Client Component or Cache Component. See more info here: https://nextjs.org/docs/messages/next-prerender-crypto`
104+
break
105+
default:
106+
throw new InvariantError(
107+
'Unknown expression type in abortOnSynchronousPlatformIOAccess.'
108+
)
109+
}
110+
111+
trackSynchronousPlatformIOAccessInDev(
112+
workUnitStore,
113+
applyOwnerStack(new Error(message), workUnitStore)
114+
)
115+
}
91116
}
92117
break
93118
case 'prerender-ppr':
@@ -101,7 +126,10 @@ export function io(expression: string, type: ApiType) {
101126
}
102127
}
103128

104-
function applyOwnerStack(error: Error, workUnitStore: PrerenderStoreModern) {
129+
function applyOwnerStack(
130+
error: Error,
131+
workUnitStore: PrerenderStoreModern | DevRequestStoreModern
132+
) {
105133
// TODO: Instead of stitching the stacks here, we should log the original
106134
// error as-is when it occurs, and let `patchErrorInspect` handle adding the
107135
// owner stack, instead of logging it deferred in the `LogSafely` component

0 commit comments

Comments
 (0)