Skip to content

Commit c4ae6b6

Browse files
committed
abort and report on sync IO errors in dev
1 parent 57a8d69 commit c4ae6b6

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'
@@ -2772,6 +2773,7 @@ async function renderWithRestartOnCacheMissInDev(
27722773
) {
27732774
const {
27742775
renderOpts,
2776+
workStore,
27752777
componentMod: {
27762778
routeModule: {
27772779
userland: { loaderTree },
@@ -2780,16 +2782,24 @@ async function renderWithRestartOnCacheMissInDev(
27802782
} = ctx
27812783
const { clientReferenceManifest, ComponentMod, setReactDebugChannel } =
27822784
renderOpts
2785+
const captureOwnerStack = ComponentMod.captureOwnerStack
2786+
27832787
assertClientReferenceManifest(clientReferenceManifest)
27842788

2789+
// Check if any segment of the current page has runtime prefetching enabled.
2790+
// Note that if we're in a client navigation, this config might come from
2791+
// a shared layout parent that won't actually be rendered here.
2792+
// However, if the parent is runtime prefetchable, then all of its children
2793+
// can potentially run as part of a runtime prefetch, so it makes sense to validate them.
27852794
const hasRuntimePrefetch =
27862795
await anySegmentHasRuntimePrefetchEnabled(loaderTree)
27872796

27882797
// If the render is restarted, we'll recreate a fresh request store
27892798
let requestStore: RequestStore = initialRequestStore
27902799

27912800
const environmentName = () => {
2792-
const currentStage = requestStore.stagedRendering!.currentStage
2801+
const { stagedRendering } = requestStore as DevRequestStoreModern
2802+
const currentStage = stagedRendering.currentStage
27932803
switch (currentStage) {
27942804
case RenderStage.Static:
27952805
return 'Prerender'
@@ -2803,6 +2813,25 @@ async function renderWithRestartOnCacheMissInDev(
28032813
}
28042814
}
28052815

2816+
const throwIfInvalidDynamic = (expectedStage: RenderStage) => {
2817+
const { stagedRendering, dynamicTracking } =
2818+
requestStore as DevRequestStoreModern
2819+
if (
2820+
expectedStage !== RenderStage.Dynamic &&
2821+
// Sync IO errors advance us to the dynamic stage.
2822+
stagedRendering.currentStage === RenderStage.Dynamic
2823+
) {
2824+
// We should always have an error set, but be defensive
2825+
if (dynamicTracking.syncDynamicErrorWithStack) {
2826+
throw dynamicTracking.syncDynamicErrorWithStack
2827+
}
2828+
}
2829+
2830+
if (workStore.invalidDynamicUsageError) {
2831+
throw workStore.invalidDynamicUsageError
2832+
}
2833+
}
2834+
28062835
//===============================================
28072836
// Initial render
28082837
//===============================================
@@ -2840,6 +2869,10 @@ async function renderWithRestartOnCacheMissInDev(
28402869
),
28412870
prerenderResumeDataCache,
28422871
cacheSignal,
2872+
dynamicTracking: createDynamicTrackingState(
2873+
false // isDebugDynamicAccesses
2874+
),
2875+
captureOwnerStack,
28432876
} satisfies DevStoreModernPartial)
28442877

28452878
let debugChannel = setReactDebugChannel && createDebugChannel()
@@ -2878,6 +2911,8 @@ async function renderWithRestartOnCacheMissInDev(
28782911
return stream
28792912
},
28802913
(stream) => {
2914+
throwIfInvalidDynamic(/* expected stage */ RenderStage.Static)
2915+
28812916
// Runtime stage
28822917
initialStageController.advanceStage(RenderStage.Runtime)
28832918

@@ -2891,7 +2926,9 @@ async function renderWithRestartOnCacheMissInDev(
28912926
// and see if there's any cache misses in the runtime stage.
28922927
return stream
28932928
},
2894-
async (maybeStream) => {
2929+
(maybeStream) => {
2930+
throwIfInvalidDynamic(/* expected stage */ RenderStage.Runtime)
2931+
28952932
// Dynamic stage
28962933

28972934
// If we had cache misses in either of the previous stages,
@@ -2929,6 +2966,10 @@ async function renderWithRestartOnCacheMissInDev(
29292966

29302967
await cacheSignal.cacheReady()
29312968
initialReactController.abort()
2969+
throwIfInvalidDynamic(
2970+
// If we're warming caches, we shouldn't have advanced past the runtime stage.
2971+
RenderStage.Runtime
2972+
)
29322973

29332974
//===============================================
29342975
// Final render (restarted)
@@ -2954,6 +2995,10 @@ async function renderWithRestartOnCacheMissInDev(
29542995
),
29552996
prerenderResumeDataCache: null,
29562997
cacheSignal: null,
2998+
dynamicTracking: createDynamicTrackingState(
2999+
false // isDebugDynamicAccesses
3000+
),
3001+
captureOwnerStack: ComponentMod.captureOwnerStack,
29573002
} satisfies DevStoreModernPartial)
29583003

29593004
// The initial render already wrote to its debug channel.
@@ -2977,11 +3022,15 @@ async function renderWithRestartOnCacheMissInDev(
29773022
)
29783023
},
29793024
(stream) => {
3025+
throwIfInvalidDynamic(/* expected stage */ RenderStage.Static)
3026+
29803027
// Runtime stage
29813028
finalStageController.advanceStage(RenderStage.Runtime)
29823029
return stream
29833030
},
29843031
(stream) => {
3032+
throwIfInvalidDynamic(/* expected stage */ RenderStage.Runtime)
3033+
29853034
// Dynamic stage
29863035
finalStageController.advanceStage(RenderStage.Dynamic)
29873036
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)