Skip to content

Commit 041d3e1

Browse files
committed
abort and report on sync IO errors in dev
1 parent 227c75e commit 041d3e1

File tree

6 files changed

+99
-14
lines changed

6 files changed

+99
-14
lines changed

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

Lines changed: 53 additions & 3 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'
@@ -213,7 +214,7 @@ import { createPromiseWithResolvers } from '../../shared/lib/promise-with-resolv
213214
import { ImageConfigContext } from '../../shared/lib/image-config-context.shared-runtime'
214215
import { imageConfigDefault } from '../../shared/lib/image-config'
215216
import { RenderStage, StagedRenderingController } from './staged-rendering'
216-
import { hasRuntimePrefetchInLoaderTree } from './prefetch-validation'
217+
import { anySegmentHasRuntimePrefetchEnabled } from './prefetch-validation'
217218

218219
export type GetDynamicParamFromSegment = (
219220
// [slug] / [[slug]] / [...slug]
@@ -2706,6 +2707,7 @@ async function renderWithRestartOnCacheMissInDev(
27062707
) {
27072708
const {
27082709
renderOpts,
2710+
workStore,
27092711
componentMod: {
27102712
routeModule: {
27112713
userland: { loaderTree },
@@ -2714,15 +2716,24 @@ async function renderWithRestartOnCacheMissInDev(
27142716
} = ctx
27152717
const { clientReferenceManifest, ComponentMod, setReactDebugChannel } =
27162718
renderOpts
2719+
const captureOwnerStack = ComponentMod.captureOwnerStack
2720+
27172721
assertClientReferenceManifest(clientReferenceManifest)
27182722

2719-
const hasRuntimePrefetch = await hasRuntimePrefetchInLoaderTree(loaderTree)
2723+
// Check if any segment of the current page has runtime prefetching enabled.
2724+
// Note that if we're in a client navigation, this config might come from
2725+
// a shared layout parent that won't actually be rendered here.
2726+
// However, if the parent is runtime prefetchable, then all of its children
2727+
// can potentially run as part of a runtime prefetch, so it makes sense to validate them.
2728+
const hasRuntimePrefetch =
2729+
await anySegmentHasRuntimePrefetchEnabled(loaderTree)
27202730

27212731
// If the render is restarted, we'll recreate a fresh request store
27222732
let requestStore: RequestStore = initialRequestStore
27232733

27242734
const environmentName = () => {
2725-
const currentStage = requestStore.stagedRendering!.currentStage
2735+
const { stagedRendering } = requestStore as DevRequestStoreModern
2736+
const currentStage = stagedRendering.currentStage
27262737
switch (currentStage) {
27272738
case RenderStage.Static:
27282739
return 'Prerender'
@@ -2739,6 +2750,25 @@ async function renderWithRestartOnCacheMissInDev(
27392750
}
27402751
}
27412752

2753+
const throwIfInvalidDynamic = (expectedStage: RenderStage) => {
2754+
const { stagedRendering, dynamicTracking } =
2755+
requestStore as DevRequestStoreModern
2756+
if (
2757+
expectedStage !== RenderStage.Dynamic &&
2758+
// Sync IO errors advance us to the dynamic stage.
2759+
stagedRendering.currentStage === RenderStage.Dynamic
2760+
) {
2761+
// We should always have an error set, but be defensive
2762+
if (dynamicTracking.syncDynamicErrorWithStack) {
2763+
throw dynamicTracking.syncDynamicErrorWithStack
2764+
}
2765+
}
2766+
2767+
if (workStore.invalidDynamicUsageError) {
2768+
throw workStore.invalidDynamicUsageError
2769+
}
2770+
}
2771+
27422772
//===============================================
27432773
// Initial render
27442774
//===============================================
@@ -2773,6 +2803,10 @@ async function renderWithRestartOnCacheMissInDev(
27732803
cacheSignal,
27742804
hangingCacheAbortSignal: hangingCacheAbortController.signal,
27752805
hangingPromiseAbortSignal: initialHangingPromiseController.signal,
2806+
dynamicTracking: createDynamicTrackingState(
2807+
false // isDebugDynamicAccesses
2808+
),
2809+
captureOwnerStack,
27762810
} satisfies DevStoreModernPartial)
27772811

27782812
let debugChannel = setReactDebugChannel && createDebugChannel()
@@ -2813,6 +2847,8 @@ async function renderWithRestartOnCacheMissInDev(
28132847
return stream
28142848
},
28152849
(stream) => {
2850+
throwIfInvalidDynamic(/* expected stage */ RenderStage.Static)
2851+
28162852
// Runtime stage
28172853

28182854
hadCacheMissInPreviousStages = cacheSignal.hasPendingReads()
@@ -2831,6 +2867,8 @@ async function renderWithRestartOnCacheMissInDev(
28312867
return stream
28322868
},
28332869
(maybeStream) => {
2870+
throwIfInvalidDynamic(/* expected stage */ RenderStage.Runtime)
2871+
28342872
// Dynamic stage
28352873

28362874
// If the previous stage bailed out of the render due to a cache miss,
@@ -2881,6 +2919,10 @@ async function renderWithRestartOnCacheMissInDev(
28812919

28822920
await cacheSignal.cacheReady()
28832921
initialReactController.abort()
2922+
throwIfInvalidDynamic(
2923+
// If we're warming caches, we shouldn't have advanced past the runtime stage.
2924+
RenderStage.Runtime
2925+
)
28842926

28852927
//===============================================
28862928
// Final render (restarted)
@@ -2900,6 +2942,10 @@ async function renderWithRestartOnCacheMissInDev(
29002942
stagedRendering: finalStageController,
29012943
prerenderResumeDataCache: null,
29022944
cacheSignal: null,
2945+
dynamicTracking: createDynamicTrackingState(
2946+
false // isDebugDynamicAccesses
2947+
),
2948+
captureOwnerStack: ComponentMod.captureOwnerStack,
29032949
} satisfies DevStoreModernPartial)
29042950

29052951
// The initial render already wrote to its debug channel.
@@ -2923,11 +2969,15 @@ async function renderWithRestartOnCacheMissInDev(
29232969
)
29242970
},
29252971
(stream) => {
2972+
throwIfInvalidDynamic(/* expected stage */ RenderStage.Static)
2973+
29262974
// Runtime stage
29272975
finalStageController.advanceStage(RenderStage.Runtime)
29282976
return stream
29292977
},
29302978
(stream) => {
2979+
throwIfInvalidDynamic(/* expected stage */ RenderStage.Runtime)
2980+
29312981
// Dynamic stage
29322982
finalStageController.advanceStage(RenderStage.Dynamic)
29332983
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/prefetch-validation.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import type { LoaderTree } from '../lib/app-dir-module'
33
import { parseLoaderTree } from '../../shared/lib/router/utils/parse-loader-tree'
44
import type { AppSegmentConfig } from '../../build/segment-config/app/app-segment-config'
55

6-
export async function hasRuntimePrefetchInLoaderTree(
6+
export async function anySegmentHasRuntimePrefetchEnabled(
77
tree: LoaderTree
88
): Promise<boolean> {
99
const { mod: layoutOrPageMod } = await getLayoutOrPageModule(tree)
@@ -22,7 +22,7 @@ export async function hasRuntimePrefetchInLoaderTree(
2222
for (const parallelRouteKey in parallelRoutes) {
2323
const parallelRoute = parallelRoutes[parallelRouteKey]
2424
const hasChildRuntimePrefetch =
25-
await hasRuntimePrefetchInLoaderTree(parallelRoute)
25+
await anySegmentHasRuntimePrefetchEnabled(parallelRoute)
2626
if (hasChildRuntimePrefetch) {
2727
return true
2828
}

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.
@@ -89,6 +90,8 @@ type DevStoreCommon = {
8990

9091
export type DevStoreModernPartial = {
9192
readonly stagedRendering: StagedRenderingController
93+
readonly captureOwnerStack: () => string | null
94+
readonly dynamicTracking: DynamicTrackingState
9295
} & (
9396
| {
9497
// 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)