Skip to content

Commit 0edd917

Browse files
committed
[Cache Components] separate runtime stage in dev render
1 parent 06d3719 commit 0edd917

File tree

16 files changed

+356
-160
lines changed

16 files changed

+356
-160
lines changed

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,
@@ -209,6 +206,7 @@ import { getDynamicParam } from '../../shared/lib/router/utils/get-dynamic-param
209206
import type { ExperimentalConfig } from '../config-shared'
210207
import type { Params } from '../request/params'
211208
import { createPromiseWithResolvers } from '../../shared/lib/promise-with-resolvers'
209+
import { RenderStage, StagedRenderingController } from './staged-rendering'
212210

213211
export type GetDynamicParamFromSegment = (
214212
// [slug] / [[slug]] / [...slug]
@@ -2175,8 +2173,21 @@ async function renderToStream(
21752173
)
21762174
}
21772175

2178-
const environmentName = () =>
2179-
requestStore.prerenderPhase === true ? 'Prerender' : 'Server'
2176+
const environmentName = () => {
2177+
const currentStage = requestStore.stagedRendering!.currentStage
2178+
switch (currentStage) {
2179+
case RenderStage.Static:
2180+
return 'Prerender'
2181+
case RenderStage.Runtime:
2182+
// TODO: only label as "Prefetch" if the page has a `prefetch` config.
2183+
return 'Prefetch'
2184+
case RenderStage.Dynamic:
2185+
return 'Server'
2186+
default:
2187+
currentStage satisfies never
2188+
throw new InvariantError(`Invalid render stage: ${currentStage}`)
2189+
}
2190+
}
21802191

21812192
// Try to render the page and see if there's any cache misses.
21822193
// If there are, wait for caches to finish and restart the render.
@@ -2192,26 +2203,30 @@ async function renderToStream(
21922203

21932204
const prerenderResumeDataCache = createPrerenderResumeDataCache()
21942205

2206+
const initialRenderReactController = new AbortController() // Controls the react render
2207+
const initialRenderDataController = new AbortController() // Controls hanging promises we create
2208+
const initialRenderStageController = new StagedRenderingController(
2209+
initialRenderDataController.signal
2210+
)
2211+
21952212
requestStore.prerenderResumeDataCache = prerenderResumeDataCache
21962213
// `getRenderResumeDataCache` will fall back to using `prerenderResumeDataCache` as `renderResumeDataCache`,
21972214
// so not having a resume data cache won't break any expectations in case we don't need to restart.
21982215
requestStore.renderResumeDataCache = null
2216+
requestStore.stagedRendering = initialRenderStageController
21992217
requestStore.cacheSignal = cacheSignal
22002218

2201-
const initialRenderReactController = new AbortController()
2202-
22032219
const intialRenderDebugChannel =
22042220
setReactDebugChannel && createDebugChannel()
22052221

22062222
const initialRscPayload = await getPayload()
22072223
const maybeInitialServerStream = await workUnitAsyncStorage.run(
22082224
requestStore,
22092225
() =>
2210-
pipelineInSequentialTasks(
2226+
pipelineInSequentialTasks3(
22112227
() => {
22122228
// Static stage
2213-
requestStore.prerenderPhase = true
2214-
return ComponentMod.renderToReadableStream(
2229+
const stream = ComponentMod.renderToReadableStream(
22152230
initialRscPayload,
22162231
clientReferenceManifest.clientModules,
22172232
{
@@ -2222,25 +2237,57 @@ async function renderToStream(
22222237
signal: initialRenderReactController.signal,
22232238
}
22242239
)
2240+
// If we abort the render, we want to reject the stage-dependent promises as well.
2241+
// Note that we want to install this listener after the render is started
2242+
// so that it runs after react is finished running its abort code.
2243+
initialRenderReactController.signal.addEventListener(
2244+
'abort',
2245+
() => {
2246+
initialRenderDataController.abort(
2247+
initialRenderReactController.signal.reason
2248+
)
2249+
}
2250+
)
2251+
return stream
22252252
},
2226-
async (stream) => {
2227-
// Dynamic stage
2228-
// Note: if we had cache misses, things that would've happened statically otherwise
2229-
// may be marked as dynamic instead.
2230-
requestStore.prerenderPhase = false
2253+
(stream) => {
2254+
// Runtime stage
2255+
initialRenderStageController.advanceStage(RenderStage.Runtime)
22312256

22322257
// If all cache reads initiated in the static stage have completed,
22332258
// then all of the necessary caches have to be warm (or there's no caches on the page).
22342259
// On the other hand, if we still have pending cache reads, then we had a cache miss,
22352260
// and the static stage didn't render all the content that it normally would have.
2236-
const hadCacheMiss = cacheSignal.hasPendingReads()
2237-
if (!hadCacheMiss) {
2261+
if (!cacheSignal.hasPendingReads()) {
22382262
// No cache misses. We can use the stream as is.
22392263
return stream
22402264
} else {
22412265
// Cache miss. We'll discard this stream, and render again.
22422266
return null
22432267
}
2268+
},
2269+
async (maybeStream) => {
2270+
// Dynamic stage
2271+
2272+
if (maybeStream === null) {
2273+
// If we had cache misses in either of the previous stages, then we'll only use this render for filling caches.
2274+
// We won't advance the stage, and thus leave dynamic APIs hanging,
2275+
// because they won't be cached anyway, so it'd be wasted work.
2276+
return null
2277+
}
2278+
2279+
// Note: if we had cache misses, things that would've happened statically otherwise
2280+
// may be marked as dynamic instead.
2281+
initialRenderStageController.advanceStage(RenderStage.Dynamic)
2282+
2283+
// Analogous to the previous stage.
2284+
if (!cacheSignal.hasPendingReads()) {
2285+
// No cache misses. We can use the stream as is.
2286+
return maybeStream
2287+
} else {
2288+
// Cache miss. We'll discard this stream, and render again.
2289+
return null
2290+
}
22442291
}
22452292
)
22462293
)
@@ -2273,11 +2320,14 @@ async function renderToStream(
22732320
// Now, we need to do another render.
22742321
requestStore = createRequestStore()
22752322

2323+
const finalRenderStageController = new StagedRenderingController()
2324+
22762325
// We've filled the caches, so now we can render as usual.
22772326
requestStore.prerenderResumeDataCache = null
22782327
requestStore.renderResumeDataCache = createRenderResumeDataCache(
22792328
prerenderResumeDataCache
22802329
)
2330+
requestStore.stagedRendering = finalRenderStageController
22812331
requestStore.cacheSignal = null
22822332

22832333
// The initial render already wrote to its debug channel. We're not using it,
@@ -2292,25 +2342,32 @@ async function renderToStream(
22922342
const finalRscPayload = await getPayload()
22932343
const finalServerStream = await workUnitAsyncStorage.run(
22942344
requestStore,
2295-
scheduleInSequentialTasks,
2296-
() => {
2297-
// Static stage
2298-
requestStore.prerenderPhase = true
2299-
return ComponentMod.renderToReadableStream(
2300-
finalRscPayload,
2301-
clientReferenceManifest.clientModules,
2302-
{
2303-
onError: serverComponentsErrorHandler,
2304-
environmentName,
2305-
filterStackFrame,
2306-
debugChannel: finalRenderDebugChannel?.serverSide,
2345+
() =>
2346+
pipelineInSequentialTasks3(
2347+
() => {
2348+
// Static stage
2349+
return ComponentMod.renderToReadableStream(
2350+
finalRscPayload,
2351+
clientReferenceManifest.clientModules,
2352+
{
2353+
onError: serverComponentsErrorHandler,
2354+
environmentName,
2355+
filterStackFrame,
2356+
debugChannel: finalRenderDebugChannel?.serverSide,
2357+
}
2358+
)
2359+
},
2360+
(stream) => {
2361+
// Runtime stage
2362+
finalRenderStageController.advanceStage(RenderStage.Runtime)
2363+
return stream
2364+
},
2365+
(stream) => {
2366+
// Dynamic stage
2367+
finalRenderStageController.advanceStage(RenderStage.Dynamic)
2368+
return stream
23072369
}
23082370
)
2309-
},
2310-
() => {
2311-
// Dynamic stage
2312-
requestStore.prerenderPhase = false
2313-
}
23142371
)
23152372

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