Skip to content

Commit df056a6

Browse files
committed
abort and report on sync IO errors in dev
1 parent 6835e98 commit df056a6

File tree

5 files changed

+131
-23
lines changed

5 files changed

+131
-23
lines changed

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

Lines changed: 56 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ import {
1515
type WorkStore,
1616
} from '../app-render/work-async-storage.external'
1717
import type {
18+
CommonDevStoreModern,
19+
DevRequestStoreModern,
1820
PrerenderStoreModernRuntime,
1921
RequestStore,
2022
} from '../app-render/work-unit-async-storage.external'
@@ -2668,16 +2670,19 @@ async function renderWithRestartOnCacheMissInDev(
26682670
getPayload: (requestStore: RequestStore) => Promise<RSCPayload>,
26692671
onError: (error: unknown) => void
26702672
) {
2671-
const { renderOpts } = ctx
2673+
const { renderOpts, workStore } = ctx
26722674
const { clientReferenceManifest, ComponentMod, setReactDebugChannel } =
26732675
renderOpts
2676+
const captureOwnerStack = ComponentMod.captureOwnerStack
2677+
26742678
assertClientReferenceManifest(clientReferenceManifest)
26752679

26762680
// If the render is restarted, we'll recreate a fresh request store
26772681
let requestStore: RequestStore = initialRequestStore
26782682

26792683
const environmentName = () => {
2680-
const currentStage = requestStore.stagedRendering!.currentStage
2684+
const { stagedRendering } = requestStore as DevRequestStoreModern
2685+
const currentStage = stagedRendering.currentStage
26812686
switch (currentStage) {
26822687
case RenderStage.Static:
26832688
return 'Prerender'
@@ -2692,6 +2697,25 @@ async function renderWithRestartOnCacheMissInDev(
26922697
}
26932698
}
26942699

2700+
const throwIfInvalidDynamic = (expectedStage: RenderStage) => {
2701+
const { stagedRendering, dynamicTracking } =
2702+
requestStore as DevRequestStoreModern
2703+
if (
2704+
expectedStage !== RenderStage.Dynamic &&
2705+
// Sync IO errors advance us to the dynamic stage.
2706+
stagedRendering.currentStage === RenderStage.Dynamic
2707+
) {
2708+
// We should always have an error set, but be defensive
2709+
if (dynamicTracking.syncDynamicErrorWithStack) {
2710+
throw dynamicTracking.syncDynamicErrorWithStack
2711+
}
2712+
}
2713+
2714+
if (workStore.invalidDynamicUsageError) {
2715+
throw workStore.invalidDynamicUsageError
2716+
}
2717+
}
2718+
26952719
//===============================================
26962720
// Initial render
26972721
//===============================================
@@ -2716,12 +2740,18 @@ async function renderWithRestartOnCacheMissInDev(
27162740
initialDataController.signal
27172741
)
27182742

2719-
requestStore.prerenderResumeDataCache = prerenderResumeDataCache
27202743
// `getRenderResumeDataCache` will fall back to using `prerenderResumeDataCache` as `renderResumeDataCache`,
27212744
// so not having a resume data cache won't break any expectations in case we don't need to restart.
27222745
requestStore.renderResumeDataCache = null
2723-
requestStore.stagedRendering = initialStageController
2724-
requestStore.cacheSignal = cacheSignal
2746+
Object.assign(requestStore, {
2747+
stagedRendering: initialStageController,
2748+
prerenderResumeDataCache,
2749+
cacheSignal,
2750+
dynamicTracking: createDynamicTrackingState(
2751+
false // isDebugDynamicAccesses
2752+
),
2753+
captureOwnerStack,
2754+
} satisfies CommonDevStoreModern)
27252755

27262756
let debugChannel = setReactDebugChannel && createDebugChannel()
27272757

@@ -2752,6 +2782,8 @@ async function renderWithRestartOnCacheMissInDev(
27522782
return stream
27532783
},
27542784
(stream) => {
2785+
throwIfInvalidDynamic(/* expected stage */ RenderStage.Static)
2786+
27552787
// Runtime stage
27562788
initialStageController.advanceStage(RenderStage.Runtime)
27572789

@@ -2766,6 +2798,8 @@ async function renderWithRestartOnCacheMissInDev(
27662798
return stream
27672799
},
27682800
async (maybeStream) => {
2801+
throwIfInvalidDynamic(/* expected stage */ RenderStage.Runtime)
2802+
27692803
// Dynamic stage
27702804

27712805
// If we had cache misses in either of the previous stages,
@@ -2803,6 +2837,10 @@ async function renderWithRestartOnCacheMissInDev(
28032837

28042838
await cacheSignal.cacheReady()
28052839
initialReactController.abort()
2840+
throwIfInvalidDynamic(
2841+
// If we're warming caches, we shouldn't have advanced past the runtime stage.
2842+
RenderStage.Runtime
2843+
)
28062844

28072845
//===============================================
28082846
// Final render (restarted)
@@ -2815,12 +2853,18 @@ async function renderWithRestartOnCacheMissInDev(
28152853

28162854
// We've filled the caches, so now we can render as usual,
28172855
// without any cache-filling mechanics.
2818-
requestStore.prerenderResumeDataCache = null
28192856
requestStore.renderResumeDataCache = createRenderResumeDataCache(
28202857
prerenderResumeDataCache
28212858
)
2822-
requestStore.stagedRendering = finalStageController
2823-
requestStore.cacheSignal = null
2859+
Object.assign(requestStore, {
2860+
stagedRendering: finalStageController,
2861+
prerenderResumeDataCache: null,
2862+
cacheSignal: null,
2863+
dynamicTracking: createDynamicTrackingState(
2864+
false // isDebugDynamicAccesses
2865+
),
2866+
captureOwnerStack: ComponentMod.captureOwnerStack,
2867+
} satisfies CommonDevStoreModern)
28242868

28252869
// The initial render already wrote to its debug channel.
28262870
// We're not using it, so we need to create a new one.
@@ -2843,11 +2887,15 @@ async function renderWithRestartOnCacheMissInDev(
28432887
)
28442888
},
28452889
(stream) => {
2890+
throwIfInvalidDynamic(/* expected stage */ RenderStage.Static)
2891+
28462892
// Runtime stage
28472893
finalStageController.advanceStage(RenderStage.Runtime)
28482894
return stream
28492895
},
28502896
(stream) => {
2897+
throwIfInvalidDynamic(/* expected stage */ RenderStage.Runtime)
2898+
28512899
// Dynamic stage
28522900
finalStageController.advanceStage(RenderStage.Dynamic)
28532901
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: 34 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ export interface CommonWorkUnitStore {
2929
readonly implicitTags: ImplicitTags
3030
}
3131

32-
export interface RequestStore extends CommonWorkUnitStore {
32+
interface BaseRequestStore extends CommonWorkUnitStore {
3333
readonly type: 'request'
3434

3535
/**
@@ -65,13 +65,41 @@ export interface RequestStore extends CommonWorkUnitStore {
6565
* The resume data cache for this request. This will be a immutable cache.
6666
*/
6767
renderResumeDataCache: RenderResumeDataCache | null
68+
}
69+
70+
export type RequestStore = ProdRequestStore | DevRequestStore
71+
export type ProdRequestStore = BaseRequestStore & NoneOf<DevStore>
72+
export type DevRequestStore = BaseRequestStore & DevStore
73+
export type DevRequestStoreModern = BaseRequestStore & DevStoreModern
6874

69-
// DEV-only
75+
type DevStore = DevStoreLegacy | DevStoreModern
76+
type DevStoreLegacy = CommonDevStore & NoneOf<CommonDevStoreModern>
77+
type DevStoreModern = CommonDevStore & CommonDevStoreModern
78+
79+
type CommonDevStore = {
7080
usedDynamic?: boolean
71-
devFallbackParams?: OpaqueFallbackRouteParams | null
72-
stagedRendering?: StagedRenderingController | null
73-
cacheSignal?: CacheSignal | null
74-
prerenderResumeDataCache?: PrerenderResumeDataCache | null
81+
devFallbackParams: OpaqueFallbackRouteParams | null
82+
}
83+
84+
export type CommonDevStoreModern = {
85+
readonly stagedRendering: StagedRenderingController
86+
readonly captureOwnerStack: () => string | null
87+
readonly dynamicTracking: DynamicTrackingState
88+
} & (
89+
| {
90+
// In the initial render, we track and fill caches
91+
readonly cacheSignal: CacheSignal
92+
readonly prerenderResumeDataCache: PrerenderResumeDataCache
93+
}
94+
| {
95+
// In the final (restarted) render, we do not track or fill caches
96+
readonly cacheSignal: null
97+
readonly prerenderResumeDataCache: null
98+
}
99+
)
100+
101+
type NoneOf<TObj extends Record<string, any>> = {
102+
[key in keyof TObj]?: undefined
75103
}
76104

77105
/**

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)