Skip to content

Commit 537b9d0

Browse files
committed
[Cache Components] separate runtime stage in dev render
1 parent 06765ee commit 537b9d0

File tree

17 files changed

+378
-132
lines changed

17 files changed

+378
-132
lines changed

packages/next/errors.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -868,5 +868,6 @@
868868
"867": "The %s \"%s\" must export a %s or a \\`default\\` function",
869869
"868": "No reference found for param: %s in reference: %s",
870870
"869": "No reference found for segment: %s with reference: %s",
871-
"870": "`pipelineInSequentialTasks` should not be called in edge runtime."
871+
"870": "`pipelineInSequentialTasks` should not be called in edge runtime.",
872+
"871": "Invalid render stage: %s"
872873
}

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 pipelineInSequentialTasks3<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: 92 additions & 35 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 { pipelineInSequentialTasks3 } from './app-render-render-utils'
175172
import { waitAtLeastOneReactRenderTask } from '../../lib/scheduler'
176173
import {
177174
workUnitAsyncStorage,
@@ -212,6 +209,7 @@ import {
212209
import type { ExperimentalConfig } from '../config-shared'
213210
import type { Params } from '../request/params'
214211
import { createPromiseWithResolvers } from '../../shared/lib/promise-with-resolvers'
212+
import { RenderStage, StagedRenderingController } from './staged-rendering'
215213

216214
export type GetDynamicParamFromSegment = (
217215
// [slug] / [[slug]] / [...slug]
@@ -2203,8 +2201,21 @@ async function renderToStream(
22032201
)
22042202
}
22052203

2206-
const environmentName = () =>
2207-
requestStore.prerenderPhase === true ? 'Prerender' : 'Server'
2204+
const environmentName = () => {
2205+
const currentStage = requestStore.stagedRendering!.currentStage
2206+
switch (currentStage) {
2207+
case RenderStage.Static:
2208+
return 'Prerender'
2209+
case RenderStage.Runtime:
2210+
// TODO: only label as "Prefetch" if the page has a `prefetch` config.
2211+
return 'Prefetch'
2212+
case RenderStage.Dynamic:
2213+
return 'Server'
2214+
default:
2215+
currentStage satisfies never
2216+
throw new InvariantError(`Invalid render stage: ${currentStage}`)
2217+
}
2218+
}
22082219

22092220
// Try to render the page and see if there's any cache misses.
22102221
// If there are, wait for caches to finish and restart the render.
@@ -2220,26 +2231,30 @@ async function renderToStream(
22202231

22212232
const prerenderResumeDataCache = createPrerenderResumeDataCache()
22222233

2234+
const initialRenderReactController = new AbortController() // Controls the react render
2235+
const initialRenderDataController = new AbortController() // Controls hanging promises we create
2236+
const initialRenderStageController = new StagedRenderingController(
2237+
initialRenderDataController.signal
2238+
)
2239+
22232240
requestStore.prerenderResumeDataCache = prerenderResumeDataCache
22242241
// `getRenderResumeDataCache` will fall back to using `prerenderResumeDataCache` as `renderResumeDataCache`,
22252242
// so not having a resume data cache won't break any expectations in case we don't need to restart.
22262243
requestStore.renderResumeDataCache = null
2244+
requestStore.stagedRendering = initialRenderStageController
22272245
requestStore.cacheSignal = cacheSignal
22282246

2229-
const initialRenderReactController = new AbortController()
2230-
22312247
const intialRenderDebugChannel =
22322248
setReactDebugChannel && createDebugChannel()
22332249

22342250
const initialRscPayload = await getPayload()
22352251
const maybeInitialServerStream = await workUnitAsyncStorage.run(
22362252
requestStore,
22372253
() =>
2238-
pipelineInSequentialTasks(
2254+
pipelineInSequentialTasks3(
22392255
() => {
22402256
// Static stage
2241-
requestStore.prerenderPhase = true
2242-
return ComponentMod.renderToReadableStream(
2257+
const stream = ComponentMod.renderToReadableStream(
22432258
initialRscPayload,
22442259
clientReferenceManifest.clientModules,
22452260
{
@@ -2250,25 +2265,57 @@ async function renderToStream(
22502265
signal: initialRenderReactController.signal,
22512266
}
22522267
)
2268+
// If we abort the render, we want to reject the stage-dependent promises as well.
2269+
// Note that we want to install this listener after the render is started
2270+
// so that it runs after react is finished running its abort code.
2271+
initialRenderReactController.signal.addEventListener(
2272+
'abort',
2273+
() => {
2274+
initialRenderDataController.abort(
2275+
initialRenderReactController.signal.reason
2276+
)
2277+
}
2278+
)
2279+
return stream
22532280
},
2254-
async (stream) => {
2255-
// Dynamic stage
2256-
// Note: if we had cache misses, things that would've happened statically otherwise
2257-
// may be marked as dynamic instead.
2258-
requestStore.prerenderPhase = false
2281+
(stream) => {
2282+
// Runtime stage
2283+
initialRenderStageController.advanceStage(RenderStage.Runtime)
22592284

22602285
// If all cache reads initiated in the static stage have completed,
22612286
// then all of the necessary caches have to be warm (or there's no caches on the page).
22622287
// On the other hand, if we still have pending cache reads, then we had a cache miss,
22632288
// and the static stage didn't render all the content that it normally would have.
2264-
const hadCacheMiss = cacheSignal.hasPendingReads()
2265-
if (!hadCacheMiss) {
2289+
if (!cacheSignal.hasPendingReads()) {
22662290
// No cache misses. We can use the stream as is.
22672291
return stream
22682292
} else {
22692293
// Cache miss. We'll discard this stream, and render again.
22702294
return null
22712295
}
2296+
},
2297+
async (maybeStream) => {
2298+
// Dynamic stage
2299+
2300+
if (maybeStream === null) {
2301+
// If we had cache misses in either of the previous stages, then we'll only use this render for filling caches.
2302+
// We won't advance the stage, and thus leave dynamic APIs hanging,
2303+
// because they won't be cached anyway, so it'd be wasted work.
2304+
return null
2305+
}
2306+
2307+
// Note: if we had cache misses, things that would've happened statically otherwise
2308+
// may be marked as dynamic instead.
2309+
initialRenderStageController.advanceStage(RenderStage.Dynamic)
2310+
2311+
// Analogous to the previous stage.
2312+
if (!cacheSignal.hasPendingReads()) {
2313+
// No cache misses. We can use the stream as is.
2314+
return maybeStream
2315+
} else {
2316+
// Cache miss. We'll discard this stream, and render again.
2317+
return null
2318+
}
22722319
}
22732320
)
22742321
)
@@ -2301,11 +2348,14 @@ async function renderToStream(
23012348
// Now, we need to do another render.
23022349
requestStore = createRequestStore()
23032350

2351+
const finalRenderStageController = new StagedRenderingController()
2352+
23042353
// We've filled the caches, so now we can render as usual.
23052354
requestStore.prerenderResumeDataCache = null
23062355
requestStore.renderResumeDataCache = createRenderResumeDataCache(
23072356
prerenderResumeDataCache
23082357
)
2358+
requestStore.stagedRendering = finalRenderStageController
23092359
requestStore.cacheSignal = null
23102360

23112361
// The initial render already wrote to its debug channel. We're not using it,
@@ -2320,25 +2370,32 @@ async function renderToStream(
23202370
const finalRscPayload = await getPayload()
23212371
const finalServerStream = await workUnitAsyncStorage.run(
23222372
requestStore,
2323-
scheduleInSequentialTasks,
2324-
() => {
2325-
// Static stage
2326-
requestStore.prerenderPhase = true
2327-
return ComponentMod.renderToReadableStream(
2328-
finalRscPayload,
2329-
clientReferenceManifest.clientModules,
2330-
{
2331-
onError: serverComponentsErrorHandler,
2332-
environmentName,
2333-
filterStackFrame,
2334-
debugChannel: finalRenderDebugChannel?.serverSide,
2373+
() =>
2374+
pipelineInSequentialTasks3(
2375+
() => {
2376+
// Static stage
2377+
return ComponentMod.renderToReadableStream(
2378+
finalRscPayload,
2379+
clientReferenceManifest.clientModules,
2380+
{
2381+
onError: serverComponentsErrorHandler,
2382+
environmentName,
2383+
filterStackFrame,
2384+
debugChannel: finalRenderDebugChannel?.serverSide,
2385+
}
2386+
)
2387+
},
2388+
(stream) => {
2389+
// Runtime stage
2390+
finalRenderStageController.advanceStage(RenderStage.Runtime)
2391+
return stream
2392+
},
2393+
(stream) => {
2394+
// Dynamic stage
2395+
finalRenderStageController.advanceStage(RenderStage.Dynamic)
2396+
return stream
23352397
}
23362398
)
2337-
},
2338-
() => {
2339-
// Dynamic stage
2340-
requestStore.prerenderPhase = false
2341-
}
23422399
)
23432400

23442401
reactServerResult = new ReactServerResult(finalServerStream)

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

Lines changed: 6 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,11 @@ 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: this doesn't seem like it'll actually do what we need?
305+
requestStore.stagedRendering.advanceStage(RenderStage.Dynamic)
306+
}
303307
}
304308

305309
/**
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
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(abortSignal?: AbortSignal) {
19+
if (abortSignal) {
20+
abortSignal.addEventListener(
21+
'abort',
22+
() => {
23+
const { reason } = abortSignal
24+
if (this.currentStage < RenderStage.Runtime) {
25+
this.runtimeStagePromise.reject(reason)
26+
}
27+
if (this.currentStage < RenderStage.Dynamic) {
28+
this.dynamicStagePromise.reject(reason)
29+
}
30+
},
31+
{ once: true }
32+
)
33+
}
34+
}
35+
36+
advanceStage(stage: NonStaticRenderStage) {
37+
// If we're already at the target stage or beyond, do nothing.
38+
// (this can happen e.g. if sync IO advanced us to the dynamic stage)
39+
if (this.currentStage >= stage) {
40+
return
41+
}
42+
this.currentStage = stage
43+
// Note that we might be going directly from Static to Dynamic,
44+
// so we need to resolve the runtime stage as well.
45+
if (stage >= RenderStage.Runtime) {
46+
this.runtimeStagePromise.resolve()
47+
}
48+
if (stage >= RenderStage.Dynamic) {
49+
this.dynamicStagePromise.resolve()
50+
}
51+
}
52+
53+
delayUntilStage<T>(stage: NonStaticRenderStage, resolvedValue: T) {
54+
let promise: Promise<void>
55+
switch (stage) {
56+
case RenderStage.Runtime: {
57+
promise = this.runtimeStagePromise.promise
58+
break
59+
}
60+
case RenderStage.Dynamic: {
61+
promise = this.dynamicStagePromise.promise
62+
break
63+
}
64+
default: {
65+
stage satisfies never
66+
throw new InvariantError(`Invalid render stage: ${stage}`)
67+
}
68+
}
69+
70+
// FIXME: this seems to be the only form that leads to correct API names
71+
// being displayed in React Devtools (in the "suspended by" section).
72+
// If we use `promise.then(() => resolvedValue)`, the names are lost.
73+
// It's a bit strange that only one of those works right.
74+
return new Promise<T>((resolve, reject) => {
75+
promise.then(resolve.bind(null, resolvedValue), reject)
76+
})
77+
}
78+
}

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import type { ImplicitTags } from '../lib/implicit-tags'
1919
import type { WorkStore } from './work-async-storage.external'
2020
import { NEXT_HMR_REFRESH_HASH_COOKIE } from '../../client/components/app-router-headers'
2121
import { InvariantError } from '../../shared/lib/invariant-error'
22+
import type { StagedRenderingController } from './staged-rendering'
2223

2324
export type WorkUnitPhase = 'action' | 'render' | 'after'
2425

@@ -67,8 +68,8 @@ export interface RequestStore extends CommonWorkUnitStore {
6768

6869
// DEV-only
6970
usedDynamic?: boolean
70-
prerenderPhase?: boolean
7171
devFallbackParams?: OpaqueFallbackRouteParams | null
72+
stagedRendering?: StagedRenderingController | null
7273
cacheSignal?: CacheSignal | null
7374
prerenderResumeDataCache?: PrerenderResumeDataCache | null
7475
}

0 commit comments

Comments
 (0)