Skip to content

Commit 523ae09

Browse files
authored
[Cache Components] Faster partial hydration in PPR resumes (#82742)
This PR fixes some HTML/RSC stream interleaving logic that, under some circumstances, resulted in hydration of a resumed page being delayed unnecessarily. Fixes NAR-284 --- When SSRing a page, we're mixing two streams -- the HTML stream, and the RSC stream (containing scripts with RSC data, used for hydration). In a regular (non-PPR) render, we need to wait for react to flush the first HTML chunk (containing `<html><body>...`) before we can send the first RSC script. This was implemented in `createMergedTransformStream`. The problem was that `createMergedTransformStream` was also re-used when rendering a PPR `resume()`. In that case, we're only outputting the dynamic portion of HTML, and the shell is sent separately (either by the next server, outside of `app-render`, or by infrastructure), meaning that is _not_ part of the stream that `createMergedTransformStream` receives. But the "wait for the first html chunk" logic was still there, and thus, we were accidentally delaying sending any RSC scripts until the first piece of dynamic HTML was rendered even if they had no connection to each other. In particular, this also prevented us from sending hydration data for the static shell. There's one edge case here -- if we didn't produce a static shell (e.g. for suspense-above-body), we should still apply the html-waiting logic, otherwise we'd end up sending scripts before the body is rendered. But if we have a shell, we shouldn't wait for the HTML at all. This is now solved as follows: 1. During the prerender, we track whether or not a shell (aka "prelude") was produced, and store that information in the postponed state object 2. When resuming, we check whether the prerender had a shell, and if it does, we don't wait for HTML (under the assumption that it was already sent separately, outside the dynamic render) To test this, I've had to extend our testing setup a bit -- with the default settings, playwright would wait until `load` is fired, which seems to fire after all the HTML finished streaming and thus doesn't let us inspect whether partial hydration is working correctly. We're also waiting for `load` in `elementByCss` (apparently, for compatibility with tests written before playwright) so i've had to work around that as well.
1 parent c970d95 commit 523ae09

File tree

15 files changed

+535
-78
lines changed

15 files changed

+535
-78
lines changed

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

Lines changed: 40 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,7 @@ import {
107107
import {
108108
DynamicState,
109109
type PostponedState,
110+
DynamicHTMLPreludeState,
110111
parsePostponedState,
111112
} from './postponed-state'
112113
import {
@@ -2291,8 +2292,8 @@ async function renderToStream(
22912292
)
22922293
} else if (postponedState) {
22932294
// We assume we have dynamic HTML requiring a resume render to complete
2294-
const postponed = getPostponedFromState(postponedState)
2295-
2295+
const { postponed, preludeState } =
2296+
getPostponedFromState(postponedState)
22962297
const resume = (
22972298
require('react-dom/server') as typeof import('react-dom/server')
22982299
).resume
@@ -2319,6 +2320,12 @@ async function renderToStream(
23192320
tracingMetadata: tracingMetadata,
23202321
})
23212322
return await continueDynamicHTMLResume(htmlStream, {
2323+
// If the prelude is empty (i.e. is no static shell), we should wait for initial HTML to be rendered
2324+
// to avoid injecting RSC data too early.
2325+
// If we have a non-empty-prelude (i.e. a static HTML shell), then it's already been sent separately,
2326+
// so we shouldn't wait for any HTML to be emitted from the resume before sending RSC data.
2327+
delayDataUntilFirstHtmlChunk:
2328+
preludeState === DynamicHTMLPreludeState.Empty,
23222329
inlinedDataStream: createInlinedDataReadableStream(
23232330
reactServerResult.consume(),
23242331
nonce,
@@ -3919,6 +3926,9 @@ async function prerenderToStream(
39193926
// Dynamic HTML case
39203927
metadata.postponed = await getDynamicHTMLPostponedState(
39213928
postponed,
3929+
preludeIsEmpty
3930+
? DynamicHTMLPreludeState.Empty
3931+
: DynamicHTMLPreludeState.Full,
39223932
fallbackRouteParams,
39233933
resumeDataCache
39243934
)
@@ -4071,27 +4081,28 @@ async function prerenderToStream(
40714081
const prerender = (
40724082
require('react-dom/static') as typeof import('react-dom/static')
40734083
).prerender
4074-
const { prelude, postponed } = await workUnitAsyncStorage.run(
4075-
ssrPrerenderStore,
4076-
prerender,
4077-
<App
4078-
reactServerStream={reactServerResult.asUnclosingStream()}
4079-
preinitScripts={preinitScripts}
4080-
clientReferenceManifest={clientReferenceManifest}
4081-
ServerInsertedHTMLProvider={ServerInsertedHTMLProvider}
4082-
nonce={nonce}
4083-
/>,
4084-
{
4085-
onError: htmlRendererErrorHandler,
4086-
onHeaders: (headers: Headers) => {
4087-
headers.forEach((value, key) => {
4088-
appendHeader(key, value)
4089-
})
4090-
},
4091-
maxHeadersLength: reactMaxHeadersLength,
4092-
bootstrapScripts: [bootstrapScript],
4093-
}
4094-
)
4084+
const { prelude: unprocessedPrelude, postponed } =
4085+
await workUnitAsyncStorage.run(
4086+
ssrPrerenderStore,
4087+
prerender,
4088+
<App
4089+
reactServerStream={reactServerResult.asUnclosingStream()}
4090+
preinitScripts={preinitScripts}
4091+
clientReferenceManifest={clientReferenceManifest}
4092+
ServerInsertedHTMLProvider={ServerInsertedHTMLProvider}
4093+
nonce={nonce}
4094+
/>,
4095+
{
4096+
onError: htmlRendererErrorHandler,
4097+
onHeaders: (headers: Headers) => {
4098+
headers.forEach((value, key) => {
4099+
appendHeader(key, value)
4100+
})
4101+
},
4102+
maxHeadersLength: reactMaxHeadersLength,
4103+
bootstrapScripts: [bootstrapScript],
4104+
}
4105+
)
40954106
const getServerInsertedHTML = makeGetServerInsertedHTML({
40964107
polyfills,
40974108
renderServerInsertedHTML,
@@ -4115,6 +4126,9 @@ async function prerenderToStream(
41154126
)
41164127
}
41174128

4129+
const { prelude, preludeIsEmpty } =
4130+
await processPrelude(unprocessedPrelude)
4131+
41184132
/**
41194133
* When prerendering there are three outcomes to consider
41204134
*
@@ -4135,6 +4149,9 @@ async function prerenderToStream(
41354149
// Dynamic HTML case.
41364150
metadata.postponed = await getDynamicHTMLPostponedState(
41374151
postponed,
4152+
preludeIsEmpty
4153+
? DynamicHTMLPreludeState.Empty
4154+
: DynamicHTMLPreludeState.Full,
41384155
fallbackRouteParams,
41394156
prerenderResumeDataCache
41404157
)

packages/next/src/server/app-render/postponed-state.test.ts

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
getDynamicDataPostponedState,
99
getDynamicHTMLPostponedState,
1010
parsePostponedState,
11+
DynamicHTMLPreludeState,
1112
} from './postponed-state'
1213

1314
describe('getDynamicHTMLPostponedState', () => {
@@ -30,19 +31,23 @@ describe('getDynamicHTMLPostponedState', () => {
3031

3132
const state = await getDynamicHTMLPostponedState(
3233
{ [key]: key, nested: { [key]: key } },
34+
DynamicHTMLPreludeState.Full,
3335
fallbackRouteParams,
3436
prerenderResumeDataCache
3537
)
3638

3739
const parsed = parsePostponedState(state, { slug: '123' })
3840
expect(parsed).toMatchInlineSnapshot(`
3941
{
40-
"data": {
41-
"123": "123",
42-
"nested": {
42+
"data": [
43+
1,
44+
{
4345
"123": "123",
46+
"nested": {
47+
"123": "123",
48+
},
4449
},
45-
},
50+
],
4651
"renderResumeDataCache": {
4752
"cache": Map {
4853
"1" => Promise {},
@@ -65,17 +70,19 @@ describe('getDynamicHTMLPostponedState', () => {
6570
it('serializes a HTML postponed state without fallback params', async () => {
6671
const state = await getDynamicHTMLPostponedState(
6772
{ key: 'value' },
73+
DynamicHTMLPreludeState.Full,
6874
null,
6975
createPrerenderResumeDataCache()
7076
)
71-
expect(state).toMatchInlineSnapshot(`"15:{"key":"value"}null"`)
77+
expect(state).toMatchInlineSnapshot(`"19:[1,{"key":"value"}]null"`)
7278
})
7379

7480
it('can serialize and deserialize a HTML postponed state with fallback params', async () => {
7581
const key = '%%drp:slug:e9615126684e5%%'
7682
const fallbackRouteParams = new Map([['slug', key]])
7783
const state = await getDynamicHTMLPostponedState(
7884
{ [key]: key },
85+
DynamicHTMLPreludeState.Full,
7986
fallbackRouteParams,
8087
createPrerenderResumeDataCache()
8188
)
@@ -85,7 +92,7 @@ describe('getDynamicHTMLPostponedState', () => {
8592
const parsed = parsePostponedState(state, params)
8693
expect(parsed).toEqual({
8794
type: DynamicState.HTML,
88-
data: { [value]: value },
95+
data: [1, { [value]: value }],
8996
renderResumeDataCache: createPrerenderResumeDataCache(),
9097
})
9198

@@ -105,7 +112,7 @@ describe('getDynamicDataPostponedState', () => {
105112

106113
describe('parsePostponedState', () => {
107114
it('parses a HTML postponed state with fallback params', () => {
108-
const state = `2589:39[["slug","%%drp:slug:e9615126684e5%%"]]{"t":2,"d":{"nextSegmentId":2,"rootFormatContext":{"insertionMode":0,"selectedValue":null,"tagScope":0},"progressiveChunkSize":12800,"resumableState":{"idPrefix":"","nextFormID":0,"streamingFormat":0,"instructions":0,"hasBody":true,"hasHtml":true,"unknownResources":{},"dnsResources":{},"connectResources":{"default":{},"anonymous":{},"credentials":{}},"imageResources":{},"styleResources":{},"scriptResources":{"/_next/static/chunks/webpack-6b2534a6458c6fe5.js":null,"/_next/static/chunks/f5e865f6-5e04edf75402c5e9.js":null,"/_next/static/chunks/9440-26a4cfbb73347735.js":null,"/_next/static/chunks/main-app-315ef55d588dbeeb.js":null,"/_next/static/chunks/8630-8e01a4bea783c651.js":null,"/_next/static/chunks/app/layout-1b900e1a3caf3737.js":null},"moduleUnknownResources":{},"moduleScriptResources":{"/_next/static/chunks/webpack-6b2534a6458c6fe5.js":null}},"replayNodes":[["oR",0,[["Context.Provider",0,[["ServerInsertedHTMLProvider",0,[["Context.Provider",0,[["n7",0,[["nU",0,[["nF",0,[["n9",0,[["Fragment",0,[["Context.Provider",2,[["Context.Provider",0,[["Context.Provider",0,[["Context.Provider",0,[["Context.Provider",0,[["Context.Provider",0,[["nY",0,[["nX",0,[["Fragment","c",[["Fragment",0,[["html",1,[["body",0,[["main",3,[["j",0,[["Fragment",0,[["Context.Provider","validation",[["i",2,[["Fragment",0,[["E",0,[["R",0,[["h",0,[["Fragment",0,[["O",0,[["Fragment",0,[["s",0,[["c",0,[["s",0,[["c",0,[["v",0,[["Context.Provider",0,[["Fragment","c",[["j",1,[["Fragment",0,[["Context.Provider","slug|%%drp:slug:e9615126684e5%%|d",[["i",2,[["Fragment",0,[["E",0,[["R",0,[["h",0,[["Fragment",0,[["O",0,[["Fragment",0,[["s",0,[["Fragment",0,[["s",0,[["c",0,[["v",0,[["Context.Provider",0,[["Fragment","c",[["j",1,[["Fragment",0,[["Context.Provider","__PAGE__",[["i",2,[["Fragment",0,[["E",0,[["R",0,[["h",0,[["Fragment",0,[["O",0,[["Suspense",0,[["s",0,[["Fragment",0,[["s",0,[["c",0,[["v",0,[["Context.Provider",0,[["Fragment","c",[["Fragment",0,[],{"1":1}]],null]],null]],null]],null]],null]],null]],null]],null,["Suspense Fallback",0,[],null],0]],null]],null]],null]],null]],null]],null]],null]],null]],null]],null]],null]],null]],null]],null]],null]],null]],null]],null]],null]],null]],null]],null]],null]],null]],null]],null]],null]],null]],null]],null]],null]],null]],null]],null]],null]],null]],null]],null]],null]],null]],null]],null]],null]],null]],null]],null]],null]],null]],null]],null]],null]],null]],null]],null]],null]],null]],null]],null]],null]],null]],null]],null]],null]],null]],null]],null]],null]],null]],"replaySlots":null}}null`
115+
const state = `2593:39[["slug","%%drp:slug:e9615126684e5%%"]][1,{"t":2,"d":{"nextSegmentId":2,"rootFormatContext":{"insertionMode":0,"selectedValue":null,"tagScope":0},"progressiveChunkSize":12800,"resumableState":{"idPrefix":"","nextFormID":0,"streamingFormat":0,"instructions":0,"hasBody":true,"hasHtml":true,"unknownResources":{},"dnsResources":{},"connectResources":{"default":{},"anonymous":{},"credentials":{}},"imageResources":{},"styleResources":{},"scriptResources":{"/_next/static/chunks/webpack-6b2534a6458c6fe5.js":null,"/_next/static/chunks/f5e865f6-5e04edf75402c5e9.js":null,"/_next/static/chunks/9440-26a4cfbb73347735.js":null,"/_next/static/chunks/main-app-315ef55d588dbeeb.js":null,"/_next/static/chunks/8630-8e01a4bea783c651.js":null,"/_next/static/chunks/app/layout-1b900e1a3caf3737.js":null},"moduleUnknownResources":{},"moduleScriptResources":{"/_next/static/chunks/webpack-6b2534a6458c6fe5.js":null}},"replayNodes":[["oR",0,[["Context.Provider",0,[["ServerInsertedHTMLProvider",0,[["Context.Provider",0,[["n7",0,[["nU",0,[["nF",0,[["n9",0,[["Fragment",0,[["Context.Provider",2,[["Context.Provider",0,[["Context.Provider",0,[["Context.Provider",0,[["Context.Provider",0,[["Context.Provider",0,[["nY",0,[["nX",0,[["Fragment","c",[["Fragment",0,[["html",1,[["body",0,[["main",3,[["j",0,[["Fragment",0,[["Context.Provider","validation",[["i",2,[["Fragment",0,[["E",0,[["R",0,[["h",0,[["Fragment",0,[["O",0,[["Fragment",0,[["s",0,[["c",0,[["s",0,[["c",0,[["v",0,[["Context.Provider",0,[["Fragment","c",[["j",1,[["Fragment",0,[["Context.Provider","slug|%%drp:slug:e9615126684e5%%|d",[["i",2,[["Fragment",0,[["E",0,[["R",0,[["h",0,[["Fragment",0,[["O",0,[["Fragment",0,[["s",0,[["Fragment",0,[["s",0,[["c",0,[["v",0,[["Context.Provider",0,[["Fragment","c",[["j",1,[["Fragment",0,[["Context.Provider","__PAGE__",[["i",2,[["Fragment",0,[["E",0,[["R",0,[["h",0,[["Fragment",0,[["O",0,[["Suspense",0,[["s",0,[["Fragment",0,[["s",0,[["c",0,[["v",0,[["Context.Provider",0,[["Fragment","c",[["Fragment",0,[],{"1":1}]],null]],null]],null]],null]],null]],null]],null]],null,["Suspense Fallback",0,[],null],0]],null]],null]],null]],null]],null]],null]],null]],null]],null]],null]],null]],null]],null]],null]],null]],null]],null]],null]],null]],null]],null]],null]],null]],null]],null]],null]],null]],null]],null]],null]],null]],null]],null]],null]],null]],null]],null]],null]],null]],null]],null]],null]],null]],null]],null]],null]],null]],null]],null]],null]],null]],null]],null]],null]],null]],null]],null]],null]],null]],null]],null]],null]],null]],null]],null]],null]],null]],null]],"replaySlots":null}}]null`
109116
const params = {
110117
slug: Math.random().toString(16).slice(3),
111118
}

packages/next/src/server/app-render/postponed-state.ts

Lines changed: 22 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -47,35 +47,48 @@ export type DynamicHTMLPostponedState = {
4747
/**
4848
* The postponed data used by React.
4949
*/
50-
readonly data: object
50+
readonly data: [
51+
preludeState: DynamicHTMLPreludeState,
52+
postponed: ReactPostponed,
53+
]
5154

5255
/**
5356
* The immutable resume data cache.
5457
*/
5558
readonly renderResumeDataCache: RenderResumeDataCache
5659
}
5760

61+
export const enum DynamicHTMLPreludeState {
62+
Empty = 0,
63+
Full = 1,
64+
}
65+
66+
type ReactPostponed = NonNullable<
67+
import('react-dom/static').PrerenderResult['postponed']
68+
>
69+
5870
export type PostponedState =
5971
| DynamicDataPostponedState
6072
| DynamicHTMLPostponedState
6173

6274
export async function getDynamicHTMLPostponedState(
63-
data: object,
75+
postponed: ReactPostponed,
76+
preludeState: DynamicHTMLPreludeState,
6477
fallbackRouteParams: FallbackRouteParams | null,
6578
resumeDataCache: PrerenderResumeDataCache | RenderResumeDataCache
6679
): Promise<string> {
67-
if (!fallbackRouteParams || fallbackRouteParams.size === 0) {
68-
const postponedString = JSON.stringify(data)
80+
const data: DynamicHTMLPostponedState['data'] = [preludeState, postponed]
81+
const dataString = JSON.stringify(data)
6982

83+
if (!fallbackRouteParams || fallbackRouteParams.size === 0) {
7084
// Serialized as `<postponedString.length>:<postponedString><renderResumeDataCache>`
71-
return `${postponedString.length}:${postponedString}${await stringifyResumeDataCache(
85+
return `${dataString.length}:${dataString}${await stringifyResumeDataCache(
7286
createRenderResumeDataCache(resumeDataCache)
7387
)}`
7488
}
7589

7690
const replacements: Array<[string, string]> = Array.from(fallbackRouteParams)
7791
const replacementsString = JSON.stringify(replacements)
78-
const dataString = JSON.stringify(data)
7992

8093
// Serialized as `<replacements.length><replacements><data>`
8194
const postponedString = `${replacementsString.length}${replacementsString}${dataString}`
@@ -168,10 +181,7 @@ export function parsePostponedState(
168181
}
169182
}
170183

171-
export function getPostponedFromState(state: PostponedState): any {
172-
if (state.type === DynamicState.DATA) {
173-
return null
174-
}
175-
176-
return state.data
184+
export function getPostponedFromState(state: DynamicHTMLPostponedState) {
185+
const [preludeState, postponed] = state.data
186+
return { preludeState, postponed }
177187
}

0 commit comments

Comments
 (0)