Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
94 changes: 74 additions & 20 deletions packages/next/src/server/app-render/app-render.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ import {
type WorkStore,
} from '../app-render/work-async-storage.external'
import type {
DevStoreModernPartial,
DevRequestStoreModern,
PrerenderStoreModernRuntime,
RequestStore,
} from '../app-render/work-unit-async-storage.external'
Expand Down Expand Up @@ -2788,6 +2790,7 @@ async function renderWithRestartOnCacheMissInDev(
htmlRequestId,
renderOpts,
requestId,
workStore,
componentMod: {
routeModule: {
userland: { loaderTree },
Expand All @@ -2802,14 +2805,22 @@ async function renderWithRestartOnCacheMissInDev(
} = renderOpts
assertClientReferenceManifest(clientReferenceManifest)

const captureOwnerStack = ComponentMod.captureOwnerStack

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

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

const environmentName = () => {
const currentStage = requestStore.stagedRendering!.currentStage
const { stagedRendering } = requestStore as DevRequestStoreModern
const currentStage = stagedRendering.currentStage
switch (currentStage) {
case RenderStage.Static:
return 'Prerender'
Expand All @@ -2823,6 +2834,25 @@ async function renderWithRestartOnCacheMissInDev(
}
}

const throwIfInvalidDynamic = (expectedStage: RenderStage) => {
const { stagedRendering, dynamicTracking } =
requestStore as DevRequestStoreModern
if (
expectedStage !== RenderStage.Dynamic &&
// Sync IO errors advance us to the dynamic stage.
stagedRendering.currentStage === RenderStage.Dynamic
) {
// We should always have an error set, but be defensive
if (dynamicTracking.syncDynamicErrorWithStack) {
throw dynamicTracking.syncDynamicErrorWithStack
}
}

if (workStore.invalidDynamicUsageError) {
throw workStore.invalidDynamicUsageError
}
}

//===============================================
// Initial render
//===============================================
Expand All @@ -2847,18 +2877,24 @@ async function renderWithRestartOnCacheMissInDev(
initialHangingPromiseController.signal
)

requestStore.prerenderResumeDataCache = prerenderResumeDataCache
// `getRenderResumeDataCache` will fall back to using `prerenderResumeDataCache` as `renderResumeDataCache`,
// so not having a resume data cache won't break any expectations in case we don't need to restart.
requestStore.renderResumeDataCache = null
requestStore.stagedRendering = initialStageController
requestStore.asyncApiPromises = createAsyncApiPromisesInDev(
initialStageController,
requestStore.cookies,
requestStore.mutableCookies,
requestStore.headers
)
requestStore.cacheSignal = cacheSignal
Object.assign(requestStore, {
stagedRendering: initialStageController,
asyncApiPromises: createAsyncApiPromisesInDev(
initialStageController,
requestStore.cookies,
requestStore.mutableCookies,
requestStore.headers
),
prerenderResumeDataCache,
cacheSignal,
dynamicTracking: createDynamicTrackingState(
false // isDebugDynamicAccesses
),
captureOwnerStack,
} satisfies DevStoreModernPartial)

let debugChannel = setReactDebugChannel && createDebugChannel()

Expand Down Expand Up @@ -2896,6 +2932,8 @@ async function renderWithRestartOnCacheMissInDev(
return stream
},
(stream) => {
throwIfInvalidDynamic(/* expected stage */ RenderStage.Static)

// Runtime stage
initialStageController.advanceStage(RenderStage.Runtime)

Expand All @@ -2909,7 +2947,9 @@ async function renderWithRestartOnCacheMissInDev(
// and see if there's any cache misses in the runtime stage.
return stream
},
async (maybeStream) => {
(maybeStream) => {
throwIfInvalidDynamic(/* expected stage */ RenderStage.Runtime)

// Dynamic stage

// If we had cache misses in either of the previous stages,
Expand Down Expand Up @@ -2951,6 +2991,10 @@ async function renderWithRestartOnCacheMissInDev(

await cacheSignal.cacheReady()
initialReactController.abort()
throwIfInvalidDynamic(
// If we're warming caches, we shouldn't have advanced past the runtime stage.
RenderStage.Runtime
)

//===============================================
// Final render (restarted)
Expand All @@ -2963,18 +3007,24 @@ async function renderWithRestartOnCacheMissInDev(

// We've filled the caches, so now we can render as usual,
// without any cache-filling mechanics.
requestStore.prerenderResumeDataCache = null
requestStore.renderResumeDataCache = createRenderResumeDataCache(
prerenderResumeDataCache
)
requestStore.stagedRendering = finalStageController
requestStore.cacheSignal = null
requestStore.asyncApiPromises = createAsyncApiPromisesInDev(
finalStageController,
requestStore.cookies,
requestStore.mutableCookies,
requestStore.headers
)
Object.assign(requestStore, {
stagedRendering: finalStageController,
asyncApiPromises: createAsyncApiPromisesInDev(
finalStageController,
requestStore.cookies,
requestStore.mutableCookies,
requestStore.headers
),
prerenderResumeDataCache: null,
cacheSignal: null,
dynamicTracking: createDynamicTrackingState(
false // isDebugDynamicAccesses
),
captureOwnerStack: ComponentMod.captureOwnerStack,
} satisfies DevStoreModernPartial)

// The initial render already wrote to its debug channel.
// We're not using it, so we need to create a new one.
Expand All @@ -2997,11 +3047,15 @@ async function renderWithRestartOnCacheMissInDev(
)
},
(stream) => {
throwIfInvalidDynamic(/* expected stage */ RenderStage.Static)

// Runtime stage
finalStageController.advanceStage(RenderStage.Runtime)
return stream
},
(stream) => {
throwIfInvalidDynamic(/* expected stage */ RenderStage.Runtime)

// Dynamic stage
finalStageController.advanceStage(RenderStage.Dynamic)
return stream
Expand Down
16 changes: 10 additions & 6 deletions packages/next/src/server/app-render/dynamic-rendering.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,10 @@
import type { WorkStore } from '../app-render/work-async-storage.external'
import type {
WorkUnitStore,
RequestStore,
PrerenderStoreLegacy,
PrerenderStoreModern,
PrerenderStoreModernRuntime,
DevRequestStoreModern,
} from '../app-render/work-unit-async-storage.external'

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

export function trackSynchronousPlatformIOAccessInDev(
requestStore: RequestStore
requestStore: DevRequestStoreModern,
errorWithStack: Error
): void {
const { stagedRendering, dynamicTracking } = requestStore
// We don't actually have a controller to abort but we do the semantic equivalent by
// advancing the request store out of the prerender stage
if (requestStore.stagedRendering) {
// TODO: error for sync IO in the runtime stage
// (which is not currently covered by the validation render in `spawnDynamicValidationInDev`)
requestStore.stagedRendering.advanceStage(RenderStage.Dynamic)

// TODO: error for sync IO in the runtime stage
// (which is not currently covered by the validation render in `spawnDynamicValidationInDev`)
stagedRendering.advanceStage(RenderStage.Dynamic)
if (dynamicTracking.syncDynamicErrorWithStack === null) {
dynamicTracking.syncDynamicErrorWithStack = errorWithStack
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ export interface CommonWorkUnitStore {
readonly implicitTags: ImplicitTags
}

export interface RequestStore extends CommonWorkUnitStore {
interface BaseRequestStore extends CommonWorkUnitStore {
readonly type: 'request'

/**
Expand Down Expand Up @@ -65,14 +65,49 @@ export interface RequestStore extends CommonWorkUnitStore {
* The resume data cache for this request. This will be a immutable cache.
*/
renderResumeDataCache: RenderResumeDataCache | null
}

export type RequestStore = ProdRequestStore | DevRequestStore

export type ProdRequestStore = BaseRequestStore & AllMissing<DevStore>
export type DevRequestStore = BaseRequestStore & DevStore
export type DevRequestStoreModern = BaseRequestStore & DevStoreModern

// DEV-only
// If `cacheComponents` is enabled, we add multiple extra properties on the store.
// We either want all of them to be present, or all of them to be undefined.
// Note that we don't want to remove the properties altogether, as in `{ a: A } | { a: A, b: B }`,
// because then typescript wouldn prevent us from doing `if (requestStore.someProp) { ... }` --
// we'd need to check `if ('someProp' in requestStore && requestStore.someProp) { ... }` instead,
// which is annoying to write everywhere.
type DevStore = DevStoreLegacy | DevStoreModern
type DevStoreLegacy = DevStoreCommon & AllMissing<DevStoreModernPartial>
type DevStoreModern = DevStoreCommon & DevStoreModernPartial

type DevStoreCommon = {
usedDynamic?: boolean
devFallbackParams?: OpaqueFallbackRouteParams | null
stagedRendering?: StagedRenderingController | null
asyncApiPromises?: DevAsyncApiPromises
cacheSignal?: CacheSignal | null
prerenderResumeDataCache?: PrerenderResumeDataCache | null
}

export type DevStoreModernPartial = {
readonly stagedRendering: StagedRenderingController
readonly asyncApiPromises: DevAsyncApiPromises
readonly captureOwnerStack: () => string | null
readonly dynamicTracking: DynamicTrackingState
} & (
| {
// In the initial render, we track and fill caches
readonly cacheSignal: CacheSignal
readonly prerenderResumeDataCache: PrerenderResumeDataCache
}
| {
// In the final (restarted) render, we do not track or fill caches
readonly cacheSignal: null
readonly prerenderResumeDataCache: null
}
)

type AllMissing<TObj extends Record<string, any>> = {
[key in keyof TObj]?: undefined
}

type DevAsyncApiPromises = {
Expand Down
2 changes: 1 addition & 1 deletion packages/next/src/server/async-storage/request-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -263,7 +263,7 @@ function createRequestStoreImpl(
serverComponentsHmrCache:
serverComponentsHmrCache ||
(globalThis as any).__serverComponentsHmrCache,
devFallbackParams,
devFallbackParams: devFallbackParams ?? null,
}
}

Expand Down
32 changes: 30 additions & 2 deletions packages/next/src/server/node-environment-extensions/utils.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { workAsyncStorage } from '../app-render/work-async-storage.external'
import {
workUnitAsyncStorage,
type DevRequestStoreModern,
type PrerenderStoreModern,
} from '../app-render/work-unit-async-storage.external'
import {
Expand Down Expand Up @@ -87,7 +88,31 @@ export function io(expression: string, type: ApiType) {
}
case 'request':
if (process.env.NODE_ENV === 'development') {
trackSynchronousPlatformIOAccessInDev(workUnitStore)
if (workUnitStore.stagedRendering) {
// If the prerender signal is already aborted we don't need to construct
// any stacks because something else actually terminated the prerender.
let message: string
switch (type) {
case 'time':
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`
break
case 'random':
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`
break
case 'crypto':
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`
break
default:
throw new InvariantError(
'Unknown expression type in abortOnSynchronousPlatformIOAccess.'
)
}

trackSynchronousPlatformIOAccessInDev(
workUnitStore,
applyOwnerStack(new Error(message), workUnitStore)
Comment on lines +112 to +113
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
workUnitStore,
applyOwnerStack(new Error(message), workUnitStore)
workUnitStore as DevRequestStoreModern,
applyOwnerStack(new Error(message), workUnitStore as DevRequestStoreModern)

Type mismatch: trackSynchronousPlatformIOAccessInDev expects DevRequestStoreModern but receives RequestStore which is a broader union type that includes production and legacy stores.

View Details

Analysis

TypeScript type mismatch in trackSynchronousPlatformIOAccessInDev call

What fails: trackSynchronousPlatformIOAccessInDev() in packages/next/src/server/node-environment-extensions/utils.tsx:112-113 expects DevRequestStoreModern but receives workUnitStore typed as RequestStore (broader union type)

How to reproduce:

cd packages/next && npx tsc --noEmit --skipLibCheck src/server/node-environment-extensions/utils.tsx

Result: TypeScript compilation error: "Argument of type 'RequestStore' is not assignable to parameter of type 'DevRequestStoreModern'"

Expected: Clean compilation. The runtime guard if (workUnitStore.stagedRendering) ensures only modern dev stores are passed, but TypeScript control flow analysis cannot narrow the union type properly due to complex type intersection logic.

)
}
}
break
case 'prerender-ppr':
Expand All @@ -101,7 +126,10 @@ export function io(expression: string, type: ApiType) {
}
}

function applyOwnerStack(error: Error, workUnitStore: PrerenderStoreModern) {
function applyOwnerStack(
error: Error,
workUnitStore: PrerenderStoreModern | DevRequestStoreModern
) {
// TODO: Instead of stitching the stacks here, we should log the original
// error as-is when it occurs, and let `patchErrorInspect` handle adding the
// owner stack, instead of logging it deferred in the `LogSafely` component
Expand Down
5 changes: 3 additions & 2 deletions packages/next/src/server/use-cache/use-cache-wrapper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { prerender } from 'react-server-dom-webpack/static'
import type { WorkStore } from '../app-render/work-async-storage.external'
import { workAsyncStorage } from '../app-render/work-async-storage.external'
import type {
DevRequestStore,
PrerenderStoreModernClient,
PrerenderStoreModernRuntime,
PrivateUseCacheStore,
Expand Down Expand Up @@ -1848,7 +1849,7 @@ function isRecentlyRevalidatedTag(tag: string, workStore: WorkStore): boolean {

async function delayBeforeCacheReadStartInDev(
stage: NonStaticRenderStage,
requestStore: RequestStore
requestStore: DevRequestStore
): Promise<void> {
const { stagedRendering } = requestStore
if (stagedRendering && stagedRendering.currentStage < stage) {
Expand All @@ -1859,7 +1860,7 @@ async function delayBeforeCacheReadStartInDev(
/** Note: Only call this after a `cacheSignal.beginRead()`. */
async function delayOrHangStartedCacheReadInDev(
stage: NonStaticRenderStage,
requestStore: RequestStore,
requestStore: DevRequestStore,
cacheSignal: CacheSignal | null,
route: string,
expression: string
Expand Down
Loading