Skip to content

Commit 1e5932d

Browse files
authored
[Runtime prefetch] resolve runtime APIs in a separate task (#82475)
In a runtime prefetch, sync IO is dangerous, because it makes us abort the whole prerender. If this abort happens early, a runtime prefetch can end up giving us *less* data than a static prefetch. To prevent this, we can split the final prerender into two separate tasks (which i'm calling "stages" in the code): 1. The static stage (task 1). This is meant to be equivalent to what we'd do during build, so runtime APIs like `cookies()` aren't resolved yet. This lets us render everything that's reachable statically. We should not hit a sync IO here -- if we had, it would've also caused an error during build. 2. The runtime stage (task 2). Here, we allow `cookies()` (and all other runtime APIs available in a runtime prerender) to resolve. We might encounter sync IO errors here (e.g. `await cookies(); Date.now()`), which will make the result suboptimal, but still usable.
1 parent 4a0763e commit 1e5932d

File tree

27 files changed

+871
-107
lines changed

27 files changed

+871
-107
lines changed

packages/next/errors.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -775,5 +775,6 @@
775775
"774": "Route %s used %s outside of a Server Component. This is not allowed.",
776776
"775": "Node.js instrumentation extensions should not be loaded in the Edge runtime.",
777777
"776": "`unstable_isUnrecognizedActionError` can only be used on the client.",
778-
"777": "Invariant: failed to find source route %s for prerender %s"
778+
"777": "Invariant: failed to find source route %s for prerender %s",
779+
"778": "`prerenderAndAbortInSequentialTasksWithStages` should not be called in edge runtime."
779780
}

packages/next/src/client/components/segment-cache-impl/cache.ts

Lines changed: 1 addition & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,7 @@ import {
7878
doesExportedHtmlMatchBuildId,
7979
} from '../../../shared/lib/segment-cache/output-export-prefetch-encoding'
8080
import { FetchStrategy } from '../segment-cache'
81+
import { createPromiseWithResolvers } from '../../../shared/lib/promise-with-resolvers'
8182

8283
// A note on async/await when working in the prefetch cache:
8384
//
@@ -2050,17 +2051,6 @@ function addSegmentPathToUrlInOutputExportMode(
20502051
return url
20512052
}
20522053

2053-
function createPromiseWithResolvers<T>(): PromiseWithResolvers<T> {
2054-
// Shim of Stage 4 Promise.withResolvers proposal
2055-
let resolve: (value: T | PromiseLike<T>) => void
2056-
let reject: (reason: any) => void
2057-
const promise = new Promise<T>((res, rej) => {
2058-
resolve = res
2059-
reject = rej
2060-
})
2061-
return { resolve: resolve!, reject: reject!, promise }
2062-
}
2063-
20642054
/**
20652055
* Checks whether the new fetch strategy is likely to provide more content than the old one.
20662056
*

packages/next/src/server/app-render/app-render-prerender-utils.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,41 @@ export function prerenderAndAbortInSequentialTasks<R>(
3131
}
3232
}
3333

34+
/**
35+
* Like `prerenderAndAbortInSequentialTasks`, but with another task between `prerender` and `abort`,
36+
* which allows us to move a part of the render into a separate task.
37+
*/
38+
export function prerenderAndAbortInSequentialTasksWithStages<R>(
39+
prerender: () => Promise<R>,
40+
advanceStage: () => void,
41+
abort: () => void
42+
): Promise<R> {
43+
if (process.env.NEXT_RUNTIME === 'edge') {
44+
throw new InvariantError(
45+
'`prerenderAndAbortInSequentialTasksWithStages` should not be called in edge runtime.'
46+
)
47+
} else {
48+
return new Promise((resolve, reject) => {
49+
let pendingResult: Promise<R>
50+
setImmediate(() => {
51+
try {
52+
pendingResult = prerender()
53+
pendingResult.catch(() => {})
54+
} catch (err) {
55+
reject(err)
56+
}
57+
})
58+
setImmediate(() => {
59+
advanceStage()
60+
})
61+
setImmediate(() => {
62+
abort()
63+
resolve(pendingResult)
64+
})
65+
})
66+
}
67+
}
68+
3469
// React's RSC prerender function will emit an incomplete flight stream when using `prerender`. If the connection
3570
// closes then whatever hanging chunks exist will be errored. This is because prerender (an experimental feature)
3671
// has not yet implemented a concept of resume. For now we will simulate a paused connection by wrapping the stream

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

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -155,7 +155,10 @@ import { createMutableActionQueue } from '../../client/components/app-router-ins
155155
import { getRevalidateReason } from '../instrumentation/utils'
156156
import { PAGE_SEGMENT_KEY } from '../../shared/lib/segment'
157157
import type { FallbackRouteParams } from '../request/fallback-params'
158-
import { processPrelude } from './app-render-prerender-utils'
158+
import {
159+
prerenderAndAbortInSequentialTasksWithStages,
160+
processPrelude,
161+
} from './app-render-prerender-utils'
159162
import {
160163
type ReactServerPrerenderResult,
161164
ReactServerResult,
@@ -200,6 +203,7 @@ import { getRequestMeta } from '../request-meta'
200203
import { getDynamicParam } from '../../shared/lib/router/utils/get-dynamic-param'
201204
import type { ExperimentalConfig } from '../config-shared'
202205
import type { Params } from '../request/params'
206+
import { createPromiseWithResolvers } from '../../shared/lib/promise-with-resolvers'
203207

204208
export type GetDynamicParamFromSegment = (
205209
// [slug] / [[slug]] / [...slug]
@@ -711,6 +715,8 @@ async function prospectiveRuntimeServerPrerender(
711715
prerenderResumeDataCache,
712716
hmrRefreshHash: undefined,
713717
captureOwnerStack: undefined,
718+
// We only need task sequencing in the final prerender.
719+
runtimeStagePromise: null,
714720
// These are not present in regular prerenders, but allowed in a runtime prerender.
715721
cookies,
716722
draftMode,
@@ -821,6 +827,9 @@ async function finalRuntimeServerPrerender(
821827
isDebugDynamicAccesses
822828
)
823829

830+
const { promise: runtimeStagePromise, resolve: resolveBlockedRuntimeAPIs } =
831+
createPromiseWithResolvers<void>()
832+
824833
const finalServerPrerenderStore: PrerenderStoreModernRuntime = {
825834
type: 'prerender-runtime',
826835
phase: 'render',
@@ -841,6 +850,8 @@ async function finalRuntimeServerPrerender(
841850
renderResumeDataCache,
842851
hmrRefreshHash: undefined,
843852
captureOwnerStack: undefined,
853+
// Used to separate the "Static" stage from the "Runtime" stage.
854+
runtimeStagePromise,
844855
// These are not present in regular prerenders, but allowed in a runtime prerender.
845856
cookies,
846857
draftMode,
@@ -852,8 +863,9 @@ async function finalRuntimeServerPrerender(
852863
)
853864

854865
let prerenderIsPending = true
855-
const result = await prerenderAndAbortInSequentialTasks(
866+
const result = await prerenderAndAbortInSequentialTasksWithStages(
856867
async () => {
868+
// Static stage
857869
const prerenderResult = await workUnitAsyncStorage.run(
858870
finalServerPrerenderStore,
859871
ComponentMod.prerender,
@@ -869,6 +881,17 @@ async function finalRuntimeServerPrerender(
869881
return prerenderResult
870882
},
871883
() => {
884+
// Advance to the runtime stage.
885+
//
886+
// We make runtime APIs hang during the first task (above), and unblock them in the following task (here).
887+
// This makes sure that, at this point, we'll have finished all the static parts (what we'd prerender statically).
888+
// We know that they don't contain any incorrect sync IO, because that'd have caused a build error.
889+
// After we unblock Runtime APIs, if we encounter sync IO (e.g. `await cookies(); Date.now()`),
890+
// we'll abort, but we'll produce at least as much output as a static prerender would.
891+
resolveBlockedRuntimeAPIs()
892+
},
893+
() => {
894+
// Abort.
872895
if (finalServerController.signal.aborted) {
873896
// If the server controller is already aborted we must have called something
874897
// that required aborting the prerender synchronously such as with new Date()

packages/next/src/server/app-render/dynamic-rendering.ts

Lines changed: 30 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,14 +26,18 @@ import type {
2626
RequestStore,
2727
PrerenderStoreLegacy,
2828
PrerenderStoreModern,
29+
PrerenderStoreModernRuntime,
2930
} from '../app-render/work-unit-async-storage.external'
3031

3132
// Once postpone is in stable we should switch to importing the postpone export directly
3233
import React from 'react'
3334

3435
import { DynamicServerError } from '../../client/components/hooks-server-context'
3536
import { StaticGenBailoutError } from '../../client/components/static-generation-bailout'
36-
import { workUnitAsyncStorage } from './work-unit-async-storage.external'
37+
import {
38+
getRuntimeStagePromise,
39+
workUnitAsyncStorage,
40+
} from './work-unit-async-storage.external'
3741
import { workAsyncStorage } from '../app-render/work-async-storage.external'
3842
import { makeHangingPromise } from '../dynamic-rendering-utils'
3943
import {
@@ -548,12 +552,25 @@ export function createHangingInputAbortSignal(
548552
})
549553
} else {
550554
// Otherwise we're in the final render and we should already have all
551-
// our caches filled. We might still be waiting on some microtasks so we
555+
// our caches filled.
556+
// If the prerender uses stages, we have wait until the runtime stage,
557+
// at which point all runtime inputs will be resolved.
558+
// (otherwise, a runtime prerender might consider `cookies()` hanging
559+
// even though they'd resolve in the next task.)
560+
//
561+
// We might still be waiting on some microtasks so we
552562
// wait one tick before giving up. When we give up, we still want to
553563
// render the content of this cache as deeply as we can so that we can
554564
// suspend as deeply as possible in the tree or not at all if we don't
555565
// end up waiting for the input.
556-
scheduleOnNextTick(() => controller.abort())
566+
const runtimeStagePromise = getRuntimeStagePromise(workUnitStore)
567+
if (runtimeStagePromise) {
568+
runtimeStagePromise.then(() =>
569+
scheduleOnNextTick(() => controller.abort())
570+
)
571+
} else {
572+
scheduleOnNextTick(() => controller.abort())
573+
}
557574
}
558575

559576
return controller.signal
@@ -824,3 +841,13 @@ export function throwIfDisallowedDynamic(
824841
}
825842
}
826843
}
844+
845+
export function delayUntilRuntimeStage<T>(
846+
prerenderStore: PrerenderStoreModernRuntime,
847+
result: Promise<T>
848+
): Promise<T> {
849+
if (prerenderStore.runtimeStagePromise) {
850+
return prerenderStore.runtimeStagePromise.then(() => result)
851+
}
852+
return result
853+
}

packages/next/src/server/app-render/work-unit-async-storage.external.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,18 @@ export interface PrerenderStoreModernRuntime
106106
extends PrerenderStoreModernCommon {
107107
readonly type: 'prerender-runtime'
108108

109+
/**
110+
* A runtime prerender resolves APIs in two tasks:
111+
*
112+
* 1. Static data (available in a static prerender)
113+
* 2. Runtime data (available in a runtime prerender)
114+
*
115+
* This separation is achieved by awaiting this promise in "runtime" APIs.
116+
* In the final prerender, the promise will be resolved during the second task,
117+
* and the render will be aborted in the task that follows it.
118+
*/
119+
readonly runtimeStagePromise: Promise<void> | null
120+
109121
readonly cookies: RequestStore['cookies']
110122
readonly draftMode: RequestStore['draftMode']
111123
}
@@ -268,6 +280,18 @@ export interface PublicUseCacheStore extends CommonUseCacheStore {
268280
export interface PrivateUseCacheStore extends CommonUseCacheStore {
269281
readonly type: 'private-cache'
270282

283+
/**
284+
* A runtime prerender resolves APIs in two tasks:
285+
*
286+
* 1. Static data (available in a static prerender)
287+
* 2. Runtime data (available in a runtime prerender)
288+
*
289+
* This separation is achieved by awaiting this promise in "runtime" APIs.
290+
* In the final prerender, the promise will be resolved during the second task,
291+
* and the render will be aborted in the task that follows it.
292+
*/
293+
readonly runtimeStagePromise: Promise<void> | null
294+
271295
/**
272296
* As opposed to the public cache store, the private cache store is allowed to
273297
* access the request cookies.
@@ -486,3 +510,23 @@ export function getCacheSignal(
486510
return workUnitStore satisfies never
487511
}
488512
}
513+
514+
export function getRuntimeStagePromise(
515+
workUnitStore: WorkUnitStore
516+
): Promise<void> | null {
517+
switch (workUnitStore.type) {
518+
case 'prerender-runtime':
519+
case 'private-cache':
520+
return workUnitStore.runtimeStagePromise
521+
case 'prerender':
522+
case 'prerender-client':
523+
case 'prerender-ppr':
524+
case 'prerender-legacy':
525+
case 'request':
526+
case 'cache':
527+
case 'unstable-cache':
528+
return null
529+
default:
530+
return workUnitStore satisfies never
531+
}
532+
}

packages/next/src/server/request/cookies.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {
1515
type PrerenderStoreModern,
1616
} from '../app-render/work-unit-async-storage.external'
1717
import {
18+
delayUntilRuntimeStage,
1819
postponeWithTracking,
1920
throwToInterruptStaticGeneration,
2021
trackDynamicDataInDynamicRender,
@@ -118,6 +119,10 @@ export function cookies(): Promise<ReadonlyRequestCookies> {
118119
workUnitStore
119120
)
120121
case 'prerender-runtime':
122+
return delayUntilRuntimeStage(
123+
workUnitStore,
124+
makeUntrackedExoticCookies(workUnitStore.cookies)
125+
)
121126
case 'private-cache':
122127
return makeUntrackedExoticCookies(workUnitStore.cookies)
123128
case 'request':

packages/next/src/server/request/draft-mode.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
import { workUnitAsyncStorage } from '../app-render/work-unit-async-storage.external'
1313
import {
1414
abortAndThrowOnSynchronousRequestDataAccess,
15+
delayUntilRuntimeStage,
1516
postponeWithTracking,
1617
trackDynamicDataInDynamicRender,
1718
trackSynchronousRequestDataAccessInDev,
@@ -56,6 +57,11 @@ export function draftMode(): Promise<DraftMode> {
5657

5758
switch (workUnitStore.type) {
5859
case 'prerender-runtime':
60+
// TODO(runtime-ppr): does it make sense to delay this? normally it's always microtasky
61+
return delayUntilRuntimeStage(
62+
workUnitStore,
63+
createOrGetCachedDraftMode(workUnitStore.draftMode, workStore)
64+
)
5965
case 'request':
6066
return createOrGetCachedDraftMode(workUnitStore.draftMode, workStore)
6167

packages/next/src/server/request/params.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
throwToInterruptStaticGeneration,
1010
postponeWithTracking,
1111
trackSynchronousRequestDataAccessInDev,
12+
delayUntilRuntimeStage,
1213
} from '../app-render/dynamic-rendering'
1314

1415
import {
@@ -114,6 +115,10 @@ export function createServerParamsForRoute(
114115
'createServerParamsForRoute should not be called in cache contexts.'
115116
)
116117
case 'prerender-runtime':
118+
return delayUntilRuntimeStage(
119+
workUnitStore,
120+
createRenderParams(underlyingParams, workStore)
121+
)
117122
case 'request':
118123
break
119124
default:
@@ -142,6 +147,10 @@ export function createServerParamsForServerSegment(
142147
'createServerParamsForServerSegment should not be called in cache contexts.'
143148
)
144149
case 'prerender-runtime':
150+
return delayUntilRuntimeStage(
151+
workUnitStore,
152+
createRenderParams(underlyingParams, workStore)
153+
)
145154
case 'request':
146155
break
147156
default:

packages/next/src/server/request/pathname.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type { WorkStore } from '../app-render/work-async-storage.external'
22

33
import {
4+
delayUntilRuntimeStage,
45
postponeWithTracking,
56
type DynamicTrackingState,
67
} from '../app-render/dynamic-rendering'
@@ -37,6 +38,10 @@ export function createServerPathnameForMetadata(
3738
)
3839

3940
case 'prerender-runtime':
41+
return delayUntilRuntimeStage(
42+
workUnitStore,
43+
createRenderPathname(underlyingPathname)
44+
)
4045
case 'request':
4146
break
4247
default:

0 commit comments

Comments
 (0)