Skip to content

Commit 8b51fc2

Browse files
committed
wip
1 parent a928827 commit 8b51fc2

File tree

9 files changed

+465
-61
lines changed

9 files changed

+465
-61
lines changed

packages/next/errors.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -852,5 +852,6 @@
852852
"851": "Pass either `webpack` or `turbopack`, not both.",
853853
"852": "Only custom servers can pass `webpack`, `turbo`, or `turbopack`.",
854854
"853": "Turbopack build failed",
855-
"854": "Expected a %s request header."
855+
"854": "Expected a %s request header.",
856+
"855": "`pipelineInSequentialTasks` should not be called in edge runtime."
856857
}

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

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,3 +29,40 @@ export function scheduleInSequentialTasks<R>(
2929
})
3030
}
3131
}
32+
33+
/**
34+
* This is a utility function to make scheduling sequential tasks that run back to back easier.
35+
* We schedule on the same queue (setTimeout) at the same time to ensure no other events can sneak in between.
36+
* The function that runs in the second task gets access to the first tasks's result.
37+
*/
38+
export function pipelineInSequentialTasks<A, B>(
39+
render: () => A,
40+
followup: (a: A) => B | Promise<B>
41+
): Promise<B> {
42+
if (process.env.NEXT_RUNTIME === 'edge') {
43+
throw new InvariantError(
44+
'`pipelineInSequentialTasks` should not be called in edge runtime.'
45+
)
46+
} else {
47+
return new Promise((resolve, reject) => {
48+
let renderResult: A | undefined = undefined
49+
setTimeout(() => {
50+
try {
51+
renderResult = render()
52+
} catch (err) {
53+
clearTimeout(followupId)
54+
reject(err)
55+
}
56+
}, 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`.
60+
try {
61+
resolve(followup(renderResult!))
62+
} catch (err) {
63+
reject(err)
64+
}
65+
}, 0)
66+
})
67+
}
68+
}

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

Lines changed: 191 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -168,7 +168,10 @@ import {
168168
prerenderAndAbortInSequentialTasks,
169169
} from './app-render-prerender-utils'
170170
import { printDebugThrownValueForProspectiveRender } from './prospective-render-utils'
171-
import { scheduleInSequentialTasks } from './app-render-render-utils'
171+
import {
172+
pipelineInSequentialTasks,
173+
scheduleInSequentialTasks,
174+
} from './app-render-render-utils'
172175
import { waitAtLeastOneReactRenderTask } from '../../lib/scheduler'
173176
import {
174177
workUnitAsyncStorage,
@@ -197,6 +200,7 @@ import {
197200
trackPendingChunkLoad,
198201
trackPendingImport,
199202
trackPendingModules,
203+
trackPendingModulesInRender,
200204
} from './module-loading/track-module-loading.external'
201205
import { isReactLargeShellError } from './react-large-shell-error'
202206
import type { GlobalErrorComponent } from '../../client/components/builtin/global-error'
@@ -2122,57 +2126,198 @@ async function renderToStream(
21222126
// We only have a Prerender environment for projects opted into cacheComponents
21232127
experimental.cacheComponents
21242128
) {
2125-
// This is a dynamic render. We don't do dynamic tracking because we're not prerendering
2126-
const RSCPayload: InitialRSCPayload & {
2129+
type RSCPayloadWithValidation = InitialRSCPayload & {
21272130
/** Only available during cacheComponents development builds. Used for logging errors. */
21282131
_validation?: Promise<React.ReactNode>
2129-
} = await workUnitAsyncStorage.run(
2130-
requestStore,
2131-
getRSCPayload,
2132-
tree,
2133-
ctx,
2134-
res.statusCode === 404
2135-
)
2132+
}
2133+
21362134
const [resolveValidation, validationOutlet] = createValidationOutlet()
2137-
RSCPayload._validation = validationOutlet
21382135

2139-
const debugChannel = setReactDebugChannel && createDebugChannel()
2136+
const getPayload = async (): Promise<RSCPayloadWithValidation> => {
2137+
const payload: RSCPayloadWithValidation =
2138+
await workUnitAsyncStorage.run(
2139+
requestStore,
2140+
getRSCPayload,
2141+
tree,
2142+
ctx,
2143+
res.statusCode === 404
2144+
)
2145+
// Placing the validation outlet in the payload is safe
2146+
// even if we end up discarding a render and restarting,
2147+
// because we're not going to wait for the stream to complete,
2148+
// so leaving the validation unresolved is fine.
2149+
payload._validation = validationOutlet
2150+
return payload
2151+
}
21402152

2141-
if (debugChannel) {
2153+
const setDebugChannelForClientRender = (
2154+
debugChannel: DebugChannelPair
2155+
) => {
21422156
const [readableSsr, readableBrowser] =
21432157
debugChannel.clientSide.readable.tee()
21442158

21452159
reactDebugStream = readableSsr
21462160

2147-
setReactDebugChannel(
2161+
setReactDebugChannel!(
21482162
{ readable: readableBrowser },
21492163
htmlRequestId,
21502164
requestId
21512165
)
21522166
}
21532167

2154-
const reactServerStream = await workUnitAsyncStorage.run(
2168+
const environmentName = () =>
2169+
requestStore.prerenderPhase === true ? 'Prerender' : 'Server'
2170+
2171+
// Try to render the page and see if there's any cache misses.
2172+
// If there are, wait for caches to finish and restart the render.
2173+
2174+
// This render might end up being used as a prospective render (if there's cache misses),
2175+
// so we need to set it up for filling caches.
2176+
const cacheSignal = new CacheSignal()
2177+
2178+
// If we encounter async modules that delay rendering, we'll also need to restart.
2179+
// TODO(restart-on-cache-miss): technically, we only need to wait for pending *server* modules here,
2180+
// but `trackPendingModules` doesn't distinguish between client and server.
2181+
trackPendingModulesInRender(cacheSignal)
2182+
2183+
const prerenderResumeDataCache = createPrerenderResumeDataCache()
2184+
2185+
requestStore.prerenderResumeDataCache = prerenderResumeDataCache
2186+
// `getRenderResumeDataCache` will fall back to using `prerenderResumeDataCache` as `renderResumeDataCache`,
2187+
// so not having a resume data cache won't break any expectations in case we don't need to restart.
2188+
requestStore.renderResumeDataCache = null
2189+
requestStore.cacheSignal = cacheSignal
2190+
2191+
const initialRenderReactController = new AbortController()
2192+
2193+
const intialRenderDebugChannel =
2194+
setReactDebugChannel && createDebugChannel()
2195+
2196+
const initialRscPayload = await getPayload()
2197+
const maybeInitialServerStream = await workUnitAsyncStorage.run(
21552198
requestStore,
2156-
scheduleInSequentialTasks,
2157-
() => {
2158-
requestStore.prerenderPhase = true
2159-
return ComponentMod.renderToReadableStream(
2160-
RSCPayload,
2161-
clientReferenceManifest.clientModules,
2162-
{
2163-
onError: serverComponentsErrorHandler,
2164-
environmentName: () =>
2165-
requestStore.prerenderPhase === true ? 'Prerender' : 'Server',
2166-
filterStackFrame,
2167-
debugChannel: debugChannel?.serverSide,
2199+
() =>
2200+
pipelineInSequentialTasks(
2201+
() => {
2202+
// Static stage
2203+
requestStore.prerenderPhase = true
2204+
return ComponentMod.renderToReadableStream(
2205+
initialRscPayload,
2206+
clientReferenceManifest.clientModules,
2207+
{
2208+
onError: serverComponentsErrorHandler,
2209+
environmentName,
2210+
filterStackFrame,
2211+
debugChannel: intialRenderDebugChannel?.serverSide,
2212+
signal: initialRenderReactController.signal,
2213+
}
2214+
)
2215+
},
2216+
async (stream) => {
2217+
// Dynamic stage
2218+
// Note: if we had cache misses, things that would've happened statically otherwise
2219+
// may be marked as dynamic instead.
2220+
requestStore.prerenderPhase = false
2221+
2222+
// If all cache reads initiated in the static stage have completed,
2223+
// then all of the necessary caches have to be warm (or there's no caches on the page).
2224+
// On the other hand, if we still have pending cache reads, then we had a cache miss,
2225+
// and the static stage didn't render all the content that it normally would have.
2226+
const hadCacheMiss = cacheSignal.hasPendingReads()
2227+
if (!hadCacheMiss) {
2228+
// No cache misses. We can use the stream as is.
2229+
return stream
2230+
} else {
2231+
// Cache miss. We'll discard this stream, and render again.
2232+
return null
2233+
}
21682234
}
21692235
)
2170-
},
2171-
() => {
2172-
requestStore.prerenderPhase = false
2173-
}
21742236
)
21752237

2238+
if (maybeInitialServerStream !== null) {
2239+
// No cache misses. We can use the stream as is.
2240+
2241+
// We're using this render, so we should pass its debug channel to the client render.
2242+
if (intialRenderDebugChannel) {
2243+
setDebugChannelForClientRender(intialRenderDebugChannel)
2244+
}
2245+
2246+
reactServerResult = new ReactServerResult(maybeInitialServerStream)
2247+
} else {
2248+
// Cache miss. We will use the initial render to fill caches, and discard its result.
2249+
// Then, we can render again with warm caches.
2250+
2251+
// TODO(restart-on-cache-miss):
2252+
// This might end up waiting for more caches than strictly necessary,
2253+
// because we can't abort the render yet, and we'll let runtime/dynamic APIs resolve.
2254+
// Ideally we'd only wait for caches that are needed in the static stage.
2255+
// This will be optimized in the future by not allowing runtime/dynamic APIs to resolve.
2256+
2257+
// During a render, React pings pending tasks using `setImmediate`,
2258+
// and only waiting for a single `cacheReady` can make us stop filling caches too soon.
2259+
// To avoid this, we await `cacheReady` repeatedly with an extra delay to let React try render new content
2260+
// (and potentially discover more caches).
2261+
await cacheSignal.cacheReadyInRender()
2262+
initialRenderReactController.abort()
2263+
2264+
//===============================================
2265+
2266+
// The initial render acted as a prospective render to warm the caches.
2267+
// Now, we need to do another render.
2268+
2269+
// TODO(restart-on-cache-miss): we should use a separate request store for this instead
2270+
2271+
// We've filled the caches, so now we can render as usual.
2272+
requestStore.prerenderResumeDataCache = null
2273+
requestStore.renderResumeDataCache = createRenderResumeDataCache(
2274+
prerenderResumeDataCache
2275+
)
2276+
requestStore.cacheSignal = null
2277+
2278+
// Reset mutable fields.
2279+
requestStore.prerenderPhase = undefined
2280+
requestStore.usedDynamic = undefined
2281+
2282+
// The initial render already wrote to its debug channel. We're not using it,
2283+
// so we need to create a new one.
2284+
const finalRenderDebugChannel =
2285+
setReactDebugChannel && createDebugChannel()
2286+
// We know that we won't discard this render, so we can set the debug channel up immediately.
2287+
if (finalRenderDebugChannel) {
2288+
setDebugChannelForClientRender(finalRenderDebugChannel)
2289+
}
2290+
2291+
const finalRscPayload = await getPayload()
2292+
const finalServerStream = await workUnitAsyncStorage.run(
2293+
requestStore,
2294+
scheduleInSequentialTasks,
2295+
() => {
2296+
// Static stage
2297+
requestStore.prerenderPhase = true
2298+
return ComponentMod.renderToReadableStream(
2299+
finalRscPayload,
2300+
clientReferenceManifest.clientModules,
2301+
{
2302+
onError: serverComponentsErrorHandler,
2303+
environmentName,
2304+
filterStackFrame,
2305+
debugChannel: finalRenderDebugChannel?.serverSide,
2306+
}
2307+
)
2308+
},
2309+
() => {
2310+
// Dynamic stage
2311+
requestStore.prerenderPhase = false
2312+
}
2313+
)
2314+
2315+
reactServerResult = new ReactServerResult(finalServerStream)
2316+
}
2317+
2318+
// TODO(restart-on-cache-miss):
2319+
// This can probably be optimized to do less work,
2320+
// because we've already made sure that we have warm caches.
21762321
consoleAsyncStorage.run(
21772322
{ dim: true },
21782323
spawnDynamicValidationInDev,
@@ -2184,8 +2329,6 @@ async function renderToStream(
21842329
requestStore,
21852330
devValidatingFallbackParams
21862331
)
2187-
2188-
reactServerResult = new ReactServerResult(reactServerStream)
21892332
} else {
21902333
// This is a dynamic render. We don't do dynamic tracking because we're not prerendering
21912334
const RSCPayload = await workUnitAsyncStorage.run(
@@ -2537,12 +2680,21 @@ async function renderToStream(
25372680
}
25382681
}
25392682

2540-
function createDebugChannel():
2541-
| {
2542-
serverSide: { readable?: ReadableStream; writable: WritableStream }
2543-
clientSide: { readable: ReadableStream; writable?: WritableStream }
2544-
}
2545-
| undefined {
2683+
type DebugChannelPair = {
2684+
serverSide: DebugChannelServer
2685+
clientSide: DebugChannelClient
2686+
}
2687+
2688+
type DebugChannelServer = {
2689+
readable?: ReadableStream<Uint8Array>
2690+
writable: WritableStream<Uint8Array>
2691+
}
2692+
type DebugChannelClient = {
2693+
readable: ReadableStream<Uint8Array>
2694+
writable?: WritableStream<Uint8Array>
2695+
}
2696+
2697+
function createDebugChannel(): DebugChannelPair | undefined {
25462698
if (process.env.NODE_ENV === 'production') {
25472699
return undefined
25482700
}

packages/next/src/server/app-render/cache-signal.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
* and should only be used in codepaths gated with this feature.
66
*/
77

8+
import { waitAtLeastOneReactRenderTask } from '../../lib/scheduler'
89
import { InvariantError } from '../../shared/lib/invariant-error'
910

1011
export class CacheSignal {
@@ -79,6 +80,23 @@ export class CacheSignal {
7980
})
8081
}
8182

83+
/**
84+
* Like `cacheReady`, but for use when rendering (not prerendering).
85+
* React schedules work differently between these two, which affects the timing
86+
* of waiting for all caches to be discovered.
87+
**/
88+
async cacheReadyInRender() {
89+
// During a render, React pings pending tasks (that are waiting for something async to resolve) using `setImmediate`.
90+
// This is unlike a prerender, where they are pinged in a microtask.
91+
// This means that, if we're warming caches via a render (not a prerender),
92+
// we need to give React more time to continue rendering after a cache has resolved
93+
// in order to make sure we've discovered all the caches needed for the current render.
94+
do {
95+
await this.cacheReady()
96+
await waitAtLeastOneReactRenderTask()
97+
} while (this.hasPendingReads())
98+
}
99+
82100
beginRead() {
83101
this.count++
84102

@@ -114,6 +132,10 @@ export class CacheSignal {
114132
}
115133
}
116134

135+
hasPendingReads(): boolean {
136+
return this.count > 0
137+
}
138+
117139
trackRead<T>(promise: Promise<T>) {
118140
this.beginRead()
119141
// `promise.finally()` still rejects, so don't use it here to avoid unhandled rejections

packages/next/src/server/app-render/module-loading/track-module-loading.external.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,12 @@ import {
66
trackPendingChunkLoad,
77
trackPendingImport,
88
trackPendingModules,
9+
trackPendingModulesInRender,
910
} from './track-module-loading.instance' with { 'turbopack-transition': 'next-shared' }
1011

11-
export { trackPendingChunkLoad, trackPendingImport, trackPendingModules }
12+
export {
13+
trackPendingChunkLoad,
14+
trackPendingImport,
15+
trackPendingModules,
16+
trackPendingModulesInRender,
17+
}

0 commit comments

Comments
 (0)