Skip to content

Commit ee1d23f

Browse files
committed
[Runtime prefetch] resolve runtime APIs in a separate task
1 parent e30dd09 commit ee1d23f

File tree

27 files changed

+861
-107
lines changed

27 files changed

+861
-107
lines changed

packages/next/errors.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -774,5 +774,6 @@
774774
"773": "Missing workStore in createPrerenderParamsForClientSegment",
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.",
777-
"776": "`unstable_isUnrecognizedActionError` can only be used on the client."
777+
"776": "`unstable_isUnrecognizedActionError` can only be used on the client.",
778+
"777": "`prerenderAndAbortInSequentialTasksWithStages` should not be called in edge runtime."
778779
}

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

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

8182
// A note on async/await when working in the prefetch cache:
8283
//
@@ -2060,17 +2061,6 @@ function addSegmentPathToUrlInOutputExportMode(
20602061
return url
20612062
}
20622063

2063-
function createPromiseWithResolvers<T>(): PromiseWithResolvers<T> {
2064-
// Shim of Stage 4 Promise.withResolvers proposal
2065-
let resolve: (value: T | PromiseLike<T>) => void
2066-
let reject: (reason: any) => void
2067-
const promise = new Promise<T>((res, rej) => {
2068-
resolve = res
2069-
reject = rej
2070-
})
2071-
return { resolve: resolve!, reject: reject!, promise }
2072-
}
2073-
20742064
/**
20752065
* Checks whether the new fetch strategy is likely to provide more content than the old one.
20762066
*

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]
@@ -713,6 +717,8 @@ async function prospectiveRuntimeServerPrerender(
713717
prerenderResumeDataCache,
714718
hmrRefreshHash: undefined,
715719
captureOwnerStack: undefined,
720+
// We only need task sequencing in the final prerender.
721+
runtimeStagePromise: null,
716722
// These are not present in regular prerenders, but allowed in a runtime prerender.
717723
cookies,
718724
draftMode,
@@ -823,6 +829,9 @@ async function finalRuntimeServerPrerender(
823829
isDebugDynamicAccesses
824830
)
825831

832+
const { promise: runtimeStagePromise, resolve: resolveBlockedRuntimeAPIs } =
833+
createPromiseWithResolvers<void>()
834+
826835
const finalServerPrerenderStore: PrerenderStoreModernRuntime = {
827836
type: 'prerender-runtime',
828837
phase: 'render',
@@ -843,6 +852,8 @@ async function finalRuntimeServerPrerender(
843852
renderResumeDataCache,
844853
hmrRefreshHash: undefined,
845854
captureOwnerStack: undefined,
855+
// Used to separate the "Static" stage from the "Runtime" stage.
856+
runtimeStagePromise,
846857
// These are not present in regular prerenders, but allowed in a runtime prerender.
847858
cookies,
848859
draftMode,
@@ -854,8 +865,9 @@ async function finalRuntimeServerPrerender(
854865
)
855866

856867
let prerenderIsPending = true
857-
const result = await prerenderAndAbortInSequentialTasks(
868+
const result = await prerenderAndAbortInSequentialTasksWithStages(
858869
async () => {
870+
// Static stage
859871
const prerenderResult = await workUnitAsyncStorage.run(
860872
finalServerPrerenderStore,
861873
ComponentMod.prerender,
@@ -871,6 +883,17 @@ async function finalRuntimeServerPrerender(
871883
return prerenderResult
872884
},
873885
() => {
886+
// Advance to the runtime stage.
887+
//
888+
// We make runtime APIs hang during the first task (above), and unblock them in the following task (here).
889+
// This makes sure that, at this point, we'll have finished all the static parts (what we'd prerender statically).
890+
// We know that they don't contain any incorrect sync IO, because that'd have caused a build error.
891+
// After we unblock Runtime APIs, if we encounter sync IO (e.g. `await cookies(); Date.now()`),
892+
// we'll abort, but we'll produce at least as much output as a static prerender would.
893+
resolveBlockedRuntimeAPIs()
894+
},
895+
() => {
896+
// Abort.
874897
if (finalServerController.signal.aborted) {
875898
// If the server controller is already aborted we must have called something
876899
// 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 {
@@ -547,12 +551,24 @@ export function createHangingInputAbortSignal(
547551
})
548552
} else {
549553
// Otherwise we're in the final render and we should already have all
550-
// our caches filled. We might still be waiting on some microtasks so we
554+
// our caches filled.
555+
// If the prerender uses stages, we have wait until the runtime stage,
556+
// at which point all inputs will be resolved.
557+
// (otherwise, we might consider `cookies()` hanging even though they'd resolve in the next task.)
558+
//
559+
// We might still be waiting on some microtasks so we
551560
// wait one tick before giving up. When we give up, we still want to
552561
// render the content of this cache as deeply as we can so that we can
553562
// suspend as deeply as possible in the tree or not at all if we don't
554563
// end up waiting for the input.
555-
scheduleOnNextTick(() => controller.abort())
564+
const runtimeStagePromise = getRuntimeStagePromise(workUnitStore)
565+
if (runtimeStagePromise) {
566+
runtimeStagePromise.then(() =>
567+
scheduleOnNextTick(() => controller.abort())
568+
)
569+
} else {
570+
scheduleOnNextTick(() => controller.abort())
571+
}
556572
}
557573

558574
return controller.signal
@@ -799,3 +815,14 @@ export function throwIfDisallowedDynamic(
799815
}
800816
}
801817
}
818+
819+
export function delayUntilRuntimeStage<T>(
820+
prerenderStore: PrerenderStoreModernRuntime,
821+
result: Promise<T>
822+
): Promise<T> {
823+
if (prerenderStore.runtimeStagePromise) {
824+
// TODO(runtime-ppr): does this break exotic promises?
825+
return prerenderStore.runtimeStagePromise.then(() => result)
826+
}
827+
return result
828+
}

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)