Skip to content

Commit 868f28d

Browse files
committed
[Cache Components] separate runtime stage in dev render
1 parent a5064d7 commit 868f28d

File tree

17 files changed

+395
-147
lines changed

17 files changed

+395
-147
lines changed

packages/next/errors.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -878,5 +878,6 @@
878878
"877": "Config options `experimental.externalProxyRewritesResolve` and `experimental.externalMiddlewareRewritesResolve` cannot be set at the same time. Please use `experimental.externalProxyRewritesResolve` instead.",
879879
"878": "Config options `skipProxyUrlNormalize` and `skipMiddlewareUrlNormalize` cannot be set at the same time. Please use `skipProxyUrlNormalize` instead.",
880880
"879": "Config options `experimental.proxyClientMaxBodySize` and `experimental.middlewareClientMaxBodySize` cannot be set at the same time. Please use `experimental.proxyClientMaxBodySize` instead.",
881-
"880": "Config options `experimental.proxyPrefetch` and `experimental.middlewarePrefetch` cannot be set at the same time. Please use `experimental.proxyPrefetch` instead."
881+
"880": "Config options `experimental.proxyPrefetch` and `experimental.middlewarePrefetch` cannot be set at the same time. Please use `experimental.proxyPrefetch` instead.",
882+
"881": "Invalid render stage: %s"
882883
}

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

Lines changed: 26 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -35,30 +35,45 @@ export function scheduleInSequentialTasks<R>(
3535
* We schedule on the same queue (setTimeout) at the same time to ensure no other events can sneak in between.
3636
* The function that runs in the second task gets access to the first tasks's result.
3737
*/
38-
export function pipelineInSequentialTasks<A, B>(
39-
render: () => A,
40-
followup: (a: A) => B | Promise<B>
41-
): Promise<B> {
38+
export function pipelineInSequentialTasks<A, B, C>(
39+
one: () => A,
40+
two: (a: A) => B,
41+
three: (b: B) => C | Promise<C>
42+
): Promise<C> {
4243
if (process.env.NEXT_RUNTIME === 'edge') {
4344
throw new InvariantError(
4445
'`pipelineInSequentialTasks` should not be called in edge runtime.'
4546
)
4647
} else {
4748
return new Promise((resolve, reject) => {
48-
let renderResult: A | undefined = undefined
49+
let oneResult: A | undefined = undefined
4950
setTimeout(() => {
5051
try {
51-
renderResult = render()
52+
oneResult = one()
5253
} catch (err) {
53-
clearTimeout(followupId)
54+
clearTimeout(twoId)
55+
clearTimeout(threeId)
5456
reject(err)
5557
}
5658
}, 0)
57-
const followupId = setTimeout(() => {
58-
// if `render` threw, then the `followup` timeout would've been cleared,
59-
// so if we got here, we're guaranteed to have a `renderResult`.
59+
60+
let twoResult: B | undefined = undefined
61+
const twoId = setTimeout(() => {
62+
// if `one` threw, then this timeout would've been cleared,
63+
// so if we got here, we're guaranteed to have a value.
64+
try {
65+
twoResult = two(oneResult!)
66+
} catch (err) {
67+
clearTimeout(threeId)
68+
reject(err)
69+
}
70+
}, 0)
71+
72+
const threeId = setTimeout(() => {
73+
// if `two` threw, then this timeout would've been cleared,
74+
// so if we got here, we're guaranteed to have a value.
6075
try {
61-
resolve(followup(renderResult!))
76+
resolve(three(twoResult!))
6277
} catch (err) {
6378
reject(err)
6479
}

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

Lines changed: 86 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -168,10 +168,7 @@ import {
168168
prerenderAndAbortInSequentialTasks,
169169
} from './app-render-prerender-utils'
170170
import { printDebugThrownValueForProspectiveRender } from './prospective-render-utils'
171-
import {
172-
pipelineInSequentialTasks,
173-
scheduleInSequentialTasks,
174-
} from './app-render-render-utils'
171+
import { pipelineInSequentialTasks } from './app-render-render-utils'
175172
import { waitAtLeastOneReactRenderTask } from '../../lib/scheduler'
176173
import {
177174
workUnitAsyncStorage,
@@ -214,6 +211,7 @@ import type { Params } from '../request/params'
214211
import { createPromiseWithResolvers } from '../../shared/lib/promise-with-resolvers'
215212
import { ImageConfigContext } from '../../shared/lib/image-config-context.shared-runtime'
216213
import { imageConfigDefault } from '../../shared/lib/image-config'
214+
import { RenderStage, StagedRenderingController } from './staged-rendering'
217215

218216
export type GetDynamicParamFromSegment = (
219217
// [slug] / [[slug]] / [...slug]
@@ -2678,8 +2676,21 @@ async function renderWithRestartOnCacheMissInDev(
26782676
// If the render is restarted, we'll recreate a fresh request store
26792677
let requestStore: RequestStore = initialRequestStore
26802678

2681-
const environmentName = () =>
2682-
requestStore.prerenderPhase === true ? 'Prerender' : 'Server'
2679+
const environmentName = () => {
2680+
const currentStage = requestStore.stagedRendering!.currentStage
2681+
switch (currentStage) {
2682+
case RenderStage.Static:
2683+
return 'Prerender'
2684+
case RenderStage.Runtime:
2685+
// TODO: only label as "Prefetch" if the page has a `prefetch` config.
2686+
return 'Prefetch'
2687+
case RenderStage.Dynamic:
2688+
return 'Server'
2689+
default:
2690+
currentStage satisfies never
2691+
throw new InvariantError(`Invalid render stage: ${currentStage}`)
2692+
}
2693+
}
26832694

26842695
//===============================================
26852696
// Initial render
@@ -2699,14 +2710,19 @@ async function renderWithRestartOnCacheMissInDev(
26992710

27002711
const prerenderResumeDataCache = createPrerenderResumeDataCache()
27012712

2713+
const initialReactController = new AbortController()
2714+
const initialDataController = new AbortController() // Controls hanging promises we create
2715+
const initialStageController = new StagedRenderingController(
2716+
initialDataController.signal
2717+
)
2718+
27022719
requestStore.prerenderResumeDataCache = prerenderResumeDataCache
27032720
// `getRenderResumeDataCache` will fall back to using `prerenderResumeDataCache` as `renderResumeDataCache`,
27042721
// so not having a resume data cache won't break any expectations in case we don't need to restart.
27052722
requestStore.renderResumeDataCache = null
2723+
requestStore.stagedRendering = initialStageController
27062724
requestStore.cacheSignal = cacheSignal
27072725

2708-
const initialReactController = new AbortController()
2709-
27102726
let debugChannel = setReactDebugChannel && createDebugChannel()
27112727

27122728
const initialRscPayload = await getPayload(requestStore)
@@ -2716,8 +2732,7 @@ async function renderWithRestartOnCacheMissInDev(
27162732
pipelineInSequentialTasks(
27172733
() => {
27182734
// Static stage
2719-
requestStore.prerenderPhase = true
2720-
return ComponentMod.renderToReadableStream(
2735+
const stream = ComponentMod.renderToReadableStream(
27212736
initialRscPayload,
27222737
clientReferenceManifest.clientModules,
27232738
{
@@ -2728,25 +2743,42 @@ async function renderWithRestartOnCacheMissInDev(
27282743
signal: initialReactController.signal,
27292744
}
27302745
)
2746+
// If we abort the render, we want to reject the stage-dependent promises as well.
2747+
// Note that we want to install this listener after the render is started
2748+
// so that it runs after react is finished running its abort code.
2749+
initialReactController.signal.addEventListener('abort', () => {
2750+
initialDataController.abort(initialReactController.signal.reason)
2751+
})
2752+
return stream
2753+
},
2754+
(stream) => {
2755+
// Runtime stage
2756+
initialStageController.advanceStage(RenderStage.Runtime)
2757+
2758+
// If we had a cache miss in the static stage, we'll have to disard this stream
2759+
// and render again once the caches are warm.
2760+
if (cacheSignal.hasPendingReads()) {
2761+
return null
2762+
}
2763+
2764+
// If there's no cache misses, we'll continue rendering,
2765+
// and see if there's any cache misses in the runtime stage.
2766+
return stream
27312767
},
2732-
async (stream) => {
2768+
async (maybeStream) => {
27332769
// Dynamic stage
2734-
// Note: if we had cache misses, things that would've happened statically otherwise
2735-
// may be marked as dynamic instead.
2736-
requestStore.prerenderPhase = false
2737-
2738-
// If all cache reads initiated in the static stage have completed,
2739-
// then all of the necessary caches have to be warm (or there's no caches on the page).
2740-
// On the other hand, if we still have pending cache reads, then we had a cache miss,
2741-
// and the static stage didn't render all the content that it normally would have.
2742-
const hadCacheMiss = cacheSignal.hasPendingReads()
2743-
if (!hadCacheMiss) {
2744-
// No cache misses. We can use the stream as is.
2745-
return stream
2746-
} else {
2747-
// Cache miss. We'll discard this stream, and render again.
2770+
2771+
// If we had cache misses in either of the previous stages,
2772+
// then we'll only use this render for filling caches.
2773+
// We won't advance the stage, and thus leave dynamic APIs hanging,
2774+
// because they won't be cached anyway, so it'd be wasted work.
2775+
if (maybeStream === null || cacheSignal.hasPendingReads()) {
27482776
return null
27492777
}
2778+
2779+
// If there's no cache misses, we'll use this render, so let it advance to the dynamic stage.
2780+
initialStageController.advanceStage(RenderStage.Dynamic)
2781+
return maybeStream
27502782
}
27512783
)
27522784
)
@@ -2779,40 +2811,48 @@ async function renderWithRestartOnCacheMissInDev(
27792811
// The initial render acted as a prospective render to warm the caches.
27802812
requestStore = createRequestStore()
27812813

2814+
const finalStageController = new StagedRenderingController()
2815+
27822816
// We've filled the caches, so now we can render as usual,
27832817
// without any cache-filling mechanics.
27842818
requestStore.prerenderResumeDataCache = null
27852819
requestStore.renderResumeDataCache = createRenderResumeDataCache(
27862820
prerenderResumeDataCache
27872821
)
2822+
requestStore.stagedRendering = finalStageController
27882823
requestStore.cacheSignal = null
27892824

27902825
// The initial render already wrote to its debug channel.
27912826
// We're not using it, so we need to create a new one.
27922827
debugChannel = setReactDebugChannel && createDebugChannel()
27932828

27942829
const finalRscPayload = await getPayload(requestStore)
2795-
const finalServerStream = await workUnitAsyncStorage.run(
2796-
requestStore,
2797-
scheduleInSequentialTasks,
2798-
() => {
2799-
// Static stage
2800-
requestStore.prerenderPhase = true
2801-
return ComponentMod.renderToReadableStream(
2802-
finalRscPayload,
2803-
clientReferenceManifest.clientModules,
2804-
{
2805-
onError,
2806-
environmentName,
2807-
filterStackFrame,
2808-
debugChannel: debugChannel?.serverSide,
2809-
}
2810-
)
2811-
},
2812-
() => {
2813-
// Dynamic stage
2814-
requestStore.prerenderPhase = false
2815-
}
2830+
const finalServerStream = await workUnitAsyncStorage.run(requestStore, () =>
2831+
pipelineInSequentialTasks(
2832+
() => {
2833+
// Static stage
2834+
return ComponentMod.renderToReadableStream(
2835+
finalRscPayload,
2836+
clientReferenceManifest.clientModules,
2837+
{
2838+
onError,
2839+
environmentName,
2840+
filterStackFrame,
2841+
debugChannel: debugChannel?.serverSide,
2842+
}
2843+
)
2844+
},
2845+
(stream) => {
2846+
// Runtime stage
2847+
finalStageController.advanceStage(RenderStage.Runtime)
2848+
return stream
2849+
},
2850+
(stream) => {
2851+
// Dynamic stage
2852+
finalStageController.advanceStage(RenderStage.Dynamic)
2853+
return stream
2854+
}
2855+
)
28162856
)
28172857

28182858
return {

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

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ import {
5050
import { scheduleOnNextTick } from '../../lib/scheduler'
5151
import { BailoutToCSRError } from '../../shared/lib/lazy-dynamic/bailout-to-csr'
5252
import { InvariantError } from '../../shared/lib/invariant-error'
53+
import { RenderStage } from './staged-rendering'
5354

5455
const hasPostpone = typeof React.unstable_postpone === 'function'
5556

@@ -298,8 +299,12 @@ export function trackSynchronousPlatformIOAccessInDev(
298299
requestStore: RequestStore
299300
): void {
300301
// We don't actually have a controller to abort but we do the semantic equivalent by
301-
// advancing the request store out of prerender mode
302-
requestStore.prerenderPhase = false
302+
// 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)
307+
}
303308
}
304309

305310
/**
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
import { InvariantError } from '../../shared/lib/invariant-error'
2+
import { createPromiseWithResolvers } from '../../shared/lib/promise-with-resolvers'
3+
4+
export enum RenderStage {
5+
Static = 1,
6+
Runtime = 2,
7+
Dynamic = 3,
8+
}
9+
10+
export type NonStaticRenderStage = RenderStage.Runtime | RenderStage.Dynamic
11+
12+
export class StagedRenderingController {
13+
currentStage: RenderStage = RenderStage.Static
14+
15+
private runtimeStagePromise = createPromiseWithResolvers<void>()
16+
private dynamicStagePromise = createPromiseWithResolvers<void>()
17+
18+
constructor(private abortSignal: AbortSignal | null = null) {
19+
if (abortSignal) {
20+
abortSignal.addEventListener(
21+
'abort',
22+
() => {
23+
const { reason } = abortSignal
24+
if (this.currentStage < RenderStage.Runtime) {
25+
this.runtimeStagePromise.promise.catch(ignoreReject) // avoid unhandled rejections
26+
this.runtimeStagePromise.reject(reason)
27+
}
28+
if (this.currentStage < RenderStage.Dynamic) {
29+
this.dynamicStagePromise.promise.catch(ignoreReject) // avoid unhandled rejections
30+
this.dynamicStagePromise.reject(reason)
31+
}
32+
},
33+
{ once: true }
34+
)
35+
}
36+
}
37+
38+
advanceStage(stage: NonStaticRenderStage) {
39+
// If we're already at the target stage or beyond, do nothing.
40+
// (this can happen e.g. if sync IO advanced us to the dynamic stage)
41+
if (this.currentStage >= stage) {
42+
return
43+
}
44+
this.currentStage = stage
45+
// Note that we might be going directly from Static to Dynamic,
46+
// so we need to resolve the runtime stage as well.
47+
if (stage >= RenderStage.Runtime) {
48+
this.runtimeStagePromise.resolve()
49+
}
50+
if (stage >= RenderStage.Dynamic) {
51+
this.dynamicStagePromise.resolve()
52+
}
53+
}
54+
55+
delayUntilStage<T>(stage: NonStaticRenderStage, resolvedValue: T) {
56+
let stagePromise: Promise<void>
57+
switch (stage) {
58+
case RenderStage.Runtime: {
59+
stagePromise = this.runtimeStagePromise.promise
60+
break
61+
}
62+
case RenderStage.Dynamic: {
63+
stagePromise = this.dynamicStagePromise.promise
64+
break
65+
}
66+
default: {
67+
stage satisfies never
68+
throw new InvariantError(`Invalid render stage: ${stage}`)
69+
}
70+
}
71+
72+
// FIXME: this seems to be the only form that leads to correct API names
73+
// being displayed in React Devtools (in the "suspended by" section).
74+
// If we use `promise.then(() => resolvedValue)`, the names are lost.
75+
// It's a bit strange that only one of those works right.
76+
const promise = new Promise<T>((resolve, reject) => {
77+
stagePromise.then(resolve.bind(null, resolvedValue), reject)
78+
})
79+
80+
// Analogously to `makeHangingPromise`, we might reject this promise if the signal is invoked.
81+
// (e.g. in the case where we don't want want the render to proceed to the dynamic stage and abort it).
82+
// We shouldn't consider this an unhandled rejection, so we attach a noop catch handler here to suppress this warning.
83+
if (this.abortSignal) {
84+
promise.catch(ignoreReject)
85+
}
86+
return promise
87+
}
88+
}
89+
90+
function ignoreReject() {}

0 commit comments

Comments
 (0)