Skip to content

Commit a2bb723

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

File tree

17 files changed

+356
-159
lines changed

17 files changed

+356
-159
lines changed

packages/next/errors.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -869,5 +869,6 @@
869869
"868": "No reference found for param: %s in reference: %s",
870870
"869": "No reference found for segment: %s with reference: %s",
871871
"870": "`pipelineInSequentialTasks` should not be called in edge runtime.",
872-
"871": "dynamicInDevStagedRendering should only be used in development mode and when Cache Components is enabled."
872+
"871": "dynamicInDevStagedRendering should only be used in development mode and when Cache Components is enabled.",
873+
"872": "Invalid render stage: %s"
873874
}

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]
@@ -2193,8 +2191,21 @@ async function renderToStream(
21932191
)
21942192
}
21952193

2196-
const environmentName = () =>
2197-
requestStore.prerenderPhase === true ? 'Prerender' : 'Server'
2194+
const environmentName = () => {
2195+
const currentStage = requestStore.stagedRendering!.currentStage
2196+
switch (currentStage) {
2197+
case RenderStage.Static:
2198+
return 'Prerender'
2199+
case RenderStage.Runtime:
2200+
// TODO: only label as "Prefetch" if the page has a `prefetch` config.
2201+
return 'Prefetch'
2202+
case RenderStage.Dynamic:
2203+
return 'Server'
2204+
default:
2205+
currentStage satisfies never
2206+
throw new InvariantError(`Invalid render stage: ${currentStage}`)
2207+
}
2208+
}
21982209

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

22112222
const prerenderResumeDataCache = createPrerenderResumeDataCache()
22122223

2224+
const initialRenderReactController = new AbortController() // Controls the react render
2225+
const initialRenderDataController = new AbortController() // Controls hanging promises we create
2226+
const initialRenderStageController = new StagedRenderingController(
2227+
initialRenderDataController.signal
2228+
)
2229+
22132230
requestStore.prerenderResumeDataCache = prerenderResumeDataCache
22142231
// `getRenderResumeDataCache` will fall back to using `prerenderResumeDataCache` as `renderResumeDataCache`,
22152232
// so not having a resume data cache won't break any expectations in case we don't need to restart.
22162233
requestStore.renderResumeDataCache = null
2234+
requestStore.stagedRendering = initialRenderStageController
22172235
requestStore.cacheSignal = cacheSignal
22182236

2219-
const initialRenderReactController = new AbortController()
2220-
22212237
const intialRenderDebugChannel =
22222238
setReactDebugChannel && createDebugChannel()
22232239

22242240
const initialRscPayload = await getPayload()
22252241
const maybeInitialServerStream = await workUnitAsyncStorage.run(
22262242
requestStore,
22272243
() =>
2228-
pipelineInSequentialTasks(
2244+
pipelineInSequentialTasks3(
22292245
() => {
22302246
// Static stage
2231-
requestStore.prerenderPhase = true
2232-
return ComponentMod.renderToReadableStream(
2247+
const stream = ComponentMod.renderToReadableStream(
22332248
initialRscPayload,
22342249
clientReferenceManifest.clientModules,
22352250
{
@@ -2240,25 +2255,57 @@ async function renderToStream(
22402255
signal: initialRenderReactController.signal,
22412256
}
22422257
)
2258+
// If we abort the render, we want to reject the stage-dependent promises as well.
2259+
// Note that we want to install this listener after the render is started
2260+
// so that it runs after react is finished running its abort code.
2261+
initialRenderReactController.signal.addEventListener(
2262+
'abort',
2263+
() => {
2264+
initialRenderDataController.abort(
2265+
initialRenderReactController.signal.reason
2266+
)
2267+
}
2268+
)
2269+
return stream
22432270
},
2244-
async (stream) => {
2245-
// Dynamic stage
2246-
// Note: if we had cache misses, things that would've happened statically otherwise
2247-
// may be marked as dynamic instead.
2248-
requestStore.prerenderPhase = false
2271+
(stream) => {
2272+
// Runtime stage
2273+
initialRenderStageController.advanceStage(RenderStage.Runtime)
22492274

22502275
// If all cache reads initiated in the static stage have completed,
22512276
// then all of the necessary caches have to be warm (or there's no caches on the page).
22522277
// On the other hand, if we still have pending cache reads, then we had a cache miss,
22532278
// and the static stage didn't render all the content that it normally would have.
2254-
const hadCacheMiss = cacheSignal.hasPendingReads()
2255-
if (!hadCacheMiss) {
2279+
if (!cacheSignal.hasPendingReads()) {
22562280
// No cache misses. We can use the stream as is.
22572281
return stream
22582282
} else {
22592283
// Cache miss. We'll discard this stream, and render again.
22602284
return null
22612285
}
2286+
},
2287+
async (maybeStream) => {
2288+
// Dynamic stage
2289+
2290+
if (maybeStream === null) {
2291+
// If we had cache misses in either of the previous stages, then we'll only use this render for filling caches.
2292+
// We won't advance the stage, and thus leave dynamic APIs hanging,
2293+
// because they won't be cached anyway, so it'd be wasted work.
2294+
return null
2295+
}
2296+
2297+
// Note: if we had cache misses, things that would've happened statically otherwise
2298+
// may be marked as dynamic instead.
2299+
initialRenderStageController.advanceStage(RenderStage.Dynamic)
2300+
2301+
// Analogous to the previous stage.
2302+
if (!cacheSignal.hasPendingReads()) {
2303+
// No cache misses. We can use the stream as is.
2304+
return maybeStream
2305+
} else {
2306+
// Cache miss. We'll discard this stream, and render again.
2307+
return null
2308+
}
22622309
}
22632310
)
22642311
)
@@ -2291,11 +2338,14 @@ async function renderToStream(
22912338
// Now, we need to do another render.
22922339
requestStore = createRequestStore()
22932340

2341+
const finalRenderStageController = new StagedRenderingController()
2342+
22942343
// We've filled the caches, so now we can render as usual.
22952344
requestStore.prerenderResumeDataCache = null
22962345
requestStore.renderResumeDataCache = createRenderResumeDataCache(
22972346
prerenderResumeDataCache
22982347
)
2348+
requestStore.stagedRendering = finalRenderStageController
22992349
requestStore.cacheSignal = null
23002350

23012351
// The initial render already wrote to its debug channel. We're not using it,
@@ -2310,25 +2360,32 @@ async function renderToStream(
23102360
const finalRscPayload = await getPayload()
23112361
const finalServerStream = await workUnitAsyncStorage.run(
23122362
requestStore,
2313-
scheduleInSequentialTasks,
2314-
() => {
2315-
// Static stage
2316-
requestStore.prerenderPhase = true
2317-
return ComponentMod.renderToReadableStream(
2318-
finalRscPayload,
2319-
clientReferenceManifest.clientModules,
2320-
{
2321-
onError: serverComponentsErrorHandler,
2322-
environmentName,
2323-
filterStackFrame,
2324-
debugChannel: finalRenderDebugChannel?.serverSide,
2363+
() =>
2364+
pipelineInSequentialTasks3(
2365+
() => {
2366+
// Static stage
2367+
return ComponentMod.renderToReadableStream(
2368+
finalRscPayload,
2369+
clientReferenceManifest.clientModules,
2370+
{
2371+
onError: serverComponentsErrorHandler,
2372+
environmentName,
2373+
filterStackFrame,
2374+
debugChannel: finalRenderDebugChannel?.serverSide,
2375+
}
2376+
)
2377+
},
2378+
(stream) => {
2379+
// Runtime stage
2380+
finalRenderStageController.advanceStage(RenderStage.Runtime)
2381+
return stream
2382+
},
2383+
(stream) => {
2384+
// Dynamic stage
2385+
finalRenderStageController.advanceStage(RenderStage.Dynamic)
2386+
return stream
23252387
}
23262388
)
2327-
},
2328-
() => {
2329-
// Dynamic stage
2330-
requestStore.prerenderPhase = false
2331-
}
23322389
)
23332390

23342391
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: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
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+
waitForStage(stage: NonStaticRenderStage) {
54+
switch (stage) {
55+
case RenderStage.Runtime: {
56+
return this.runtimeStagePromise.promise
57+
}
58+
case RenderStage.Dynamic: {
59+
return this.dynamicStagePromise.promise
60+
}
61+
default: {
62+
stage satisfies never
63+
throw new InvariantError(`Invalid render stage: ${stage}`)
64+
}
65+
}
66+
}
67+
}

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)