Skip to content

Commit bf57ecb

Browse files
committed
[Cache Components] separate runtime stage in dev render
1 parent aa9075b commit bf57ecb

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]
@@ -2718,8 +2716,21 @@ async function renderWithRestartOnCacheMissInDev(
27182716
// If the render is restarted, we'll recreate a fresh request store
27192717
let requestStore: RequestStore = initialRequestStore
27202718

2721-
const environmentName = () =>
2722-
requestStore.prerenderPhase === true ? 'Prerender' : 'Server'
2719+
const environmentName = () => {
2720+
const currentStage = requestStore.stagedRendering!.currentStage
2721+
switch (currentStage) {
2722+
case RenderStage.Static:
2723+
return 'Prerender'
2724+
case RenderStage.Runtime:
2725+
// TODO: only label as "Prefetch" if the page has a `prefetch` config.
2726+
return 'Prefetch'
2727+
case RenderStage.Dynamic:
2728+
return 'Server'
2729+
default:
2730+
currentStage satisfies never
2731+
throw new InvariantError(`Invalid render stage: ${currentStage}`)
2732+
}
2733+
}
27232734

27242735
//===============================================
27252736
// Initial render
@@ -2739,14 +2750,19 @@ async function renderWithRestartOnCacheMissInDev(
27392750

27402751
const prerenderResumeDataCache = createPrerenderResumeDataCache()
27412752

2753+
const initialReactController = new AbortController()
2754+
const initialDataController = new AbortController() // Controls hanging promises we create
2755+
const initialStageController = new StagedRenderingController(
2756+
initialDataController.signal
2757+
)
2758+
27422759
requestStore.prerenderResumeDataCache = prerenderResumeDataCache
27432760
// `getRenderResumeDataCache` will fall back to using `prerenderResumeDataCache` as `renderResumeDataCache`,
27442761
// so not having a resume data cache won't break any expectations in case we don't need to restart.
27452762
requestStore.renderResumeDataCache = null
2763+
requestStore.stagedRendering = initialStageController
27462764
requestStore.cacheSignal = cacheSignal
27472765

2748-
const initialReactController = new AbortController()
2749-
27502766
let debugChannel = setReactDebugChannel && createDebugChannel()
27512767

27522768
const initialRscPayload = await getPayload(requestStore)
@@ -2756,8 +2772,7 @@ async function renderWithRestartOnCacheMissInDev(
27562772
pipelineInSequentialTasks(
27572773
() => {
27582774
// Static stage
2759-
requestStore.prerenderPhase = true
2760-
return ComponentMod.renderToReadableStream(
2775+
const stream = ComponentMod.renderToReadableStream(
27612776
initialRscPayload,
27622777
clientReferenceManifest.clientModules,
27632778
{
@@ -2768,25 +2783,42 @@ async function renderWithRestartOnCacheMissInDev(
27682783
signal: initialReactController.signal,
27692784
}
27702785
)
2786+
// If we abort the render, we want to reject the stage-dependent promises as well.
2787+
// Note that we want to install this listener after the render is started
2788+
// so that it runs after react is finished running its abort code.
2789+
initialReactController.signal.addEventListener('abort', () => {
2790+
initialDataController.abort(initialReactController.signal.reason)
2791+
})
2792+
return stream
2793+
},
2794+
(stream) => {
2795+
// Runtime stage
2796+
initialStageController.advanceStage(RenderStage.Runtime)
2797+
2798+
// If we had a cache miss in the static stage, we'll have to disard this stream
2799+
// and render again once the caches are warm.
2800+
if (cacheSignal.hasPendingReads()) {
2801+
return null
2802+
}
2803+
2804+
// If there's no cache misses, we'll continue rendering,
2805+
// and see if there's any cache misses in the runtime stage.
2806+
return stream
27712807
},
2772-
async (stream) => {
2808+
async (maybeStream) => {
27732809
// Dynamic stage
2774-
// Note: if we had cache misses, things that would've happened statically otherwise
2775-
// may be marked as dynamic instead.
2776-
requestStore.prerenderPhase = false
2777-
2778-
// If all cache reads initiated in the static stage have completed,
2779-
// then all of the necessary caches have to be warm (or there's no caches on the page).
2780-
// On the other hand, if we still have pending cache reads, then we had a cache miss,
2781-
// and the static stage didn't render all the content that it normally would have.
2782-
const hadCacheMiss = cacheSignal.hasPendingReads()
2783-
if (!hadCacheMiss) {
2784-
// No cache misses. We can use the stream as is.
2785-
return stream
2786-
} else {
2787-
// Cache miss. We'll discard this stream, and render again.
2810+
2811+
// If we had cache misses in either of the previous stages,
2812+
// then we'll only use this render for filling caches.
2813+
// We won't advance the stage, and thus leave dynamic APIs hanging,
2814+
// because they won't be cached anyway, so it'd be wasted work.
2815+
if (maybeStream === null || cacheSignal.hasPendingReads()) {
27882816
return null
27892817
}
2818+
2819+
// If there's no cache misses, we'll use this render, so let it advance to the dynamic stage.
2820+
initialStageController.advanceStage(RenderStage.Dynamic)
2821+
return maybeStream
27902822
}
27912823
)
27922824
)
@@ -2819,40 +2851,48 @@ async function renderWithRestartOnCacheMissInDev(
28192851
// The initial render acted as a prospective render to warm the caches.
28202852
requestStore = createRequestStore()
28212853

2854+
const finalStageController = new StagedRenderingController()
2855+
28222856
// We've filled the caches, so now we can render as usual,
28232857
// without any cache-filling mechanics.
28242858
requestStore.prerenderResumeDataCache = null
28252859
requestStore.renderResumeDataCache = createRenderResumeDataCache(
28262860
prerenderResumeDataCache
28272861
)
2862+
requestStore.stagedRendering = finalStageController
28282863
requestStore.cacheSignal = null
28292864

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

28342869
const finalRscPayload = await getPayload(requestStore)
2835-
const finalServerStream = await workUnitAsyncStorage.run(
2836-
requestStore,
2837-
scheduleInSequentialTasks,
2838-
() => {
2839-
// Static stage
2840-
requestStore.prerenderPhase = true
2841-
return ComponentMod.renderToReadableStream(
2842-
finalRscPayload,
2843-
clientReferenceManifest.clientModules,
2844-
{
2845-
onError,
2846-
environmentName,
2847-
filterStackFrame,
2848-
debugChannel: debugChannel?.serverSide,
2849-
}
2850-
)
2851-
},
2852-
() => {
2853-
// Dynamic stage
2854-
requestStore.prerenderPhase = false
2855-
}
2870+
const finalServerStream = await workUnitAsyncStorage.run(requestStore, () =>
2871+
pipelineInSequentialTasks(
2872+
() => {
2873+
// Static stage
2874+
return ComponentMod.renderToReadableStream(
2875+
finalRscPayload,
2876+
clientReferenceManifest.clientModules,
2877+
{
2878+
onError,
2879+
environmentName,
2880+
filterStackFrame,
2881+
debugChannel: debugChannel?.serverSide,
2882+
}
2883+
)
2884+
},
2885+
(stream) => {
2886+
// Runtime stage
2887+
finalStageController.advanceStage(RenderStage.Runtime)
2888+
return stream
2889+
},
2890+
(stream) => {
2891+
// Dynamic stage
2892+
finalStageController.advanceStage(RenderStage.Dynamic)
2893+
return stream
2894+
}
2895+
)
28562896
)
28572897

28582898
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)