Skip to content

Commit b1ab340

Browse files
committed
abort and report on sync IO errors in dev
1 parent 31f1820 commit b1ab340

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'
@@ -2789,6 +2790,7 @@ async function renderWithRestartOnCacheMissInDev(
27892790
htmlRequestId,
27902791
renderOpts,
27912792
requestId,
2793+
workStore,
27922794
componentMod: {
27932795
routeModule: {
27942796
userland: { loaderTree },
@@ -2803,14 +2805,22 @@ async function renderWithRestartOnCacheMissInDev(
28032805
} = renderOpts
28042806
assertClientReferenceManifest(clientReferenceManifest)
28052807

2808+
const captureOwnerStack = ComponentMod.captureOwnerStack
2809+
2810+
// Check if any segment of the current page has runtime prefetching enabled.
2811+
// Note that if we're in a client navigation, this config might come from
2812+
// a shared layout parent that won't actually be rendered here.
2813+
// However, if the parent is runtime prefetchable, then all of its children
2814+
// can potentially run as part of a runtime prefetch, so it makes sense to validate them.
28062815
const hasRuntimePrefetch =
28072816
await anySegmentHasRuntimePrefetchEnabled(loaderTree)
28082817

28092818
// If the render is restarted, we'll recreate a fresh request store
28102819
let requestStore: RequestStore = initialRequestStore
28112820

28122821
const environmentName = () => {
2813-
const currentStage = requestStore.stagedRendering!.currentStage
2822+
const { stagedRendering } = requestStore as DevRequestStoreModern
2823+
const currentStage = stagedRendering.currentStage
28142824
switch (currentStage) {
28152825
case RenderStage.Static:
28162826
return 'Prerender'
@@ -2824,6 +2834,25 @@ async function renderWithRestartOnCacheMissInDev(
28242834
}
28252835
}
28262836

2837+
const throwIfInvalidDynamic = (expectedStage: RenderStage) => {
2838+
const { stagedRendering, dynamicTracking } =
2839+
requestStore as DevRequestStoreModern
2840+
if (
2841+
expectedStage !== RenderStage.Dynamic &&
2842+
// Sync IO errors advance us to the dynamic stage.
2843+
stagedRendering.currentStage === RenderStage.Dynamic
2844+
) {
2845+
// We should always have an error set, but be defensive
2846+
if (dynamicTracking.syncDynamicErrorWithStack) {
2847+
throw dynamicTracking.syncDynamicErrorWithStack
2848+
}
2849+
}
2850+
2851+
if (workStore.invalidDynamicUsageError) {
2852+
throw workStore.invalidDynamicUsageError
2853+
}
2854+
}
2855+
28272856
//===============================================
28282857
// Initial render
28292858
//===============================================
@@ -2861,6 +2890,10 @@ async function renderWithRestartOnCacheMissInDev(
28612890
),
28622891
prerenderResumeDataCache,
28632892
cacheSignal,
2893+
dynamicTracking: createDynamicTrackingState(
2894+
false // isDebugDynamicAccesses
2895+
),
2896+
captureOwnerStack,
28642897
} satisfies DevStoreModernPartial)
28652898

28662899
let debugChannel = setReactDebugChannel && createDebugChannel()
@@ -2899,6 +2932,8 @@ async function renderWithRestartOnCacheMissInDev(
28992932
return stream
29002933
},
29012934
(stream) => {
2935+
throwIfInvalidDynamic(/* expected stage */ RenderStage.Static)
2936+
29022937
// Runtime stage
29032938
initialStageController.advanceStage(RenderStage.Runtime)
29042939

@@ -2912,7 +2947,9 @@ async function renderWithRestartOnCacheMissInDev(
29122947
// and see if there's any cache misses in the runtime stage.
29132948
return stream
29142949
},
2915-
async (maybeStream) => {
2950+
(maybeStream) => {
2951+
throwIfInvalidDynamic(/* expected stage */ RenderStage.Runtime)
2952+
29162953
// Dynamic stage
29172954

29182955
// If we had cache misses in either of the previous stages,
@@ -2954,6 +2991,10 @@ async function renderWithRestartOnCacheMissInDev(
29542991

29552992
await cacheSignal.cacheReady()
29562993
initialReactController.abort()
2994+
throwIfInvalidDynamic(
2995+
// If we're warming caches, we shouldn't have advanced past the runtime stage.
2996+
RenderStage.Runtime
2997+
)
29572998

29582999
//===============================================
29593000
// Final render (restarted)
@@ -2979,6 +3020,10 @@ async function renderWithRestartOnCacheMissInDev(
29793020
),
29803021
prerenderResumeDataCache: null,
29813022
cacheSignal: null,
3023+
dynamicTracking: createDynamicTrackingState(
3024+
false // isDebugDynamicAccesses
3025+
),
3026+
captureOwnerStack: ComponentMod.captureOwnerStack,
29823027
} satisfies DevStoreModernPartial)
29833028

29843029
// The initial render already wrote to its debug channel.
@@ -3002,11 +3047,15 @@ async function renderWithRestartOnCacheMissInDev(
30023047
)
30033048
},
30043049
(stream) => {
3050+
throwIfInvalidDynamic(/* expected stage */ RenderStage.Static)
3051+
30053052
// Runtime stage
30063053
finalStageController.advanceStage(RenderStage.Runtime)
30073054
return stream
30083055
},
30093056
(stream) => {
3057+
throwIfInvalidDynamic(/* expected stage */ RenderStage.Runtime)
3058+
30103059
// Dynamic stage
30113060
finalStageController.advanceStage(RenderStage.Dynamic)
30123061
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)