@samitouri / QOS-React / commits / b25bcd460f

[Fizz] Support Suspense boundaries anywhere (#32069)

Suspense is meant to be composable but there has been a lonstanding limitation with using Suspense above the `<body>` tag of an HTML document due to peculiarities of how HTML is parsed. For instance if you used Suspense to render an entire HTML document and had a fallback that might flush an alternate Document the comment nodes which describe this boundary scope won't be where they need to be in the DOM for client React to properly hydrate them. This is somewhat a problem of our own making in that we have a concept of a Preamble and we leave the closing body and html tags behind until streaming has completed which produces a valid HTML document that also matches the DOM structure that would be parsed from it. However Preambles as a concept are too important to features like Float to imagine moving away from this model and so we can either choose to just accept that you cannot use Suspense anywhere except inside the `<body>` or we can build special support for Suspense into react-dom that has a coherent semantic with how HTML documents are written and parsed. This change implements Suspense support for react-dom/server by correctly serializing boundaries during rendering, prerendering, and resumgin on the server. It does not yet support Suspense everywhere on the client but this will arrive in a subsequent change. In practice Suspense cannot be used above the `<body>` tag today so this is not a breaking change since no programs in the wild could be using this feature anyway. React's streaming rendering of HTML doesn't lend itself to replacing the contents of the documentElement, head, or body of a Document. These are already special cased in fiber as HostSingletons and similarly for Fizz the values we render for these tags must never be updated by the Fizz runtime once written. To accomplish these we redefine the Preamble as the tags that represent these three singletons plus the contents of the document.head. If you use Suspense above any part of the Preamble then nothing will be written to the destination until the boundary is no longer pending. If the boundary completes then the preamble from within that boudnary will be output. If the boundary postpones or errors then the preamble from the fallback will be used instead. Additionally, by default anything that is not part of the preamble is implicitly in body scope. This leads to the somewhat counterintuitive consequence that the comment nodes we use to mark the borders of a Suspense boundary in Fizz can appear INSIDE the preamble that was rendered within it. ```typescript render(( <Suspense> <html lang="en"> <body> <div>hello world</div> </body> </html> </Suspense> )) ``` will produce an HTML document like this ```html <!DOCTYPE html> <html lang="en"> <head></head> <body> <!--$--> <-- this is the comment Node representing the outermost Suspense <div>hello world</div> <$--/$--> </body> </html> ``` Later when I update Fiber to support Suspense anywhere hydration will similarly start implicitly in the document body when the root is part of the preamble (the document or one of it's singletons).

Josh Story committed Jan 17, 2025 at 10:54 UTC b25bcd460f98a0b89e5a7199a6c88112163d961f
11 files changed +2022 -209
packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js
+174 -32
@@ -135,8 +135,7 @@ export type RenderState = {
135 // be null or empty when resuming.
136
137 // preamble chunks
138 - htmlChunks: null | Array<Chunk | PrecomputedChunk>,
139 - headChunks: null | Array<Chunk | PrecomputedChunk>,
138 + preamble: PreambleState,
139
140 // external runtime script chunks
141 externalRuntimeScript: null | ExternalRuntimeScript,
@@ -442,8 +441,7 @@ export function createRenderState(
441 segmentPrefix: stringToPrecomputedChunk(idPrefix + 'S:'),
442 boundaryPrefix: stringToPrecomputedChunk(idPrefix + 'B:'),
443 startInlineScript: inlineScriptWithNonce,
445 - htmlChunks: null,
446 - headChunks: null,
444 + preamble: createPreambleState(),
445
446 externalRuntimeScript: externalRuntimeScript,
447 bootstrapChunks: bootstrapChunks,
@@ -686,6 +684,19 @@ export function completeResumableState(resumableState: ResumableState): void {
684 resumableState.bootstrapModules = undefined;
685 }
686
687 +export type PreambleState = {
688 + htmlChunks: null | Array<Chunk | PrecomputedChunk>,
689 + headChunks: null | Array<Chunk | PrecomputedChunk>,
690 + bodyChunks: null | Array<Chunk | PrecomputedChunk>,
691 +};
692 +export function createPreambleState(): PreambleState {
693 + return {
694 + htmlChunks: null,
695 + headChunks: null,
696 + bodyChunks: null,
697 + };
698 +}
699 +
700 // Constants for the insertion mode we're currently writing in. We don't encode all HTML5 insertion
701 // modes. We only include the variants as they matter for the sake of our purposes.
702 // We don't actually provide the namespace therefore we use constants instead of the string.
@@ -694,16 +705,17 @@ export const ROOT_HTML_MODE = 0; // Used for the root most element tag.
705 // still makes sense
706 const HTML_HTML_MODE = 1; // Used for the <html> if it is at the top level.
707 const HTML_MODE = 2;
697 -const SVG_MODE = 3;
698 -const MATHML_MODE = 4;
699 -const HTML_TABLE_MODE = 5;
700 -const HTML_TABLE_BODY_MODE = 6;
701 -const HTML_TABLE_ROW_MODE = 7;
702 -const HTML_COLGROUP_MODE = 8;
708 +const HTML_HEAD_MODE = 3;
709 +const SVG_MODE = 4;
710 +const MATHML_MODE = 5;
711 +const HTML_TABLE_MODE = 6;
712 +const HTML_TABLE_BODY_MODE = 7;
713 +const HTML_TABLE_ROW_MODE = 8;
714 +const HTML_COLGROUP_MODE = 9;
715 // We have a greater than HTML_TABLE_MODE check elsewhere. If you add more cases here, make sure it
716 // still makes sense
717
706 -type InsertionMode = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8;
718 +type InsertionMode = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9;
719
720 const NO_SCOPE = /* */ 0b00;
721 const NOSCRIPT_SCOPE = /* */ 0b01;
@@ -728,6 +740,10 @@ function createFormatContext(
740 };
741 }
742
743 +export function canHavePreamble(formatContext: FormatContext): boolean {
744 + return formatContext.insertionMode < HTML_MODE;
745 +}
746 +
747 export function createRootFormatContext(namespaceURI?: string): FormatContext {
748 const insertionMode =
749 namespaceURI === 'http://www.w3.org/2000/svg'
@@ -792,27 +808,42 @@ export function getChildFormatContext(
808 null,
809 parentContext.tagScope,
810 );
811 + case 'head':
812 + if (parentContext.insertionMode < HTML_MODE) {
813 + // We are either at the root or inside the <html> tag and can enter
814 + // the <head> scope
815 + return createFormatContext(
816 + HTML_HEAD_MODE,
817 + null,
818 + parentContext.tagScope,
819 + );
820 + }
821 + break;
822 + case 'html':
823 + if (parentContext.insertionMode === ROOT_HTML_MODE) {
824 + return createFormatContext(
825 + HTML_HTML_MODE,
826 + null,
827 + parentContext.tagScope,
828 + );
829 + }
830 + break;
831 }
832 if (parentContext.insertionMode >= HTML_TABLE_MODE) {
833 // Whatever tag this was, it wasn't a table parent or other special parent, so we must have
834 // entered plain HTML again.
835 return createFormatContext(HTML_MODE, null, parentContext.tagScope);
836 }
801 - if (parentContext.insertionMode === ROOT_HTML_MODE) {
802 - if (type === 'html') {
803 - // We've emitted the root and is now in <html> mode.
804 - return createFormatContext(HTML_HTML_MODE, null, parentContext.tagScope);
805 - } else {
806 - // We've emitted the root and is now in plain HTML mode.
807 - return createFormatContext(HTML_MODE, null, parentContext.tagScope);
808 - }
809 - } else if (parentContext.insertionMode === HTML_HTML_MODE) {
810 - // We've emitted the document element and is now in plain HTML mode.
837 + if (parentContext.insertionMode < HTML_MODE) {
838 return createFormatContext(HTML_MODE, null, parentContext.tagScope);
839 }
840 return parentContext;
841 }
842
843 +export function isPreambleContext(formatContext: FormatContext): boolean {
844 + return formatContext.insertionMode === HTML_HEAD_MODE;
845 +}
846 +
847 export function makeId(
848 resumableState: ResumableState,
849 treeId: string,
@@ -3185,12 +3216,18 @@ function pushStartHead(
3216 target: Array<Chunk | PrecomputedChunk>,
3217 props: Object,
3218 renderState: RenderState,
3219 + preambleState: null | PreambleState,
3220 insertionMode: InsertionMode,
3221 ): ReactNodeList {
3190 - if (insertionMode < HTML_MODE && renderState.headChunks === null) {
3222 + if (insertionMode < HTML_MODE) {
3223 // This <head> is the Document.head and should be part of the preamble
3192 - renderState.headChunks = [];
3193 - return pushStartGenericElement(renderState.headChunks, props, 'head');
3224 + const preamble = preambleState || renderState.preamble;
3225 +
3226 + if (preamble.headChunks) {
3227 + throw new Error(`The ${'`<head>`'} tag may only be rendered once.`);
3228 + }
3229 + preamble.headChunks = [];
3230 + return pushStartGenericElement(preamble.headChunks, props, 'head');
3231 } else {
3232 // This <head> is deep and is likely just an error. we emit it inline though.
3233 // Validation should warn that this tag is the the wrong spot.
@@ -3198,16 +3235,47 @@ function pushStartHead(
3235 }
3236 }
3237
3238 +function pushStartBody(
3239 + target: Array<Chunk | PrecomputedChunk>,
3240 + props: Object,
3241 + renderState: RenderState,
3242 + preambleState: null | PreambleState,
3243 + insertionMode: InsertionMode,
3244 +): ReactNodeList {
3245 + if (insertionMode < HTML_MODE) {
3246 + // This <body> is the Document.body
3247 + const preamble = preambleState || renderState.preamble;
3248 +
3249 + if (preamble.bodyChunks) {
3250 + throw new Error(`The ${'`<body>`'} tag may only be rendered once.`);
3251 + }
3252 +
3253 + preamble.bodyChunks = [];
3254 + return pushStartGenericElement(preamble.bodyChunks, props, 'body');
3255 + } else {
3256 + // This <head> is deep and is likely just an error. we emit it inline though.
3257 + // Validation should warn that this tag is the the wrong spot.
3258 + return pushStartGenericElement(target, props, 'body');
3259 + }
3260 +}
3261 +
3262 function pushStartHtml(
3263 target: Array<Chunk | PrecomputedChunk>,
3264 props: Object,
3265 renderState: RenderState,
3266 + preambleState: null | PreambleState,
3267 insertionMode: InsertionMode,
3268 ): ReactNodeList {
3207 - if (insertionMode === ROOT_HTML_MODE && renderState.htmlChunks === null) {
3208 - // This <html> is the Document.documentElement and should be part of the preamble
3209 - renderState.htmlChunks = [DOCTYPE];
3210 - return pushStartGenericElement(renderState.htmlChunks, props, 'html');
3269 + if (insertionMode === ROOT_HTML_MODE) {
3270 + // This <html> is the Document.documentElement
3271 + const preamble = preambleState || renderState.preamble;
3272 +
3273 + if (preamble.htmlChunks) {
3274 + throw new Error(`The ${'`<html>`'} tag may only be rendered once.`);
3275 + }
3276 +
3277 + preamble.htmlChunks = [DOCTYPE];
3278 + return pushStartGenericElement(preamble.htmlChunks, props, 'html');
3279 } else {
3280 // This <html> is deep and is likely just an error. we emit it inline though.
3281 // Validation should warn that this tag is the the wrong spot.
@@ -3562,6 +3630,7 @@ export function pushStartInstance(
3630 props: Object,
3631 resumableState: ResumableState,
3632 renderState: RenderState,
3633 + preambleState: null | PreambleState,
3634 hoistableState: null | HoistableState,
3635 formatContext: FormatContext,
3636 textEmbedded: boolean,
@@ -3729,6 +3798,15 @@ export function pushStartInstance(
3798 target,
3799 props,
3800 renderState,
3801 + preambleState,
3802 + formatContext.insertionMode,
3803 + );
3804 + case 'body':
3805 + return pushStartBody(
3806 + target,
3807 + props,
3808 + renderState,
3809 + preambleState,
3810 formatContext.insertionMode,
3811 );
3812 case 'html': {
@@ -3736,6 +3814,7 @@ export function pushStartInstance(
3814 target,
3815 props,
3816 renderState,
3817 + preambleState,
3818 formatContext.insertionMode,
3819 );
3820 }
@@ -3814,10 +3893,50 @@ export function pushEndInstance(
3893 return;
3894 }
3895 break;
3896 + case 'head':
3897 + if (formatContext.insertionMode <= HTML_HTML_MODE) {
3898 + return;
3899 + }
3900 + break;
3901 }
3902 target.push(endChunkForTag(type));
3903 }
3904
3905 +export function hoistPreambleState(
3906 + renderState: RenderState,
3907 + preambleState: PreambleState,
3908 +) {
3909 + const rootPreamble = renderState.preamble;
3910 + if (rootPreamble.htmlChunks === null) {
3911 + rootPreamble.htmlChunks = preambleState.htmlChunks;
3912 + }
3913 + if (rootPreamble.headChunks === null) {
3914 + rootPreamble.headChunks = preambleState.headChunks;
3915 + }
3916 + if (rootPreamble.bodyChunks === null) {
3917 + rootPreamble.bodyChunks = preambleState.bodyChunks;
3918 + }
3919 +}
3920 +
3921 +export function isPreambleReady(
3922 + renderState: RenderState,
3923 + // This means there are unfinished Suspense boundaries which could contain
3924 + // a preamble. In the case of DOM we constrain valid programs to only having
3925 + // one instance of each singleton so we can determine the preamble is ready
3926 + // as long as we have chunks for each of these tags.
3927 + hasPendingPreambles: boolean,
3928 +): boolean {
3929 + const preamble = renderState.preamble;
3930 + return (
3931 + // There are no remaining boundaries which might contain a preamble so
3932 + // the preamble is as complete as it is going to get
3933 + hasPendingPreambles === false ||
3934 + // we have a head and body tag. we don't need to wait for any more
3935 + // because it would be invalid to render additional copies of these tags
3936 + !!(preamble.headChunks && preamble.bodyChunks)
3937 + );
3938 +}
3939 +
3940 function writeBootstrap(
3941 destination: Destination,
3942 renderState: RenderState,
@@ -4033,6 +4152,7 @@ export function writeStartSegment(
4152 switch (formatContext.insertionMode) {
4153 case ROOT_HTML_MODE:
4154 case HTML_HTML_MODE:
4155 + case HTML_HEAD_MODE:
4156 case HTML_MODE: {
4157 writeChunk(destination, startSegmentHTML);
4158 writeChunk(destination, renderState.segmentPrefix);
@@ -4091,6 +4211,7 @@ export function writeEndSegment(
4211 switch (formatContext.insertionMode) {
4212 case ROOT_HTML_MODE:
4213 case HTML_HTML_MODE:
4214 + case HTML_HEAD_MODE:
4215 case HTML_MODE: {
4216 return writeChunkAndReturn(destination, endSegmentHTML);
4217 }
@@ -4679,7 +4800,7 @@ function preloadLateStyles(this: Destination, styleQueue: StyleQueue) {
4800 // flush the entire preamble in a single pass. This probably should be modified
4801 // in the future to be backpressure sensitive but that requires a larger refactor
4802 // of the flushing code in Fizz.
4682 -export function writePreamble(
4803 +export function writePreambleStart(
4804 destination: Destination,
4805 resumableState: ResumableState,
4806 renderState: RenderState,
@@ -4700,8 +4821,10 @@ export function writePreamble(
4821 internalPreinitScript(resumableState, renderState, src, chunks);
4822 }
4823
4703 - const htmlChunks = renderState.htmlChunks;
4704 - const headChunks = renderState.headChunks;
4824 + const preamble = renderState.preamble;
4825 +
4826 + const htmlChunks = preamble.htmlChunks;
4827 + const headChunks = preamble.headChunks;
4828
4829 let i = 0;
4830
@@ -4773,12 +4896,31 @@ export function writePreamble(
4896 writeChunk(destination, hoistableChunks[i]);
4897 }
4898 hoistableChunks.length = 0;
4899 +}
4900
4777 - if (htmlChunks && headChunks === null) {
4901 +// We don't bother reporting backpressure at the moment because we expect to
4902 +// flush the entire preamble in a single pass. This probably should be modified
4903 +// in the future to be backpressure sensitive but that requires a larger refactor
4904 +// of the flushing code in Fizz.
4905 +export function writePreambleEnd(
4906 + destination: Destination,
4907 + renderState: RenderState,
4908 +): void {
4909 + const preamble = renderState.preamble;
4910 + const htmlChunks = preamble.htmlChunks;
4911 + const headChunks = preamble.headChunks;
4912 + if (htmlChunks || headChunks) {
4913 // we have an <html> but we inserted an implicit <head> tag. We need
4914 // to close it since the main content won't have it
4915 writeChunk(destination, endChunkForTag('head'));
4916 }
4917 +
4918 + const bodyChunks = preamble.bodyChunks;
4919 + if (bodyChunks) {
4920 + for (let i = 0; i < bodyChunks.length; i++) {
4921 + writeChunk(destination, bodyChunks[i]);
4922 + }
4923 + }
4924 }
4925
4926 // We don't bother reporting backpressure at the moment because we expect to
packages/react-dom-bindings/src/server/ReactFizzConfigDOMLegacy.js
+11 -5
@@ -13,6 +13,7 @@ import type {
13 StyleQueue,
14 Resource,
15 HeadersDescriptor,
16 + PreambleState,
17 } from './ReactFizzConfigDOM';
18
19 import {
@@ -43,8 +44,7 @@ export type RenderState = {
44 segmentPrefix: PrecomputedChunk,
45 boundaryPrefix: PrecomputedChunk,
46 startInlineScript: PrecomputedChunk,
46 - htmlChunks: null | Array<Chunk | PrecomputedChunk>,
47 - headChunks: null | Array<Chunk | PrecomputedChunk>,
47 + preamble: PreambleState,
48 externalRuntimeScript: null | any,
49 bootstrapChunks: Array<Chunk | PrecomputedChunk>,
50 importMapChunks: Array<Chunk | PrecomputedChunk>,
@@ -96,8 +96,7 @@ export function createRenderState(
96 segmentPrefix: renderState.segmentPrefix,
97 boundaryPrefix: renderState.boundaryPrefix,
98 startInlineScript: renderState.startInlineScript,
99 - htmlChunks: renderState.htmlChunks,
100 - headChunks: renderState.headChunks,
99 + preamble: renderState.preamble,
100 externalRuntimeScript: renderState.externalRuntimeScript,
101 bootstrapChunks: renderState.bootstrapChunks,
102 importMapChunks: renderState.importMapChunks,
@@ -134,6 +133,7 @@ export const doctypeChunk: PrecomputedChunk = stringToPrecomputedChunk('');
133 export type {
134 ResumableState,
135 HoistableState,
136 + PreambleState,
137 FormatContext,
138 } from './ReactFizzConfigDOM';
139
@@ -156,8 +156,10 @@ export {
156 writeCompletedRoot,
157 createRootFormatContext,
158 createResumableState,
159 + createPreambleState,
160 createHoistableState,
160 - writePreamble,
161 + writePreambleStart,
162 + writePreambleEnd,
163 writeHoistables,
164 writePostamble,
165 hoistHoistables,
@@ -165,6 +167,10 @@ export {
167 completeResumableState,
168 emitEarlyPreloads,
169 supportsClientAPIs,
170 + canHavePreamble,
171 + hoistPreambleState,
172 + isPreambleReady,
173 + isPreambleContext,
174 } from './ReactFizzConfigDOM';
175
176 import escapeTextForBrowser from './escapeTextForBrowser';
packages/react-dom/src/__tests__/ReactDOMFizzDeferredValue-test.js
+5 -3
@@ -114,9 +114,11 @@ describe('ReactDOMFizzForm', () => {
114
115 function App() {
116 return (
117 - <Suspense fallback={<Text text="Loading..." />}>
118 - <Content />
119 - </Suspense>
117 + <div>
118 + <Suspense fallback={<Text text="Loading..." />}>
119 + <Content />
120 + </Suspense>
121 + </div>
122 );
123 }
124
packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js
+1068 -119
@@ -1282,15 +1282,17 @@ describe('ReactDOMFizzServer', () => {
1282
1283 function App({showMore}) {
1284 return (
1285 - <SuspenseList revealOrder="forwards">
1286 - {a}
1287 - {b}
1288 - {showMore ? (
1289 - <Suspense fallback="Loading C">
1290 - <span>C</span>
1291 - </Suspense>
1292 - ) : null}
1293 - </SuspenseList>
1285 + <div>
1286 + <SuspenseList revealOrder="forwards">
1287 + {a}
1288 + {b}
1289 + {showMore ? (
1290 + <Suspense fallback="Loading C">
1291 + <span>C</span>
1292 + </Suspense>
1293 + ) : null}
1294 + </SuspenseList>
1295 + </div>
1296 );
1297 }
1298
@@ -1308,12 +1310,14 @@ describe('ReactDOMFizzServer', () => {
1310
1311 // We're not hydrated yet.
1312 expect(ref.current).toBe(null);
1311 - expect(getVisibleChildren(container)).toEqual([
1312 - 'Loading A',
1313 - // TODO: This is incorrect. It should be "Loading B" but Fizz SuspenseList
1314 - // isn't implemented fully yet.
1315 - <span>B</span>,
1316 - ]);
1313 + expect(getVisibleChildren(container)).toEqual(
1314 + <div>
1315 + Loading A
1316 + {/* // TODO: This is incorrect. It should be "Loading B" but Fizz SuspenseList
1317 + // isn't implemented fully yet. */}
1318 + <span>B</span>
1319 + </div>,
1320 + );
1321
1322 // Add more rows before we've hydrated the first two.
1323 root.render(<App showMore={true} />);
@@ -1323,13 +1327,15 @@ describe('ReactDOMFizzServer', () => {
1327 expect(ref.current).toBe(null);
1328
1329 // We haven't resolved yet.
1326 - expect(getVisibleChildren(container)).toEqual([
1327 - 'Loading A',
1328 - // TODO: This is incorrect. It should be "Loading B" but Fizz SuspenseList
1329 - // isn't implemented fully yet.
1330 - <span>B</span>,
1331 - 'Loading C',
1332 - ]);
1330 + expect(getVisibleChildren(container)).toEqual(
1331 + <div>
1332 + Loading A
1333 + {/* // TODO: This is incorrect. It should be "Loading B" but Fizz SuspenseList
1334 + // isn't implemented fully yet. */}
1335 + <span>B</span>
1336 + Loading C
1337 + </div>,
1338 + );
1339
1340 await act(async () => {
1341 await resolveText('A');
@@ -1337,11 +1343,13 @@ describe('ReactDOMFizzServer', () => {
1343
1344 await waitForAll([]);
1345
1340 - expect(getVisibleChildren(container)).toEqual([
1341 - <span>A</span>,
1342 - <span>B</span>,
1343 - <span>C</span>,
1344 - ]);
1346 + expect(getVisibleChildren(container)).toEqual(
1347 + <div>
1348 + <span>A</span>
1349 + <span>B</span>
1350 + <span>C</span>
1351 + </div>,
1352 + );
1353
1354 const span = container.getElementsByTagName('span')[0];
1355 expect(ref.current).toBe(span);
@@ -1470,16 +1478,18 @@ describe('ReactDOMFizzServer', () => {
1478 await act(() => {
1479 const {pipe} = renderToPipeableStream(
1480 // We use two nested boundaries to flush out coverage of an old reentrancy bug.
1473 - <Suspense fallback="Loading...">
1474 - <Suspense fallback={<Text text="Loading A..." />}>
1475 - <>
1476 - <Text text="This will show A: " />
1477 - <div>
1478 - <AsyncText text="A" />
1479 - </div>
1480 - </>
1481 + <div>
1482 + <Suspense fallback="Loading...">
1483 + <Suspense fallback={<Text text="Loading A..." />}>
1484 + <>
1485 + <Text text="This will show A: " />
1486 + <div>
1487 + <AsyncText text="A" />
1488 + </div>
1489 + </>
1490 + </Suspense>
1491 </Suspense>
1482 - </Suspense>,
1492 + </div>,
1493 {
1494 identifierPrefix: 'A_',
1495 onShellReady() {
@@ -1493,12 +1503,14 @@ describe('ReactDOMFizzServer', () => {
1503
1504 await act(() => {
1505 const {pipe} = renderToPipeableStream(
1496 - <Suspense fallback={<Text text="Loading B..." />}>
1497 - <Text text="This will show B: " />
1498 - <div>
1499 - <AsyncText text="B" />
1500 - </div>
1501 - </Suspense>,
1506 + <div>
1507 + <Suspense fallback={<Text text="Loading B..." />}>
1508 + <Text text="This will show B: " />
1509 + <div>
1510 + <AsyncText text="B" />
1511 + </div>
1512 + </Suspense>
1513 + </div>,
1514 {
1515 identifierPrefix: 'B_',
1516 onShellReady() {
@@ -1511,8 +1523,12 @@ describe('ReactDOMFizzServer', () => {
1523 });
1524
1525 expect(getVisibleChildren(container)).toEqual([
1514 - <div id="container-A">Loading A...</div>,
1515 - <div id="container-B">Loading B...</div>,
1526 + <div id="container-A">
1527 + <div>Loading A...</div>
1528 + </div>,
1529 + <div id="container-B">
1530 + <div>Loading B...</div>
1531 + </div>,
1532 ]);
1533
1534 await act(() => {
@@ -1520,9 +1536,13 @@ describe('ReactDOMFizzServer', () => {
1536 });
1537
1538 expect(getVisibleChildren(container)).toEqual([
1523 - <div id="container-A">Loading A...</div>,
1539 + <div id="container-A">
1540 + <div>Loading A...</div>
1541 + </div>,
1542 <div id="container-B">
1525 - This will show B: <div>B</div>
1543 + <div>
1544 + This will show B: <div>B</div>
1545 + </div>
1546 </div>,
1547 ]);
1548
@@ -1535,10 +1555,14 @@ describe('ReactDOMFizzServer', () => {
1555
1556 expect(getVisibleChildren(container)).toEqual([
1557 <div id="container-A">
1538 - This will show A: <div>A</div>
1558 + <div>
1559 + This will show A: <div>A</div>
1560 + </div>
1561 </div>,
1562 <div id="container-B">
1541 - This will show B: <div>B</div>
1563 + <div>
1564 + This will show B: <div>B</div>
1565 + </div>
1566 </div>,
1567 ]);
1568 });
@@ -2087,15 +2111,21 @@ describe('ReactDOMFizzServer', () => {
2111 it('client renders a boundary if it errors before finishing the fallback', async () => {
2112 function App({isClient}) {
2113 return (
2090 - <Suspense fallback="Loading root...">
2091 - <div>
2092 - <Suspense fallback={<AsyncText text="Loading..." />}>
2093 - <h1>
2094 - {isClient ? <Text text="Hello" /> : <AsyncText text="Hello" />}
2095 - </h1>
2096 - </Suspense>
2097 - </div>
2098 - </Suspense>
2114 + <div>
2115 + <Suspense fallback="Loading root...">
2116 + <div>
2117 + <Suspense fallback={<AsyncText text="Loading..." />}>
2118 + <h1>
2119 + {isClient ? (
2120 + <Text text="Hello" />
2121 + ) : (
2122 + <AsyncText text="Hello" />
2123 + )}
2124 + </h1>
2125 + </Suspense>
2126 + </div>
2127 + </Suspense>
2128 + </div>
2129 );
2130 }
2131
@@ -2132,7 +2162,7 @@ describe('ReactDOMFizzServer', () => {
2162 await waitForAll([]);
2163
2164 // We're still loading because we're waiting for the server to stream more content.
2135 - expect(getVisibleChildren(container)).toEqual('Loading root...');
2165 + expect(getVisibleChildren(container)).toEqual(<div>Loading root...</div>);
2166
2167 expect(loggedErrors).toEqual([]);
2168
@@ -2145,7 +2175,7 @@ describe('ReactDOMFizzServer', () => {
2175
2176 // We still can't render it on the client because we haven't unblocked the parent.
2177 await waitForAll([]);
2148 - expect(getVisibleChildren(container)).toEqual('Loading root...');
2178 + expect(getVisibleChildren(container)).toEqual(<div>Loading root...</div>);
2179
2180 // Unblock the loading state
2181 await act(() => {
@@ -2153,7 +2183,11 @@ describe('ReactDOMFizzServer', () => {
2183 });
2184
2185 // Now we're able to show the inner boundary.
2156 - expect(getVisibleChildren(container)).toEqual(<div>Loading...</div>);
2186 + expect(getVisibleChildren(container)).toEqual(
2187 + <div>
2188 + <div>Loading...</div>
2189 + </div>,
2190 + );
2191
2192 // That will let us client render it instead.
2193 await waitForAll([]);
@@ -2170,6 +2204,7 @@ describe('ReactDOMFizzServer', () => {
2204 'Suspense',
2205 'div',
2206 'Suspense',
2207 + 'div',
2208 'App',
2209 ]),
2210 ],
@@ -2185,7 +2220,9 @@ describe('ReactDOMFizzServer', () => {
2220 // The client rendered HTML is now in place.
2221 expect(getVisibleChildren(container)).toEqual(
2222 <div>
2188 - <h1>Hello</h1>
2223 + <div>
2224 + <h1>Hello</h1>
2225 + </div>
2226 </div>,
2227 );
2228
@@ -2195,30 +2232,36 @@ describe('ReactDOMFizzServer', () => {
2232 it('should be able to abort the fallback if the main content finishes first', async () => {
2233 await act(() => {
2234 const {pipe} = renderToPipeableStream(
2198 - <Suspense fallback={<Text text="Loading Outer" />}>
2199 - <div>
2200 - <Suspense
2201 - fallback={
2202 - <div>
2203 - <AsyncText text="Loading" />
2204 - Inner
2205 - </div>
2206 - }>
2207 - <AsyncText text="Hello" />
2208 - </Suspense>
2209 - </div>
2210 - </Suspense>,
2235 + <div>
2236 + <Suspense fallback={<Text text="Loading Outer" />}>
2237 + <div>
2238 + <Suspense
2239 + fallback={
2240 + <div>
2241 + <AsyncText text="Loading" />
2242 + Inner
2243 + </div>
2244 + }>
2245 + <AsyncText text="Hello" />
2246 + </Suspense>
2247 + </div>
2248 + </Suspense>
2249 + </div>,
2250 );
2251 pipe(writable);
2252 });
2214 - expect(getVisibleChildren(container)).toEqual('Loading Outer');
2253 + expect(getVisibleChildren(container)).toEqual(<div>Loading Outer</div>);
2254 // We should have received a partial segment containing the a partial of the fallback.
2255 expect(container.innerHTML).toContain('Inner');
2256 await act(() => {
2257 resolveText('Hello');
2258 });
2259 // We should've been able to display the content without waiting for the rest of the fallback.
2221 - expect(getVisibleChildren(container)).toEqual(<div>Hello</div>);
2260 + expect(getVisibleChildren(container)).toEqual(
2261 + <div>
2262 + <div>Hello</div>
2263 + </div>,
2264 + );
2265 });
2266
2267 it('calls getServerSnapshot instead of getSnapshot', async () => {
@@ -5285,15 +5328,17 @@ describe('ReactDOMFizzServer', () => {
5328 it('does not insert text separators even when adjacent text is in a delayed segment', async () => {
5329 function App({name}) {
5330 return (
5288 - <Suspense fallback={'loading...'}>
5289 - <div id="app-div">
5290 - hello
5291 - <b>
5292 - world, <AsyncText text={name} />
5293 - </b>
5294 - !
5295 - </div>
5296 - </Suspense>
5331 + <div>
5332 + <Suspense fallback={'loading...'}>
5333 + <div id="app-div">
5334 + hello
5335 + <b>
5336 + world, <AsyncText text={name} />
5337 + </b>
5338 + !
5339 + </div>
5340 + </Suspense>
5341 + </div>
5342 );
5343 }
5344
@@ -5311,19 +5356,11 @@ describe('ReactDOMFizzServer', () => {
5356 const div = stripExternalRuntimeInNodes(
5357 container.children,
5358 renderOptions.unstable_externalRuntimeSrc,
5314 - )[0];
5359 + )[0].children[0];
5360 expect(div.outerHTML).toEqual(
5361 '<div id="app-div">hello<b>world, Foo</b>!</div>',
5362 );
5363
5319 - // there may be either:
5320 - // - an external runtime script and deleted nodes with data attributes
5321 - // - extra script nodes containing fizz instructions at the end of container
5322 - expect(
5323 - Array.from(container.childNodes).filter(e => e.tagName !== 'SCRIPT')
5324 - .length,
5325 - ).toBe(3);
5326 -
5364 expect(div.childNodes.length).toBe(3);
5365 const b = div.childNodes[1];
5366 expect(b.childNodes.length).toBe(2);
@@ -5339,8 +5376,10 @@ describe('ReactDOMFizzServer', () => {
5376 await waitForAll([]);
5377 expect(errors).toEqual([]);
5378 expect(getVisibleChildren(container)).toEqual(
5342 - <div id="app-div">
5343 - hello<b>world, {'Foo'}</b>!
5379 + <div>
5380 + <div id="app-div">
5381 + hello<b>world, {'Foo'}</b>!
5382 + </div>
5383 </div>,
5384 );
5385 });
@@ -5348,12 +5387,14 @@ describe('ReactDOMFizzServer', () => {
5387 it('works with multiple adjacent segments', async () => {
5388 function App() {
5389 return (
5351 - <Suspense fallback={'loading...'}>
5352 - <div id="app-div">
5353 - h<AsyncText text={'ello'} />
5354 - w<AsyncText text={'orld'} />
5355 - </div>
5356 - </Suspense>
5390 + <div>
5391 + <Suspense fallback={'loading...'}>
5392 + <div id="app-div">
5393 + h<AsyncText text={'ello'} />
5394 + w<AsyncText text={'orld'} />
5395 + </div>
5396 + </Suspense>
5397 + </div>
5398 );
5399 }
5400
@@ -5377,7 +5418,7 @@ describe('ReactDOMFizzServer', () => {
5418 stripExternalRuntimeInNodes(
5419 container.children,
5420 renderOptions.unstable_externalRuntimeSrc,
5380 - )[0].outerHTML,
5421 + )[0].children[0].outerHTML,
5422 ).toEqual('<div id="app-div">helloworld</div>');
5423
5424 const errors = [];
@@ -5389,19 +5430,23 @@ describe('ReactDOMFizzServer', () => {
5430 await waitForAll([]);
5431 expect(errors).toEqual([]);
5432 expect(getVisibleChildren(container)).toEqual(
5392 - <div id="app-div">{['h', 'ello', 'w', 'orld']}</div>,
5433 + <div>
5434 + <div id="app-div">{['h', 'ello', 'w', 'orld']}</div>
5435 + </div>,
5436 );
5437 });
5438
5439 it('works when some segments are flushed and others are patched', async () => {
5440 function App() {
5441 return (
5399 - <Suspense fallback={'loading...'}>
5400 - <div id="app-div">
5401 - h<AsyncText text={'ello'} />
5402 - w<AsyncText text={'orld'} />
5403 - </div>
5404 - </Suspense>
5442 + <div>
5443 + <Suspense fallback={'loading...'}>
5444 + <div id="app-div">
5445 + h<AsyncText text={'ello'} />
5446 + w<AsyncText text={'orld'} />
5447 + </div>
5448 + </Suspense>
5449 + </div>
5450 );
5451 }
5452
@@ -5422,7 +5467,7 @@ describe('ReactDOMFizzServer', () => {
5467 stripExternalRuntimeInNodes(
5468 container.children,
5469 renderOptions.unstable_externalRuntimeSrc,
5425 - )[0].outerHTML,
5470 + )[0].children[0].outerHTML,
5471 ).toEqual('<div id="app-div">h<!-- -->ello<!-- -->world</div>');
5472
5473 const errors = [];
@@ -5437,7 +5482,9 @@ describe('ReactDOMFizzServer', () => {
5482 await waitForAll([]);
5483 expect(errors).toEqual([]);
5484 expect(getVisibleChildren(container)).toEqual(
5440 - <div id="app-div">{['h', 'ello', 'w', 'orld']}</div>,
5485 + <div>
5486 + <div id="app-div">{['h', 'ello', 'w', 'orld']}</div>
5487 + </div>,
5488 );
5489 });
5490
@@ -6073,11 +6120,13 @@ describe('ReactDOMFizzServer', () => {
6120
6121 function App() {
6122 return (
6076 - <Suspense fallback="Loading...">
6077 - <ErrorBoundary>
6078 - <Async />
6079 - </ErrorBoundary>
6080 - </Suspense>
6123 + <div>
6124 + <Suspense fallback="Loading...">
6125 + <ErrorBoundary>
6126 + <Async />
6127 + </ErrorBoundary>
6128 + </Suspense>
6129 + </div>
6130 );
6131 }
6132
@@ -6107,7 +6156,7 @@ describe('ReactDOMFizzServer', () => {
6156 await promiseC;
6157 });
6158
6110 - expect(getVisibleChildren(container)).toEqual('Loading...');
6159 + expect(getVisibleChildren(container)).toEqual(<div>Loading...</div>);
6160 expect(reportedServerErrors.length).toBe(1);
6161 expect(reportedServerErrors[0].message).toBe('Oops!');
6162
@@ -6122,7 +6171,7 @@ describe('ReactDOMFizzServer', () => {
6171 },
6172 });
6173 await waitForAll([]);
6125 - expect(getVisibleChildren(container)).toEqual('Oops!');
6174 + expect(getVisibleChildren(container)).toEqual(<div>Oops!</div>);
6175 // Because this is rethrown on the client, it is not a recoverable error.
6176 expect(reportedClientErrors.length).toBe(0);
6177 // It is caught by the error boundary.
@@ -8792,4 +8841,904 @@ describe('ReactDOMFizzServer', () => {
8841 ),
8842 ]);
8843 });
8844 +
8845 + it('can suspend inside the <head /> tag', async () => {
8846 + function BlockedOn({value, children}) {
8847 + readText(value);
8848 + return children;
8849 + }
8850 +
8851 + function App() {
8852 + return (
8853 + <html>
8854 + <head>
8855 + <Suspense fallback={<meta itemProp="head loading" />}>
8856 + <BlockedOn value="head">
8857 + <meta itemProp="" content="head" />
8858 + </BlockedOn>
8859 + </Suspense>
8860 + </head>
8861 + <body>
8862 + <div>hello world</div>
8863 + </body>
8864 + </html>
8865 + );
8866 + }
8867 +
8868 + await act(() => {
8869 + const {pipe} = renderToPipeableStream(<App />);
8870 + pipe(writable);
8871 + });
8872 +
8873 + expect(getVisibleChildren(document)).toEqual(
8874 + <html>
8875 + <head>
8876 + <meta itemprop="head loading" />
8877 + </head>
8878 + <body>
8879 + <div>hello world</div>
8880 + </body>
8881 + </html>,
8882 + );
8883 +
8884 + await act(() => {
8885 + resolveText('head');
8886 + });
8887 +
8888 + expect(getVisibleChildren(document)).toEqual(
8889 + <html>
8890 + <head>
8891 + <meta itemprop="" content="head" />
8892 + </head>
8893 + <body>
8894 + <div>hello world</div>
8895 + </body>
8896 + </html>,
8897 + );
8898 +
8899 + const root = ReactDOMClient.hydrateRoot(document, <App />);
8900 + await waitForAll([]);
8901 +
8902 + expect(getVisibleChildren(document)).toEqual(
8903 + <html>
8904 + <head>
8905 + <meta itemprop="" content="head" />
8906 + </head>
8907 + <body>
8908 + <div>hello world</div>
8909 + </body>
8910 + </html>,
8911 + );
8912 +
8913 + await act(() => {
8914 + root.unmount();
8915 + });
8916 + await waitForAll([]);
8917 +
8918 + expect(getVisibleChildren(document)).toEqual(
8919 + <html>
8920 + <head />
8921 + <body />
8922 + </html>,
8923 + );
8924 + });
8925 +
8926 + it('can server render Suspense before, after, and around <html>', async () => {
8927 + function BlockedOn({value, children}) {
8928 + readText(value);
8929 + return children;
8930 + }
8931 +
8932 + function App() {
8933 + return (
8934 + <>
8935 + <Suspense fallback="this fallback never renders">
8936 + <div>before</div>
8937 + </Suspense>
8938 + <Suspense fallback="this fallback never renders">
8939 + <BlockedOn value="html">
8940 + <html lang="en">
8941 + <head>
8942 + <meta itemProp="" content="non-floaty meta" />
8943 + </head>
8944 + <body>
8945 + <div>hello world</div>
8946 + </body>
8947 + </html>
8948 + </BlockedOn>
8949 + </Suspense>
8950 + <Suspense fallback="this fallback never renders">
8951 + <div>after</div>
8952 + </Suspense>
8953 + </>
8954 + );
8955 + }
8956 +
8957 + let content = '';
8958 + writable.on('data', chunk => (content += chunk));
8959 +
8960 + let shellReady = false;
8961 + await act(() => {
8962 + const {pipe} = renderToPipeableStream(<App />, {
8963 + onShellReady: () => {
8964 + shellReady = true;
8965 + },
8966 + });
8967 + pipe(writable);
8968 + });
8969 +
8970 + // When we Suspend above the body we block the shell because the root HTML scope
8971 + // is considered "reconciliation" mode whereby we should stay on the prior view
8972 + // (the prior page for instance) rather than showing the fallback (semantically)
8973 + expect(shellReady).toBe(true);
8974 + expect(content).toBe('');
8975 +
8976 + await act(() => {
8977 + resolveText('html');
8978 + });
8979 + expect(content).toMatch(/^<!DOCTYPE html>/);
8980 + expect(getVisibleChildren(document)).toEqual(
8981 + <html lang="en">
8982 + <head>
8983 + <meta itemprop="" content="non-floaty meta" />
8984 + </head>
8985 + <body>
8986 + <div>before</div>
8987 + <div>hello world</div>
8988 + <div>after</div>
8989 + </body>
8990 + </html>,
8991 + );
8992 + });
8993 +
8994 + it('can server render Suspense before, after, and around <body>', async () => {
8995 + function BlockedOn({value, children}) {
8996 + readText(value);
8997 + return children;
8998 + }
8999 +
9000 + function App() {
9001 + return (
9002 + <html>
9003 + <Suspense fallback="this fallback never renders">
9004 + <meta content="before" />
9005 + <meta itemProp="" content="before" />
9006 + </Suspense>
9007 + <Suspense fallback="this fallback never renders">
9008 + <BlockedOn value="body">
9009 + <body lang="en">
9010 + <div>hello world</div>
9011 + </body>
9012 + </BlockedOn>
9013 + </Suspense>
9014 + <Suspense fallback="this fallback never renders">
9015 + <meta content="after" />
9016 + <meta itemProp="" content="after" />
9017 + </Suspense>
9018 + </html>
9019 + );
9020 + }
9021 +
9022 + let content = '';
9023 + writable.on('data', chunk => (content += chunk));
9024 +
9025 + let shellReady = false;
9026 + await act(() => {
9027 + const {pipe} = renderToPipeableStream(<App />, {
9028 + onShellReady() {
9029 + shellReady = true;
9030 + },
9031 + });
9032 + pipe(writable);
9033 + });
9034 +
9035 + expect(shellReady).toBe(true);
9036 + expect(content).toBe('');
9037 +
9038 + await act(() => {
9039 + resolveText('body');
9040 + });
9041 + expect(content).toMatch(/^<!DOCTYPE html>/);
9042 + expect(getVisibleChildren(document)).toEqual(
9043 + <html>
9044 + <head>
9045 + <meta content="before" />
9046 + <meta content="after" />
9047 + </head>
9048 + <body lang="en">
9049 + <meta itemprop="" content="before" />
9050 + <div>hello world</div>
9051 + <meta itemprop="" content="after" />
9052 + </body>
9053 + </html>,
9054 + );
9055 + });
9056 +
9057 + it('can server render Suspense before, after, and around <head>', async () => {
9058 + function BlockedOn({value, children}) {
9059 + readText(value);
9060 + return children;
9061 + }
9062 +
9063 + function App() {
9064 + return (
9065 + <html>
9066 + <Suspense fallback="this fallback never renders">
9067 + <meta content="before" />
9068 + <meta itemProp="" content="before" />
9069 + </Suspense>
9070 + <Suspense fallback="this fallback never renders">
9071 + <BlockedOn value="head">
9072 + <head lang="en">
9073 + <meta itemProp="" />
9074 + </head>
9075 + </BlockedOn>
9076 + </Suspense>
9077 + <Suspense fallback="this fallback never renders">
9078 + <meta content="after" />
9079 + <meta itemProp="" content="after" />
9080 + </Suspense>
9081 + <body>
9082 + <div>hello world</div>
9083 + </body>
9084 + </html>
9085 + );
9086 + }
9087 +
9088 + let content = '';
9089 + writable.on('data', chunk => (content += chunk));
9090 +
9091 + let shellReady = false;
9092 + await act(() => {
9093 + const {pipe} = renderToPipeableStream(<App />, {
9094 + onShellReady() {
9095 + shellReady = true;
9096 + },
9097 + });
9098 + pipe(writable);
9099 + });
9100 +
9101 + expect(shellReady).toBe(true);
9102 + expect(content).toBe('');
9103 +
9104 + await act(() => {
9105 + resolveText('head');
9106 + });
9107 + expect(content).toMatch(/^<!DOCTYPE html>/);
9108 + expect(getVisibleChildren(document)).toEqual(
9109 + <html>
9110 + <head lang="en">
9111 + <meta content="before" />
9112 + <meta content="after" />
9113 + <meta itemprop="" />
9114 + </head>
9115 + <body>
9116 + <meta itemprop="" content="before" />
9117 + <meta itemprop="" content="after" />
9118 + <div>hello world</div>
9119 + </body>
9120 + </html>,
9121 + );
9122 + });
9123 +
9124 + it('will render fallback Document when erroring a boundary above the body', async () => {
9125 + function Boom() {
9126 + throw new Error('Boom!');
9127 + }
9128 +
9129 + function App() {
9130 + return (
9131 + <Suspense
9132 + fallback={
9133 + <html data-error-html="">
9134 + <body data-error-body="">
9135 + <span>hello error</span>
9136 + </body>
9137 + </html>
9138 + }>
9139 + <html data-content-html="">
9140 + <body data-content-body="">
9141 + <Boom />
9142 + <span>hello world</span>
9143 + </body>
9144 + </html>
9145 + </Suspense>
9146 + );
9147 + }
9148 +
9149 + let content = '';
9150 + writable.on('data', chunk => (content += chunk));
9151 +
9152 + let shellReady = false;
9153 + const errors = [];
9154 + await act(() => {
9155 + const {pipe} = renderToPipeableStream(<App />, {
9156 + onShellReady() {
9157 + shellReady = true;
9158 + },
9159 + onError(e) {
9160 + errors.push(e);
9161 + },
9162 + });
9163 + pipe(writable);
9164 + });
9165 +
9166 + expect(shellReady).toBe(true);
9167 + expect(content).toMatch(/^<!DOCTYPE html>/);
9168 + expect(errors).toEqual([new Error('Boom!')]);
9169 + expect(getVisibleChildren(document)).toEqual(
9170 + <html data-error-html="">
9171 + <head />
9172 + <body data-error-body="">
9173 + <span>hello error</span>
9174 + </body>
9175 + </html>,
9176 + );
9177 + });
9178 +
9179 + it('will hoist resources and hositables from a primary tree into the <head> of a client rendered fallback', async () => {
9180 + function Boom() {
9181 + throw new Error('Boom!');
9182 + }
9183 +
9184 + function App() {
9185 + return (
9186 + <>
9187 + <meta content="hoistable before" />
9188 + <link rel="stylesheet" href="hoistable before" precedence="default" />
9189 + <Suspense
9190 + fallback={
9191 + <html data-error-html="">
9192 + <head data-error-head="">
9193 + {/* we have to make this a non-hoistable because we don't current emit
9194 + hoistables inside fallbacks because we have no way to clean them up
9195 + on hydration */}
9196 + <meta itemProp="" content="error document" />
9197 + </head>
9198 + <body data-error-body="">
9199 + <span>hello error</span>
9200 + </body>
9201 + </html>
9202 + }>
9203 + <html data-content-html="">
9204 + <body data-content-body="">
9205 + <Boom />
9206 + <span>hello world</span>
9207 + </body>
9208 + </html>
9209 + </Suspense>
9210 + <meta content="hoistable after" />
9211 + <link rel="stylesheet" href="hoistable after" precedence="default" />
9212 + </>
9213 + );
9214 + }
9215 +
9216 + let content = '';
9217 + writable.on('data', chunk => (content += chunk));
9218 +
9219 + let shellReady = false;
9220 + const errors = [];
9221 + await act(() => {
9222 + const {pipe} = renderToPipeableStream(<App />, {
9223 + onShellReady() {
9224 + shellReady = true;
9225 + },
9226 + onError(e) {
9227 + errors.push(e);
9228 + },
9229 + });
9230 + pipe(writable);
9231 + });
9232 +
9233 + expect(shellReady).toBe(true);
9234 + expect(content).toMatch(/^<!DOCTYPE html>/);
9235 + expect(errors).toEqual([new Error('Boom!')]);
9236 + expect(getVisibleChildren(document)).toEqual(
9237 + <html data-error-html="">
9238 + <head data-error-head="">
9239 + <link
9240 + rel="stylesheet"
9241 + href="hoistable before"
9242 + data-precedence="default"
9243 + />
9244 + <link
9245 + rel="stylesheet"
9246 + href="hoistable after"
9247 + data-precedence="default"
9248 + />
9249 + <meta content="hoistable before" />
9250 + <meta content="hoistable after" />
9251 + <meta itemprop="" content="error document" />
9252 + </head>
9253 + <body data-error-body="">
9254 + <span>hello error</span>
9255 + </body>
9256 + </html>,
9257 + );
9258 + });
9259 +
9260 + it('Will wait to flush Document chunks until all boundaries which might contain a preamble are errored or resolved', async () => {
9261 + let rejectFirst;
9262 + const firstPromise = new Promise((_, reject) => {
9263 + rejectFirst = reject;
9264 + });
9265 + function First({children}) {
9266 + use(firstPromise);
9267 + return children;
9268 + }
9269 +
9270 + let resolveSecond;
9271 + const secondPromise = new Promise(resolve => {
9272 + resolveSecond = resolve;
9273 + });
9274 + function Second({children}) {
9275 + use(secondPromise);
9276 + return children;
9277 + }
9278 +
9279 + const hangingPromise = new Promise(() => {});
9280 + function Hanging({children}) {
9281 + use(hangingPromise);
9282 + return children;
9283 + }
9284 +
9285 + function App() {
9286 + return (
9287 + <>
9288 + <Suspense fallback={<span>loading...</span>}>
9289 + <Suspense fallback={<span>inner loading...</span>}>
9290 + <First>
9291 + <span>first</span>
9292 + </First>
9293 + </Suspense>
9294 + </Suspense>
9295 + <Suspense fallback={<span>loading...</span>}>
9296 + <main>
9297 + <Second>
9298 + <span>second</span>
9299 + </Second>
9300 + </main>
9301 + </Suspense>
9302 + <div>
9303 + <Suspense fallback={<span>loading...</span>}>
9304 + <Hanging>
9305 + <span>third</span>
9306 + </Hanging>
9307 + </Suspense>
9308 + </div>
9309 + </>
9310 + );
9311 + }
9312 +
9313 + let content = '';
9314 + writable.on('data', chunk => (content += chunk));
9315 +
9316 + let shellReady = false;
9317 + const errors = [];
9318 + await act(() => {
9319 + const {pipe} = renderToPipeableStream(<App />, {
9320 + onShellReady() {
9321 + shellReady = true;
9322 + },
9323 + onError(e) {
9324 + errors.push(e);
9325 + },
9326 + });
9327 + pipe(writable);
9328 + });
9329 +
9330 + expect(shellReady).toBe(true);
9331 + expect(content).toBe('');
9332 +
9333 + await act(() => {
9334 + resolveSecond();
9335 + });
9336 + expect(content).toBe('');
9337 +
9338 + await act(() => {
9339 + rejectFirst('Boom!');
9340 + });
9341 + expect(content.length).toBeGreaterThan(0);
9342 + expect(errors).toEqual(['Boom!']);
9343 +
9344 + expect(getVisibleChildren(container)).toEqual([
9345 + <span>inner loading...</span>,
9346 + <main>
9347 + <span>second</span>
9348 + </main>,
9349 + <div>
9350 + <span>loading...</span>
9351 + </div>,
9352 + ]);
9353 + });
9354 +
9355 + it('Can render a fallback <head> alongside a non-fallback body', async () => {
9356 + function Boom() {
9357 + throw new Error('Boom!');
9358 + }
9359 +
9360 + function App() {
9361 + return (
9362 + <html>
9363 + <Suspense
9364 + fallback={
9365 + <head data-fallback="">
9366 + <meta itemProp="" content="fallback" />
9367 + </head>
9368 + }>
9369 + <Boom />
9370 + <head data-primary="">
9371 + <meta itemProp="" content="primary" />
9372 + </head>
9373 + </Suspense>
9374 + <Suspense
9375 + fallback={
9376 + <body data-fallback="">
9377 + <div>fallback body</div>
9378 + </body>
9379 + }>
9380 + <body data-primary="">
9381 + <div>primary body</div>
9382 + </body>
9383 + </Suspense>
9384 + </html>
9385 + );
9386 + }
9387 +
9388 + let content = '';
9389 + writable.on('data', chunk => (content += chunk));
9390 +
9391 + let shellReady = false;
9392 + const errors = [];
9393 + await act(() => {
9394 + const {pipe} = renderToPipeableStream(<App />, {
9395 + onShellReady() {
9396 + shellReady = true;
9397 + },
9398 + onError(e) {
9399 + errors.push(e);
9400 + },
9401 + });
9402 + pipe(writable);
9403 + });
9404 +
9405 + expect(shellReady).toBe(true);
9406 + expect(content).toMatch(/^<!DOCTYPE html>/);
9407 + expect(errors).toEqual([new Error('Boom!')]);
9408 +
9409 + expect(getVisibleChildren(document)).toEqual(
9410 + <html>
9411 + <head data-fallback="">
9412 + <meta itemprop="" content="fallback" />
9413 + </head>
9414 + <body data-primary="">
9415 + <div>primary body</div>
9416 + </body>
9417 + </html>,
9418 + );
9419 + });
9420 +
9421 + it('Can render a fallback <body> alongside a non-fallback head', async () => {
9422 + function Boom() {
9423 + throw new Error('Boom!');
9424 + }
9425 +
9426 + function App() {
9427 + return (
9428 + <html>
9429 + <Suspense
9430 + fallback={
9431 + <head data-fallback="">
9432 + <meta itemProp="" content="fallback" />
9433 + </head>
9434 + }>
9435 + <head data-primary="">
9436 + <meta itemProp="" content="primary" />
9437 + </head>
9438 + </Suspense>
9439 + <Suspense
9440 + fallback={
9441 + <body data-fallback="">
9442 + <div>fallback body</div>
9443 + </body>
9444 + }>
9445 + <Boom />
9446 + <body data-primary="">
9447 + <div>primary body</div>
9448 + </body>
9449 + </Suspense>
9450 + </html>
9451 + );
9452 + }
9453 +
9454 + let content = '';
9455 + writable.on('data', chunk => (content += chunk));
9456 +
9457 + let shellReady = false;
9458 + const errors = [];
9459 + await act(() => {
9460 + const {pipe} = renderToPipeableStream(<App />, {
9461 + onShellReady() {
9462 + shellReady = true;
9463 + },
9464 + onError(e) {
9465 + errors.push(e);
9466 + },
9467 + });
9468 + pipe(writable);
9469 + });
9470 +
9471 + expect(shellReady).toBe(true);
9472 + expect(content).toMatch(/^<!DOCTYPE html>/);
9473 + expect(errors).toEqual([new Error('Boom!')]);
9474 +
9475 + expect(getVisibleChildren(document)).toEqual(
9476 + <html>
9477 + <head data-primary="">
9478 + <meta itemprop="" content="primary" />
9479 + </head>
9480 + <body data-fallback="">
9481 + <div>fallback body</div>
9482 + </body>
9483 + </html>,
9484 + );
9485 + });
9486 +
9487 + it('Can render a <head> outside of a containing <html>', async () => {
9488 + function App() {
9489 + return (
9490 + <>
9491 + <Suspense>
9492 + <html data-x="">
9493 + <body data-x="">
9494 + <span>hello world</span>
9495 + </body>
9496 + </html>
9497 + </Suspense>
9498 + <head data-y="">
9499 + <meta itemProp="" />
9500 + </head>
9501 + </>
9502 + );
9503 + }
9504 +
9505 + let content = '';
9506 + writable.on('data', chunk => (content += chunk));
9507 +
9508 + let shellReady = false;
9509 + await act(() => {
9510 + const {pipe} = renderToPipeableStream(<App />, {
9511 + onShellReady() {
9512 + shellReady = true;
9513 + },
9514 + });
9515 + pipe(writable);
9516 + });
9517 +
9518 + expect(shellReady).toBe(true);
9519 + expect(content).toMatch(/^<!DOCTYPE html>/);
9520 +
9521 + expect(getVisibleChildren(document)).toEqual(
9522 + <html data-x="">
9523 + <head data-y="">
9524 + <meta itemprop="" />
9525 + </head>
9526 + <body data-x="">
9527 + <span>hello world</span>
9528 + </body>
9529 + </html>,
9530 + );
9531 + });
9532 +
9533 + it('can render preamble tags in deeply nested indirect component trees', async () => {
9534 + function App() {
9535 + return (
9536 + <Html>
9537 + <DocumentMetadata />
9538 + <Main />
9539 + </Html>
9540 + );
9541 + }
9542 +
9543 + let loadLanguage;
9544 + const langPromise = new Promise(r => {
9545 + loadLanguage = r;
9546 + });
9547 + function Html({children}) {
9548 + return (
9549 + <Suspense fallback={<FallbackHtml>{children}</FallbackHtml>}>
9550 + <MainHtml>{children}</MainHtml>
9551 + </Suspense>
9552 + );
9553 + }
9554 + function FallbackHtml({children}) {
9555 + return <html lang="default">{children}</html>;
9556 + }
9557 + function MainHtml({children}) {
9558 + const lang = use(langPromise);
9559 + return <html lang={lang}>{children}</html>;
9560 + }
9561 +
9562 + let loadMetadata;
9563 + const metadataPromise = new Promise(r => {
9564 + loadMetadata = r;
9565 + });
9566 + function DocumentMetadata() {
9567 + return (
9568 + <Suspense fallback={<FallbackDocumentMetadata />}>
9569 + <MainDocumentMetadata />
9570 + </Suspense>
9571 + );
9572 + }
9573 + function FallbackDocumentMetadata() {
9574 + return (
9575 + <head data-fallback="">
9576 + <meta content="fallback metadata" />
9577 + </head>
9578 + );
9579 + }
9580 + function MainDocumentMetadata() {
9581 + const metadata = use(metadataPromise);
9582 + return (
9583 + <head data-main="">
9584 + {metadata.map(m => (
9585 + <meta content={m} key={m} />
9586 + ))}
9587 + </head>
9588 + );
9589 + }
9590 +
9591 + let loadMainContent;
9592 + const mainContentPromise = new Promise(r => {
9593 + loadMainContent = r;
9594 + });
9595 + function Main() {
9596 + return (
9597 + <Suspense fallback={<Skeleton />}>
9598 + <PrimaryContent />
9599 + </Suspense>
9600 + );
9601 + }
9602 + function Skeleton() {
9603 + return (
9604 + <body data-fallback="">
9605 + <div>Skeleton UI</div>
9606 + </body>
9607 + );
9608 + }
9609 + function PrimaryContent() {
9610 + const content = use(mainContentPromise);
9611 + return (
9612 + <body data-main="">
9613 + <div>{content}</div>
9614 + </body>
9615 + );
9616 + }
9617 +
9618 + let content = '';
9619 + writable.on('data', chunk => (content += chunk));
9620 +
9621 + let shellReady = false;
9622 + const errors = [];
9623 + await act(() => {
9624 + const {pipe} = renderToPipeableStream(<App />, {
9625 + onShellReady() {
9626 + shellReady = true;
9627 + },
9628 + onError(e) {
9629 + errors.push(e);
9630 + },
9631 + });
9632 + pipe(writable);
9633 + });
9634 +
9635 + expect(shellReady).toBe(true);
9636 + expect(content).toBe('');
9637 +
9638 + await act(() => {
9639 + loadLanguage('es');
9640 + });
9641 + expect(content).toBe('');
9642 +
9643 + await act(() => {
9644 + loadMainContent('This is soooo cool!');
9645 + });
9646 + expect(content).toBe('');
9647 +
9648 + await act(() => {
9649 + loadMetadata(['author', 'published date']);
9650 + });
9651 + expect(content).toMatch(/^<!DOCTYPE html>/);
9652 +
9653 + expect(getVisibleChildren(document)).toEqual(
9654 + <html lang="es">
9655 + <head data-main="">
9656 + <meta content="author" />
9657 + <meta content="published date" />
9658 + </head>
9659 + <body data-main="">
9660 + <div>This is soooo cool!</div>
9661 + </body>
9662 + </html>,
9663 + );
9664 + });
9665 +
9666 + it('will flush the preamble as soon as a complete preamble is available', async () => {
9667 + function BlockedOn({value, children}) {
9668 + readText(value);
9669 + return children;
9670 + }
9671 +
9672 + function App() {
9673 + return (
9674 + <>
9675 + <Suspense fallback="loading before...">
9676 + <div>
9677 + <AsyncText text="before" />
9678 + </div>
9679 + </Suspense>
9680 + <Suspense fallback="loading document...">
9681 + <html>
9682 + <body>
9683 + <div>
9684 + <AsyncText text="body" />
9685 + </div>
9686 + </body>
9687 + </html>
9688 + </Suspense>
9689 + <Suspense fallback="loading head...">
9690 + <head>
9691 + <BlockedOn value="head">
9692 + <meta content="head" />
9693 + </BlockedOn>
9694 + </head>
9695 + </Suspense>
9696 + <Suspense fallback="loading after...">
9697 + <div>
9698 + <AsyncText text="after" />
9699 + </div>
9700 + </Suspense>
9701 + </>
9702 + );
9703 + }
9704 +
9705 + let content = '';
9706 + writable.on('data', chunk => (content += chunk));
9707 +
9708 + let shellReady = false;
9709 + await act(() => {
9710 + const {pipe} = renderToPipeableStream(<App />, {
9711 + onShellReady() {
9712 + shellReady = true;
9713 + },
9714 + });
9715 + pipe(writable);
9716 + });
9717 +
9718 + expect(shellReady).toBe(true);
9719 + expect(content).toBe('');
9720 +
9721 + await act(() => {
9722 + resolveText('body');
9723 + });
9724 + expect(content).toBe('');
9725 +
9726 + await act(() => {
9727 + resolveText('head');
9728 + });
9729 + expect(content).toMatch(/^<!DOCTYPE html>/);
9730 +
9731 + expect(getVisibleChildren(document)).toEqual(
9732 + <html>
9733 + <head>
9734 + <meta content="head" />
9735 + </head>
9736 + <body>
9737 + loading before...
9738 + <div>body</div>
9739 + loading after...
9740 + </body>
9741 + </html>,
9742 + );
9743 + });
9744 });
packages/react-dom/src/__tests__/ReactDOMFizzServerNode-test.js
+17 -11
@@ -442,9 +442,11 @@ describe('ReactDOMFizzServerNode', () => {
442 await act(() => {
443 ReactDOMFizzServer.renderToPipeableStream(
444 <DelayContext.Provider value={client}>
445 - <Suspense fallback="loading">
446 - <Component />
447 - </Suspense>
445 + <div>
446 + <Suspense fallback="loading">
447 + <Component />
448 + </Suspense>
449 + </div>
450 </DelayContext.Provider>,
451 ).pipe(writable);
452 });
@@ -501,16 +503,20 @@ describe('ReactDOMFizzServerNode', () => {
503 await act(() => {
504 ReactDOMFizzServer.renderToPipeableStream(
505 <DelayContext.Provider value={client0}>
504 - <Suspense fallback="loading">
505 - <Component />
506 - </Suspense>
506 + <div>
507 + <Suspense fallback="loading">
508 + <Component />
509 + </Suspense>
510 + </div>
511 </DelayContext.Provider>,
512 ).pipe(writable0);
513 ReactDOMFizzServer.renderToPipeableStream(
514 <DelayContext.Provider value={client1}>
511 - <Suspense fallback="loading">
512 - <Component />
513 - </Suspense>
515 + <div>
516 + <Suspense fallback="loading">
517 + <Component />
518 + </Suspense>
519 + </div>
520 </DelayContext.Provider>,
521 ).pipe(writable1);
522 });
@@ -564,7 +570,7 @@ describe('ReactDOMFizzServerNode', () => {
570 const {writable, output, completed} = getTestWritable();
571 await act(() => {
572 ReactDOMFizzServer.renderToPipeableStream(
567 - <>
573 + <div>
574 <DelayContext.Provider value={client}>
575 <Suspense fallback="loading">
576 <Component />
@@ -575,7 +581,7 @@ describe('ReactDOMFizzServerNode', () => {
581 <Component />
582 </Suspense>
583 </DelayContext.Provider>
578 - </>,
584 + </div>,
585 ).pipe(writable);
586 });
587
packages/react-dom/src/__tests__/ReactDOMFizzStaticBrowser-test.js
+401 -20
@@ -5,6 +5,7 @@
5 * LICENSE file in the root directory of this source tree.
6 *
7 * @emails react-core
8 + * @jest-environment ./scripts/jest/ReactDOMServerIntegrationEnvironment
9 */
10
11 'use strict';
@@ -22,6 +23,7 @@ global.ReadableStream =
23 global.TextEncoder = require('util').TextEncoder;
24 global.TextDecoder = require('util').TextDecoder;
25
26 +let JSDOM;
27 let React;
28 let ReactDOM;
29 let ReactDOMFizzServer;
@@ -34,6 +36,7 @@ let act;
36 describe('ReactDOMFizzStaticBrowser', () => {
37 beforeEach(() => {
38 jest.resetModules();
39 + JSDOM = require('jsdom').JSDOM;
40
41 Scheduler = require('scheduler');
42 patchMessageChannel(Scheduler);
@@ -49,6 +52,9 @@ describe('ReactDOMFizzStaticBrowser', () => {
52 });
53
54 afterEach(() => {
55 + if (typeof global.window.__restoreGlobalScope === 'function') {
56 + global.window.__restoreGlobalScope();
57 + }
58 document.body.removeChild(container);
59 });
60
@@ -129,6 +135,40 @@ describe('ReactDOMFizzStaticBrowser', () => {
135 await insertNodesAndExecuteScripts(temp, container, null);
136 }
137
138 + async function readIntoNewDocument(stream) {
139 + const content = await readContent(stream);
140 + const jsdom = new JSDOM(content, {
141 + runScripts: 'dangerously',
142 + });
143 + const originalWindow = global.window;
144 + const originalDocument = global.document;
145 + const originalNavigator = global.navigator;
146 + const originalNode = global.Node;
147 + const originalAddEventListener = global.addEventListener;
148 + const originalMutationObserver = global.MutationObserver;
149 + global.window = jsdom.window;
150 + global.document = global.window.document;
151 + global.navigator = global.window.navigator;
152 + global.Node = global.window.Node;
153 + global.addEventListener = global.window.addEventListener;
154 + global.MutationObserver = global.window.MutationObserver;
155 + global.window.__restoreGlobalScope = () => {
156 + global.window = originalWindow;
157 + global.document = originalDocument;
158 + global.navigator = originalNavigator;
159 + global.Node = originalNode;
160 + global.addEventListener = originalAddEventListener;
161 + global.MutationObserver = originalMutationObserver;
162 + };
163 + }
164 +
165 + async function readIntoCurrentDocument(stream) {
166 + const content = await readContent(stream);
167 + const temp = document.createElement('div');
168 + temp.innerHTML = content;
169 + await insertNodesAndExecuteScripts(temp, document.body, null);
170 + }
171 +
172 it('should call prerender', async () => {
173 const result = await serverAct(() =>
174 ReactDOMFizzStatic.prerender(<div>hello world</div>),
@@ -293,7 +333,7 @@ describe('ReactDOMFizzStaticBrowser', () => {
333 const prelude = await readContent(result.prelude);
334 expect(prelude).toContain('Loading');
335
296 - expect(errors).toEqual(['The operation was aborted.']);
336 + expect(errors).toEqual(['This operation was aborted']);
337 });
338
339 // @gate !enableHalt
@@ -393,7 +433,7 @@ describe('ReactDOMFizzStaticBrowser', () => {
433 if (gate(flags => flags.enableHalt)) {
434 const {prelude} = await streamPromise;
435 const content = await readContent(prelude);
396 - expect(errors).toEqual(['The operation was aborted.']);
436 + expect(errors).toEqual(['This operation was aborted']);
437 expect(content).toBe('');
438 } else {
439 let caughtError = null;
@@ -402,8 +442,8 @@ describe('ReactDOMFizzStaticBrowser', () => {
442 } catch (error) {
443 caughtError = error;
444 }
405 - expect(caughtError.message).toBe('The operation was aborted.');
406 - expect(errors).toEqual(['The operation was aborted.']);
445 + expect(caughtError.message).toBe('This operation was aborted');
446 + expect(errors).toEqual(['This operation was aborted']);
447 }
448 });
449
@@ -1719,13 +1759,15 @@ describe('ReactDOMFizzStaticBrowser', () => {
1759
1760 function App() {
1761 return (
1722 - <Suspense fallback="loading...">
1723 - <Outer>
1724 - <Middle>
1725 - <Inner />
1726 - </Middle>
1727 - </Outer>
1728 - </Suspense>
1762 + <div>
1763 + <Suspense fallback="loading...">
1764 + <Outer>
1765 + <Middle>
1766 + <Inner />
1767 + </Middle>
1768 + </Outer>
1769 + </Suspense>
1770 + </div>
1771 );
1772 }
1773
@@ -1735,7 +1777,7 @@ describe('ReactDOMFizzStaticBrowser', () => {
1777 const postponedState = JSON.stringify(prerendered.postponed);
1778
1779 await readIntoContainer(prerendered.prelude);
1738 - expect(getVisibleChildren(container)).toEqual('loading...');
1780 + expect(getVisibleChildren(container)).toEqual(<div>loading...</div>);
1781
1782 isPrerendering = false;
1783
@@ -1744,7 +1786,7 @@ describe('ReactDOMFizzStaticBrowser', () => {
1786 );
1787
1788 await readIntoContainer(dynamic);
1747 - expect(getVisibleChildren(container)).toEqual('hello');
1789 + expect(getVisibleChildren(container)).toEqual(<div>hello</div>);
1790 });
1791
1792 // @gate enableHalt
@@ -1772,9 +1814,11 @@ describe('ReactDOMFizzStaticBrowser', () => {
1814
1815 function App() {
1816 return (
1775 - <Suspense fallback="Loading A">
1776 - <ComponentA />
1777 - </Suspense>
1817 + <div>
1818 + <Suspense fallback="Loading A">
1819 + <ComponentA />
1820 + </Suspense>
1821 + </div>
1822 );
1823 }
1824
@@ -1790,12 +1834,11 @@ describe('ReactDOMFizzStaticBrowser', () => {
1834 });
1835
1836 controller.abort();
1793 -
1837 const prerendered = await pendingResult;
1838 const postponedState = JSON.stringify(prerendered.postponed);
1839
1840 await readIntoContainer(prerendered.prelude);
1798 - expect(getVisibleChildren(container)).toEqual('Loading A');
1841 + expect(getVisibleChildren(container)).toEqual(<div>Loading A</div>);
1842
1843 await resolveA();
1844
@@ -1821,7 +1864,7 @@ describe('ReactDOMFizzStaticBrowser', () => {
1864 const postponedState2 = JSON.stringify(prerendered2.postponed);
1865
1866 await readIntoContainer(prerendered2.prelude);
1824 - expect(getVisibleChildren(container)).toEqual('Loading B');
1867 + expect(getVisibleChildren(container)).toEqual(<div>Loading B</div>);
1868
1869 await resolveB();
1870
@@ -1830,6 +1873,344 @@ describe('ReactDOMFizzStaticBrowser', () => {
1873 );
1874
1875 await readIntoContainer(dynamic);
1833 - expect(getVisibleChildren(container)).toEqual('Hello');
1876 + expect(getVisibleChildren(container)).toEqual(<div>Hello</div>);
1877 + });
1878 +
1879 + // @gate enableHalt
1880 + it('can prerender a preamble', async () => {
1881 + const errors = [];
1882 +
1883 + let resolveA;
1884 + const promiseA = new Promise(r => (resolveA = r));
1885 + let resolveB;
1886 + const promiseB = new Promise(r => (resolveB = r));
1887 +
1888 + async function ComponentA() {
1889 + await promiseA;
1890 + return (
1891 + <Suspense fallback="Loading B">
1892 + <ComponentB />
1893 + </Suspense>
1894 + );
1895 + }
1896 +
1897 + async function ComponentB() {
1898 + await promiseB;
1899 + return 'Hello';
1900 + }
1901 +
1902 + function App() {
1903 + return (
1904 + <Suspense>
1905 + <html data-x="">
1906 + <body data-x="">
1907 + <Suspense fallback="Loading A">
1908 + <ComponentA />
1909 + </Suspense>
1910 + </body>
1911 + </html>
1912 + </Suspense>
1913 + );
1914 + }
1915 +
1916 + const controller = new AbortController();
1917 + let pendingResult;
1918 + await serverAct(async () => {
1919 + pendingResult = ReactDOMFizzStatic.prerender(<App />, {
1920 + signal: controller.signal,
1921 + onError(x) {
1922 + errors.push(x.message);
1923 + },
1924 + });
1925 + });
1926 +
1927 + controller.abort();
1928 +
1929 + const prerendered = await pendingResult;
1930 + const postponedState = JSON.stringify(prerendered.postponed);
1931 +
1932 + await readIntoNewDocument(prerendered.prelude);
1933 + expect(getVisibleChildren(document)).toEqual(
1934 + <html data-x="">
1935 + <head />
1936 + <body data-x="">Loading A</body>
1937 + </html>,
1938 + );
1939 +
1940 + await resolveA();
1941 +
1942 + expect(prerendered.postponed).not.toBe(null);
1943 +
1944 + const controller2 = new AbortController();
1945 + await serverAct(async () => {
1946 + pendingResult = ReactDOMFizzStatic.resumeAndPrerender(
1947 + <App />,
1948 + JSON.parse(postponedState),
1949 + {
1950 + signal: controller2.signal,
1951 + onError(x) {
1952 + errors.push(x.message);
1953 + },
1954 + },
1955 + );
1956 + });
1957 +
1958 + controller2.abort();
1959 +
1960 + const prerendered2 = await pendingResult;
1961 + const postponedState2 = JSON.stringify(prerendered2.postponed);
1962 +
1963 + await readIntoCurrentDocument(prerendered2.prelude);
1964 + expect(getVisibleChildren(document)).toEqual(
1965 + <html data-x="">
1966 + <head />
1967 + <body data-x="">Loading B</body>
1968 + </html>,
1969 + );
1970 +
1971 + await resolveB();
1972 +
1973 + const dynamic = await serverAct(() =>
1974 + ReactDOMFizzServer.resume(<App />, JSON.parse(postponedState2)),
1975 + );
1976 +
1977 + await readIntoCurrentDocument(dynamic);
1978 + expect(getVisibleChildren(document)).toEqual(
1979 + <html data-x="">
1980 + <head />
1981 + <body data-x="">Hello</body>
1982 + </html>,
1983 + );
1984 + });
1985 +
1986 + it('can suspend inside <head> tag', async () => {
1987 + const promise = new Promise(() => {});
1988 +
1989 + function App() {
1990 + return (
1991 + <html>
1992 + <head>
1993 + <Suspense fallback={<meta itemProp="" content="fallback" />}>
1994 + <Metadata />
1995 + </Suspense>
1996 + </head>
1997 + <body>
1998 + <div>hello</div>
1999 + </body>
2000 + </html>
2001 + );
2002 + }
2003 +
2004 + function Metadata() {
2005 + React.use(promise);
2006 + return <meta itemProp="" content="primary" />;
2007 + }
2008 +
2009 + const controller = new AbortController();
2010 + let pendingResult;
2011 + const errors = [];
2012 + await serverAct(() => {
2013 + pendingResult = ReactDOMFizzStatic.prerender(<App />, {
2014 + signal: controller.signal,
2015 + onError: e => {
2016 + errors.push(e.message);
2017 + },
2018 + });
2019 + });
2020 +
2021 + controller.abort(new Error('boom'));
2022 +
2023 + const prerendered = await pendingResult;
2024 +
2025 + await readIntoNewDocument(prerendered.prelude);
2026 + expect(getVisibleChildren(document)).toEqual(
2027 + <html>
2028 + <head>
2029 + <meta itemprop="" content="fallback" />
2030 + </head>
2031 + <body>
2032 + <div>hello</div>
2033 + </body>
2034 + </html>,
2035 + );
2036 +
2037 + expect(errors).toEqual(['boom']);
2038 + });
2039 +
2040 + // @gate enableHalt
2041 + it('will render fallback Document when erroring a boundary above the body', async () => {
2042 + let isPrerendering = true;
2043 + const promise = new Promise(() => {});
2044 +
2045 + function Boom() {
2046 + if (isPrerendering) {
2047 + React.use(promise);
2048 + }
2049 + throw new Error('Boom!');
2050 + }
2051 +
2052 + function App() {
2053 + return (
2054 + <Suspense
2055 + fallback={
2056 + <html data-error-html="">
2057 + <body data-error-body="">
2058 + <span>hello error</span>
2059 + </body>
2060 + </html>
2061 + }>
2062 + <html data-content-html="">
2063 + <body data-content-body="">
2064 + <Boom />
2065 + <span>hello world</span>
2066 + </body>
2067 + </html>
2068 + </Suspense>
2069 + );
2070 + }
2071 +
2072 + const controller = new AbortController();
2073 + let pendingResult;
2074 + const errors = [];
2075 + await serverAct(() => {
2076 + pendingResult = ReactDOMFizzStatic.prerender(<App />, {
2077 + signal: controller.signal,
2078 + onError: e => {
2079 + errors.push(e.message);
2080 + },
2081 + });
2082 + });
2083 +
2084 + controller.abort();
2085 +
2086 + const prerendered = await pendingResult;
2087 +
2088 + expect(errors).toEqual(['This operation was aborted']);
2089 + const content = await readContent(prerendered.prelude);
2090 + expect(content).toBe('');
2091 +
2092 + isPrerendering = false;
2093 + const postponedState = JSON.stringify(prerendered.postponed);
2094 +
2095 + const resumeErrors = [];
2096 + const dynamic = await serverAct(() =>
2097 + ReactDOMFizzServer.resume(<App />, JSON.parse(postponedState), {
2098 + onError: e => {
2099 + resumeErrors.push(e.message);
2100 + },
2101 + }),
2102 + );
2103 +
2104 + expect(resumeErrors).toEqual(['Boom!']);
2105 + await readIntoNewDocument(dynamic);
2106 +
2107 + expect(getVisibleChildren(document)).toEqual(
2108 + <html data-error-html="">
2109 + <head />
2110 + <body data-error-body="">
2111 + <span>hello error</span>
2112 + </body>
2113 + </html>,
2114 + );
2115 + });
2116 +
2117 + // @gate enableHalt
2118 + it('can omit a preamble with an empty shell if no preamble is ready when prerendering finishes', async () => {
2119 + const errors = [];
2120 +
2121 + let resolveA;
2122 + const promiseA = new Promise(r => (resolveA = r));
2123 + let resolveB;
2124 + const promiseB = new Promise(r => (resolveB = r));
2125 +
2126 + async function ComponentA() {
2127 + await promiseA;
2128 + return (
2129 + <Suspense fallback="Loading B">
2130 + <ComponentB />
2131 + </Suspense>
2132 + );
2133 + }
2134 +
2135 + async function ComponentB() {
2136 + await promiseB;
2137 + return 'Hello';
2138 + }
2139 +
2140 + function App() {
2141 + return (
2142 + <Suspense>
2143 + <html data-x="">
2144 + <body data-x="">
2145 + <ComponentA />
2146 + </body>
2147 + </html>
2148 + </Suspense>
2149 + );
2150 + }
2151 +
2152 + const controller = new AbortController();
2153 + let pendingResult;
2154 + await serverAct(async () => {
2155 + pendingResult = ReactDOMFizzStatic.prerender(<App />, {
2156 + signal: controller.signal,
2157 + onError(x) {
2158 + errors.push(x.message);
2159 + },
2160 + });
2161 + });
2162 +
2163 + controller.abort();
2164 +
2165 + const prerendered = await pendingResult;
2166 + const postponedState = JSON.stringify(prerendered.postponed);
2167 +
2168 + const content = await readContent(prerendered.prelude);
2169 + expect(content).toBe('');
2170 +
2171 + await resolveA();
2172 +
2173 + expect(prerendered.postponed).not.toBe(null);
2174 +
2175 + const controller2 = new AbortController();
2176 + await serverAct(async () => {
2177 + pendingResult = ReactDOMFizzStatic.resumeAndPrerender(
2178 + <App />,
2179 + JSON.parse(postponedState),
2180 + {
2181 + signal: controller2.signal,
2182 + onError(x) {
2183 + errors.push(x.message);
2184 + },
2185 + },
2186 + );
2187 + });
2188 +
2189 + controller2.abort();
2190 +
2191 + const prerendered2 = await pendingResult;
2192 + const postponedState2 = JSON.stringify(prerendered2.postponed);
2193 +
2194 + await readIntoNewDocument(prerendered2.prelude);
2195 + expect(getVisibleChildren(document)).toEqual(
2196 + <html data-x="">
2197 + <head />
2198 + <body data-x="">Loading B</body>
2199 + </html>,
2200 + );
2201 +
2202 + await resolveB();
2203 +
2204 + const dynamic = await serverAct(() =>
2205 + ReactDOMFizzServer.resume(<App />, JSON.parse(postponedState2)),
2206 + );
2207 +
2208 + await readIntoCurrentDocument(dynamic);
2209 + expect(getVisibleChildren(document)).toEqual(
2210 + <html data-x="">
2211 + <head />
2212 + <body data-x="">Hello</body>
2213 + </html>,
2214 + );
2215 });
2216 });
packages/react-markup/src/ReactFizzConfigMarkup.js
+11 -1
@@ -12,6 +12,7 @@ import type {ReactNodeList} from 'shared/ReactTypes';
12 import type {
13 RenderState,
14 ResumableState,
15 + PreambleState,
16 HoistableState,
17 FormatContext,
18 } from 'react-dom-bindings/src/server/ReactFizzConfigDOM';
@@ -42,6 +43,7 @@ export type {
43 RenderState,
44 ResumableState,
45 HoistableState,
46 + PreambleState,
47 FormatContext,
48 } from 'react-dom-bindings/src/server/ReactFizzConfigDOM';
49
@@ -64,8 +66,10 @@ export {
66 createRootFormatContext,
67 createRenderState,
68 createResumableState,
69 + createPreambleState,
70 createHoistableState,
68 - writePreamble,
71 + writePreambleStart,
72 + writePreambleEnd,
73 writeHoistables,
74 writePostamble,
75 hoistHoistables,
@@ -73,6 +77,10 @@ export {
77 completeResumableState,
78 emitEarlyPreloads,
79 doctypeChunk,
80 + canHavePreamble,
81 + hoistPreambleState,
82 + isPreambleReady,
83 + isPreambleContext,
84 } from 'react-dom-bindings/src/server/ReactFizzConfigDOM';
85
86 import escapeTextForBrowser from 'react-dom-bindings/src/server/escapeTextForBrowser';
@@ -83,6 +91,7 @@ export function pushStartInstance(
91 props: Object,
92 resumableState: ResumableState,
93 renderState: RenderState,
94 + preambleState: null | PreambleState,
95 hoistableState: null | HoistableState,
96 formatContext: FormatContext,
97 textEmbedded: boolean,
@@ -113,6 +122,7 @@ export function pushStartInstance(
122 props,
123 resumableState,
124 renderState,
125 + preambleState,
126 hoistableState,
127 formatContext,
128 textEmbedded,
packages/react-noop-renderer/src/ReactNoopServer.js
+16 -1
@@ -53,6 +53,7 @@ type Destination = {
53
54 type RenderState = null;
55 type HoistableState = null;
56 +type PreambleState = null;
57
58 const POP = Buffer.from('/', 'utf8');
59
@@ -264,7 +265,8 @@ const ReactNoopServer = ReactFizzServer({
265 boundary.status = 'client-render';
266 },
267
267 - writePreamble() {},
268 + writePreambleStart() {},
269 + writePreambleEnd() {},
270 writeHoistables() {},
271 writeHoistablesForBoundary() {},
272 writePostamble() {},
@@ -273,6 +275,19 @@ const ReactNoopServer = ReactFizzServer({
275 return null;
276 },
277 emitEarlyPreloads() {},
278 + createPreambleState(): PreambleState {
279 + return null;
280 + },
281 + canHavePreamble() {
282 + return false;
283 + },
284 + hoistPreambleState() {},
285 + isPreambleReady() {
286 + return true;
287 + },
288 + isPreambleContext() {
289 + return false;
290 + },
291 });
292
293 type Options = {
packages/react-server/src/ReactFizzServer.js
+309 -15
@@ -27,6 +27,7 @@ import type {LazyComponent as LazyComponentType} from 'react/src/ReactLazy';
27 import type {
28 RenderState,
29 ResumableState,
30 + PreambleState,
31 FormatContext,
32 HoistableState,
33 } from './ReactFizzConfig';
@@ -68,10 +69,12 @@ import {
69 pushSegmentFinale,
70 getChildFormatContext,
71 writeHoistables,
71 - writePreamble,
72 + writePreambleStart,
73 + writePreambleEnd,
74 writePostamble,
75 hoistHoistables,
76 createHoistableState,
77 + createPreambleState,
78 supportsRequestStorage,
79 requestStorage,
80 pushFormStateMarkerIsMatching,
@@ -80,6 +83,10 @@ import {
83 completeResumableState,
84 emitEarlyPreloads,
85 bindToConsole,
86 + canHavePreamble,
87 + hoistPreambleState,
88 + isPreambleReady,
89 + isPreambleContext,
90 } from './ReactFizzConfig';
91 import {
92 constructClassInstance,
@@ -222,6 +229,8 @@ type SuspenseBoundary = {
229 fallbackAbortableTasks: Set<Task>, // used to cancel task on the fallback if the boundary completes or gets canceled.
230 contentState: HoistableState,
231 fallbackState: HoistableState,
232 + contentPreamble: null | Preamble,
233 + fallbackPreamble: null | Preamble,
234 trackedContentKeyPath: null | KeyNode, // used to track the path for replay nodes
235 trackedFallbackNode: null | ReplayNode, // used to track the fallback for replay nodes
236 errorDigest: ?string, // the error hash if it errors
@@ -238,6 +247,7 @@ type RenderTask = {
247 ping: () => void,
248 blockedBoundary: Root | SuspenseBoundary,
249 blockedSegment: Segment, // the segment we'll write to
250 + blockedPreamble: null | Preamble,
251 hoistableState: null | HoistableState, // Boundary state we'll mutate while rendering. This may not equal the state of the blockedBoundary
252 abortSet: Set<Task>, // the abortable set that this task belongs to
253 keyPath: Root | KeyNode, // the path of all parent keys currently rendering
@@ -268,6 +278,7 @@ type ReplayTask = {
278 ping: () => void,
279 blockedBoundary: Root | SuspenseBoundary,
280 blockedSegment: null, // we don't write to anything when we replay
281 + blockedPreamble: null,
282 hoistableState: null | HoistableState, // Boundary state we'll mutate while rendering. This may not equal the state of the blockedBoundary
283 abortSet: Set<Task>, // the abortable set that this task belongs to
284 keyPath: Root | KeyNode, // the path of all parent keys currently rendering
@@ -302,6 +313,7 @@ type Segment = {
313 +index: number, // the index within the parent's chunks or 0 at the root
314 +chunks: Array<Chunk | PrecomputedChunk>,
315 +children: Array<Segment>,
316 + +preambleChildren: Array<Segment>,
317 // The context that this segment was created in.
318 parentFormatContext: FormatContext,
319 // If this segment represents a fallback, this is the content that will replace that fallback.
@@ -330,6 +342,7 @@ export opaque type Request = {
342 allPendingTasks: number, // when it reaches zero, we can close the connection.
343 pendingRootTasks: number, // when this reaches zero, we've finished at least the root boundary.
344 completedRootSegment: null | Segment, // Completed but not yet flushed root segments.
345 + completedPreambleSegments: null | Array<Array<Segment>>, // contains the ready-to-flush segments that make up the preamble
346 abortableTasks: Set<Task>,
347 pingedTasks: Array<Task>, // High priority tasks that should be worked on first.
348 // Queues to flush in order of priority
@@ -361,6 +374,8 @@ export opaque type Request = {
374 didWarnForKey?: null | WeakSet<ComponentStackNode>,
375 };
376
377 +type Preamble = PreambleState;
378 +
379 // This is a default heuristic for how to split up the HTML content into progressive
380 // loading. Our goal is to be able to display additional new content about every 500ms.
381 // Faster than that is unnecessary and should be throttled on the client. It also
@@ -426,6 +441,7 @@ function RequestInstance(
441 this.allPendingTasks = 0;
442 this.pendingRootTasks = 0;
443 this.completedRootSegment = null;
444 + this.completedPreambleSegments = null;
445 this.abortableTasks = abortSet;
446 this.pingedTasks = pingedTasks;
447 this.clientRenderedBoundaries = ([]: Array<SuspenseBoundary>);
@@ -493,6 +509,7 @@ export function createRequest(
509 null,
510 rootSegment,
511 null,
512 + null,
513 request.abortableTasks,
514 null,
515 rootFormatContext,
@@ -594,6 +611,7 @@ export function resumeRequest(
611 null,
612 rootSegment,
613 null,
614 + null,
615 request.abortableTasks,
616 null,
617 postponedState.rootFormatContext,
@@ -695,6 +713,8 @@ function pingTask(request: Request, task: Task): void {
713 function createSuspenseBoundary(
714 request: Request,
715 fallbackAbortableTasks: Set<Task>,
716 + contentPreamble: null | Preamble,
717 + fallbackPreamble: null | Preamble,
718 ): SuspenseBoundary {
719 const boundary: SuspenseBoundary = {
720 status: PENDING,
@@ -707,6 +727,8 @@ function createSuspenseBoundary(
727 errorDigest: null,
728 contentState: createHoistableState(),
729 fallbackState: createHoistableState(),
730 + contentPreamble,
731 + fallbackPreamble,
732 trackedContentKeyPath: null,
733 trackedFallbackNode: null,
734 };
@@ -726,6 +748,7 @@ function createRenderTask(
748 childIndex: number,
749 blockedBoundary: Root | SuspenseBoundary,
750 blockedSegment: Segment,
751 + blockedPreamble: null | Preamble,
752 hoistableState: null | HoistableState,
753 abortSet: Set<Task>,
754 keyPath: Root | KeyNode,
@@ -750,6 +773,7 @@ function createRenderTask(
773 ping: () => pingTask(request, task),
774 blockedBoundary,
775 blockedSegment,
776 + blockedPreamble,
777 hoistableState,
778 abortSet,
779 keyPath,
@@ -802,6 +826,7 @@ function createReplayTask(
826 ping: () => pingTask(request, task),
827 blockedBoundary,
828 blockedSegment: null,
829 + blockedPreamble: null,
830 hoistableState,
831 abortSet,
832 keyPath,
@@ -832,11 +857,12 @@ function createPendingSegment(
857 ): Segment {
858 return {
859 status: PENDING,
860 + parentFlushed: false,
861 id: -1, // lazily assigned later
862 index,
837 - parentFlushed: false,
863 chunks: [],
864 children: [],
865 + preambleChildren: [],
866 parentFormatContext,
867 boundary,
868 lastPushedText,
@@ -1116,6 +1142,7 @@ function renderSuspenseBoundary(
1142
1143 const prevKeyPath = task.keyPath;
1144 const parentBoundary = task.blockedBoundary;
1145 + const parentPreamble = task.blockedPreamble;
1146 const parentHoistableState = task.hoistableState;
1147 const parentSegment = task.blockedSegment;
1148
@@ -1127,10 +1154,21 @@ function renderSuspenseBoundary(
1154 const content: ReactNodeList = props.children;
1155
1156 const fallbackAbortSet: Set<Task> = new Set();
1130 - const newBoundary = createSuspenseBoundary(request, fallbackAbortSet);
1157 + let newBoundary: SuspenseBoundary;
1158 + if (canHavePreamble(task.formatContext)) {
1159 + newBoundary = createSuspenseBoundary(
1160 + request,
1161 + fallbackAbortSet,
1162 + createPreambleState(),
1163 + createPreambleState(),
1164 + );
1165 + } else {
1166 + newBoundary = createSuspenseBoundary(request, fallbackAbortSet, null, null);
1167 + }
1168 if (request.trackedPostpones !== null) {
1169 newBoundary.trackedContentKeyPath = keyPath;
1170 }
1171 +
1172 const insertionIndex = parentSegment.chunks.length;
1173 // The children of the boundary segment is actually the fallback.
1174 const boundarySegment = createPendingSegment(
@@ -1179,6 +1217,7 @@ function renderSuspenseBoundary(
1217 newBoundary.trackedFallbackNode = fallbackReplayNode;
1218
1219 task.blockedSegment = boundarySegment;
1220 + task.blockedPreamble = newBoundary.fallbackPreamble;
1221 task.keyPath = fallbackKeyPath;
1222 boundarySegment.status = RENDERING;
1223 try {
@@ -1199,6 +1238,7 @@ function renderSuspenseBoundary(
1238 throw thrownValue;
1239 } finally {
1240 task.blockedSegment = parentSegment;
1241 + task.blockedPreamble = parentPreamble;
1242 task.keyPath = prevKeyPath;
1243 }
1244
@@ -1211,6 +1251,7 @@ function renderSuspenseBoundary(
1251 -1,
1252 newBoundary,
1253 contentRootSegment,
1254 + newBoundary.contentPreamble,
1255 newBoundary.contentState,
1256 task.abortSet,
1257 keyPath,
@@ -1238,6 +1279,7 @@ function renderSuspenseBoundary(
1279 // context switching. We just need to temporarily switch which boundary and which segment
1280 // we're writing to. If something suspends, it'll spawn new suspended task with that context.
1281 task.blockedBoundary = newBoundary;
1282 + task.blockedPreamble = newBoundary.contentPreamble;
1283 task.hoistableState = newBoundary.contentState;
1284 task.blockedSegment = contentRootSegment;
1285 task.keyPath = keyPath;
@@ -1259,6 +1301,13 @@ function renderSuspenseBoundary(
1301 // Therefore we won't need the fallback. We early return so that we don't have to create
1302 // the fallback.
1303 newBoundary.status = COMPLETED;
1304 + if (request.pendingRootTasks === 0 && task.blockedPreamble) {
1305 + // The root is complete and this boundary may contribute part of the preamble.
1306 + // We eagerly attempt to prepare the preamble here because we expect most requests
1307 + // to have few boundaries which contribute preambles and it allow us to do this
1308 + // preparation work during the work phase rather than the when flushing.
1309 + preparePreamble(request);
1310 + }
1311 return;
1312 }
1313 } catch (thrownValue: mixed) {
@@ -1312,6 +1361,7 @@ function renderSuspenseBoundary(
1361 // We do need to fallthrough to create the fallback though.
1362 } finally {
1363 task.blockedBoundary = parentBoundary;
1364 + task.blockedPreamble = parentPreamble;
1365 task.hoistableState = parentHoistableState;
1366 task.blockedSegment = parentSegment;
1367 task.keyPath = prevKeyPath;
@@ -1327,6 +1377,7 @@ function renderSuspenseBoundary(
1377 -1,
1378 parentBoundary,
1379 boundarySegment,
1380 + newBoundary.fallbackPreamble,
1381 newBoundary.fallbackState,
1382 fallbackAbortSet,
1383 fallbackKeyPath,
@@ -1366,7 +1417,22 @@ function replaySuspenseBoundary(
1417 const fallback: ReactNodeList = props.fallback;
1418
1419 const fallbackAbortSet: Set<Task> = new Set();
1369 - const resumedBoundary = createSuspenseBoundary(request, fallbackAbortSet);
1420 + let resumedBoundary: SuspenseBoundary;
1421 + if (canHavePreamble(task.formatContext)) {
1422 + resumedBoundary = createSuspenseBoundary(
1423 + request,
1424 + fallbackAbortSet,
1425 + createPreambleState(),
1426 + createPreambleState(),
1427 + );
1428 + } else {
1429 + resumedBoundary = createSuspenseBoundary(
1430 + request,
1431 + fallbackAbortSet,
1432 + null,
1433 + null,
1434 + );
1435 + }
1436 resumedBoundary.parentFlushed = true;
1437 // We restore the same id of this boundary as was used during prerender.
1438 resumedBoundary.rootSegmentID = id;
@@ -1481,12 +1547,52 @@ function replaySuspenseBoundary(
1547 !disableLegacyContext ? task.legacyContext : emptyContextObject,
1548 __DEV__ && enableOwnerStacks ? task.debugTask : null,
1549 );
1550 +
1551 pushComponentStack(suspendedFallbackTask);
1552 // TODO: This should be queued at a separate lower priority queue so that we only work
1553 // on preparing fallbacks if we don't have any more main content to task on.
1554 request.pingedTasks.push(suspendedFallbackTask);
1555 }
1556
1557 +function renderPreamble(
1558 + request: Request,
1559 + task: Task,
1560 + blockedSegment: Segment,
1561 + node: ReactNodeList,
1562 +): void {
1563 + const preambleSegment = createPendingSegment(
1564 + request,
1565 + 0,
1566 + null,
1567 + task.formatContext,
1568 + false,
1569 + false,
1570 + );
1571 + blockedSegment.preambleChildren.push(preambleSegment);
1572 + // @TODO we can just attempt to render in the current task rather than spawning a new one
1573 + const preambleTask = createRenderTask(
1574 + request,
1575 + null,
1576 + node,
1577 + -1,
1578 + task.blockedBoundary,
1579 + preambleSegment,
1580 + task.blockedPreamble,
1581 + task.hoistableState,
1582 + request.abortableTasks,
1583 + task.keyPath,
1584 + task.formatContext,
1585 + task.context,
1586 + task.treeContext,
1587 + task.componentStack,
1588 + task.isFallback,
1589 + !disableLegacyContext ? task.legacyContext : emptyContextObject,
1590 + __DEV__ && enableOwnerStacks ? task.debugTask : null,
1591 + );
1592 + pushComponentStack(preambleTask);
1593 + request.pingedTasks.push(preambleTask);
1594 +}
1595 +
1596 function renderHostElement(
1597 request: Request,
1598 task: Task,
@@ -1513,12 +1619,14 @@ function renderHostElement(
1619 task.keyPath = prevKeyPath;
1620 } else {
1621 // Render
1622 + // RenderTask always has a preambleState
1623 const children = pushStartInstance(
1624 segment.chunks,
1625 type,
1626 props,
1627 request.resumableState,
1628 request.renderState,
1629 + task.blockedPreamble,
1630 task.hoistableState,
1631 task.formatContext,
1632 segment.lastPushedText,
@@ -1527,12 +1635,20 @@ function renderHostElement(
1635 segment.lastPushedText = false;
1636 const prevContext = task.formatContext;
1637 const prevKeyPath = task.keyPath;
1530 - task.formatContext = getChildFormatContext(prevContext, type, props);
1638 task.keyPath = keyPath;
1639
1533 - // We use the non-destructive form because if something suspends, we still
1534 - // need to pop back up and finish this subtree of HTML.
1535 - renderNode(request, task, children, -1);
1640 + const newContext = (task.formatContext = getChildFormatContext(
1641 + prevContext,
1642 + type,
1643 + props,
1644 + ));
1645 + if (isPreambleContext(newContext)) {
1646 + renderPreamble(request, task, segment, children);
1647 + } else {
1648 + // We use the non-destructive form because if something suspends, we still
1649 + // need to pop back up and finish this subtree of HTML.
1650 + renderNode(request, task, children, -1);
1651 + }
1652
1653 // We expect that errors will fatal the whole task and that we don't need
1654 // the correct context. Therefore this is not in a finally.
@@ -3356,6 +3472,7 @@ function spawnNewSuspendedRenderTask(
3472 task.childIndex,
3473 task.blockedBoundary,
3474 newSegment,
3475 + task.blockedPreamble,
3476 task.hoistableState,
3477 task.abortSet,
3478 task.keyPath,
@@ -3712,6 +3829,18 @@ function erroredTask(
3829 // We reuse the same queue for errors.
3830 request.clientRenderedBoundaries.push(boundary);
3831 }
3832 +
3833 + if (
3834 + request.pendingRootTasks === 0 &&
3835 + request.trackedPostpones === null &&
3836 + boundary.contentPreamble !== null
3837 + ) {
3838 + // The root is complete and this boundary may contribute part of the preamble.
3839 + // We eagerly attempt to prepare the preamble here because we expect most requests
3840 + // to have few boundaries which contribute preambles and it allow us to do this
3841 + // preparation work during the work phase rather than the when flushing.
3842 + preparePreamble(request);
3843 + }
3844 }
3845 }
3846
@@ -3742,7 +3871,12 @@ function abortRemainingSuspenseBoundary(
3871 errorInfo: ThrownInfo,
3872 wasAborted: boolean,
3873 ): void {
3745 - const resumedBoundary = createSuspenseBoundary(request, new Set());
3874 + const resumedBoundary = createSuspenseBoundary(
3875 + request,
3876 + new Set(),
3877 + null,
3878 + null,
3879 + );
3880 resumedBoundary.parentFlushed = true;
3881 // We restore the same id of this boundary as was used during prerender.
3882 resumedBoundary.rootSegmentID = rootSegmentID;
@@ -4038,6 +4172,13 @@ function completeShell(request: Request) {
4172 const shellComplete = true;
4173 safelyEmitEarlyPreloads(request, shellComplete);
4174 }
4175 + if (request.trackedPostpones === null) {
4176 + // When the shell is complete it will be possible to flush. We attempt to prepre
4177 + // the Preamble here in case it is ready for flushing.
4178 + // We exclude prerenders because these cannot flush until after completeAll has been called
4179 + preparePreamble(request);
4180 + }
4181 +
4182 // We have completed the shell so the shell can't error anymore.
4183 request.onShellError = noop;
4184 const onShellReady = request.onShellReady;
@@ -4060,6 +4201,11 @@ function completeAll(request: Request) {
4201 request.completedRootSegment === null ||
4202 request.completedRootSegment.status !== POSTPONED;
4203 safelyEmitEarlyPreloads(request, shellComplete);
4204 +
4205 + // When the shell is complete it will be possible to flush. We attempt to prepre
4206 + // the Preamble here in case it is ready for flushing
4207 + preparePreamble(request);
4208 +
4209 const onAllReady = request.onAllReady;
4210 onAllReady();
4211 }
@@ -4137,6 +4283,18 @@ function finishedTask(
4283 if (boundary.status === COMPLETED) {
4284 boundary.fallbackAbortableTasks.forEach(abortTaskSoft, request);
4285 boundary.fallbackAbortableTasks.clear();
4286 +
4287 + if (
4288 + request.pendingRootTasks === 0 &&
4289 + request.trackedPostpones === null &&
4290 + boundary.contentPreamble !== null
4291 + ) {
4292 + // The root is complete and this boundary may contribute part of the preamble.
4293 + // We eagerly attempt to prepare the preamble here because we expect most requests
4294 + // to have few boundaries which contribute preambles and it allow us to do this
4295 + // preparation work during the work phase rather than the when flushing.
4296 + preparePreamble(request);
4297 + }
4298 }
4299 } else {
4300 if (segment !== null && segment.parentFlushed) {
@@ -4477,19 +4635,137 @@ export function performWork(request: Request): void {
4635 }
4636 }
4637
4638 +function preparePreambleFromSubtree(
4639 + request: Request,
4640 + segment: Segment,
4641 + collectedPreambleSegments: Array<Array<Segment>>,
4642 +): boolean {
4643 + if (segment.preambleChildren.length) {
4644 + collectedPreambleSegments.push(segment.preambleChildren);
4645 + }
4646 + let pendingPreambles = false;
4647 + for (let i = 0; i < segment.children.length; i++) {
4648 + const nextSegment = segment.children[i];
4649 + pendingPreambles =
4650 + preparePreambleFromSegment(
4651 + request,
4652 + nextSegment,
4653 + collectedPreambleSegments,
4654 + ) || pendingPreambles;
4655 + }
4656 + return pendingPreambles;
4657 +}
4658 +
4659 +function preparePreambleFromSegment(
4660 + request: Request,
4661 + segment: Segment,
4662 + collectedPreambleSegments: Array<Array<Segment>>,
4663 +): boolean {
4664 + const boundary = segment.boundary;
4665 + if (boundary === null) {
4666 + // This segment is not a boundary, let's check it's children
4667 + return preparePreambleFromSubtree(
4668 + request,
4669 + segment,
4670 + collectedPreambleSegments,
4671 + );
4672 + }
4673 +
4674 + const preamble = boundary.contentPreamble;
4675 + const fallbackPreamble = boundary.fallbackPreamble;
4676 +
4677 + if (preamble === null || fallbackPreamble === null) {
4678 + // This boundary cannot have a preamble so it can't block the flushing of
4679 + // the preamble.
4680 + return false;
4681 + }
4682 +
4683 + const status = boundary.status;
4684 +
4685 + switch (status) {
4686 + case COMPLETED: {
4687 + // This boundary is complete. It might have inner boundaries which are pending
4688 + // and able to provide a preamble so we have to check it's children
4689 + hoistPreambleState(request.renderState, preamble);
4690 + const boundaryRootSegment = boundary.completedSegments[0];
4691 + if (!boundaryRootSegment) {
4692 + // Using the same error from flushSegment to avoid making a new one since conceptually the problem is still the same
4693 + throw new Error(
4694 + 'A previously unvisited boundary must have exactly one root segment. This is a bug in React.',
4695 + );
4696 + }
4697 + return preparePreambleFromSubtree(
4698 + request,
4699 + boundaryRootSegment,
4700 + collectedPreambleSegments,
4701 + );
4702 + }
4703 + case POSTPONED: {
4704 + // This segment is postponed. When prerendering we consider this pending still because
4705 + // it can resume. If we're rendering then this is equivalent to errored.
4706 + if (request.trackedPostpones !== null) {
4707 + // This boundary won't contribute a preamble to the current prerender
4708 + return true;
4709 + }
4710 + // Expected fallthrough
4711 + }
4712 + case CLIENT_RENDERED: {
4713 + if (segment.status === COMPLETED) {
4714 + // This boundary is errored so if it contains a preamble we should include it
4715 + hoistPreambleState(request.renderState, fallbackPreamble);
4716 + return preparePreambleFromSubtree(
4717 + request,
4718 + segment,
4719 + collectedPreambleSegments,
4720 + );
4721 + }
4722 + // Expected fallthrough
4723 + }
4724 + default:
4725 + // This boundary is still pending and might contain a preamble
4726 + return true;
4727 + }
4728 +}
4729 +
4730 +function preparePreamble(request: Request) {
4731 + if (
4732 + request.completedRootSegment &&
4733 + request.completedPreambleSegments === null
4734 + ) {
4735 + const collectedPreambleSegments: Array<Array<Segment>> = [];
4736 + const hasPendingPreambles = preparePreambleFromSegment(
4737 + request,
4738 + request.completedRootSegment,
4739 + collectedPreambleSegments,
4740 + );
4741 + if (isPreambleReady(request.renderState, hasPendingPreambles)) {
4742 + request.completedPreambleSegments = collectedPreambleSegments;
4743 + }
4744 + }
4745 +}
4746 +
4747 function flushPreamble(
4748 request: Request,
4749 destination: Destination,
4750 rootSegment: Segment,
4751 + preambleSegments: Array<Array<Segment>>,
4752 ) {
4753 + // The preamble is ready.
4754 const willFlushAllSegments =
4755 request.allPendingTasks === 0 && request.trackedPostpones === null;
4487 - writePreamble(
4756 + writePreambleStart(
4757 destination,
4758 request.resumableState,
4759 request.renderState,
4760 willFlushAllSegments,
4761 );
4762 + for (let i = 0; i < preambleSegments.length; i++) {
4763 + const segments = preambleSegments[i];
4764 + for (let j = 0; j < segments.length; j++) {
4765 + flushSegment(request, destination, segments[j], null);
4766 + }
4767 + }
4768 + writePreambleEnd(destination, request.renderState);
4769 }
4770
4771 function flushSubtree(
@@ -4825,11 +5101,21 @@ function flushCompletedQueues(
5101 const completedRootSegment = request.completedRootSegment;
5102 if (completedRootSegment !== null) {
5103 if (completedRootSegment.status === POSTPONED) {
4828 - // We postponed the root, so we write nothing.
5104 return;
5105 }
5106
4832 - flushPreamble(request, destination, completedRootSegment);
5107 + const completedPreambleSegments = request.completedPreambleSegments;
5108 + if (completedPreambleSegments === null) {
5109 + // The preamble isn't ready yet even though the root is so we omit flushing
5110 + return;
5111 + }
5112 +
5113 + flushPreamble(
5114 + request,
5115 + destination,
5116 + completedRootSegment,
5117 + completedPreambleSegments,
5118 + );
5119 flushSegment(request, destination, completedRootSegment, null);
5120 request.completedRootSegment = null;
5121 writeCompletedRoot(destination, request.renderState);
@@ -5147,13 +5433,21 @@ export function getPostponedState(request: Request): null | PostponedState {
5433 request.trackedPostpones = null;
5434 return null;
5435 }
5436 + let replaySlots: ResumeSlots;
5437 if (
5438 request.completedRootSegment !== null &&
5152 - request.completedRootSegment.status === POSTPONED
5439 + // The Root postponed
5440 + (request.completedRootSegment.status === POSTPONED ||
5441 + // Or the Preamble was not available
5442 + request.completedPreambleSegments === null)
5443 ) {
5154 - // We postponed the root so we didn't flush anything.
5444 + // This is necessary for the pending preamble case and is idempotent for the
5445 + // postponed root case
5446 + replaySlots = request.completedRootSegment.id;
5447 + // We either postponed the root or we did not have a preamble to flush
5448 resetResumableState(request.resumableState, request.renderState);
5449 } else {
5450 + replaySlots = trackedPostpones.rootSlots;
5451 completeResumableState(request.resumableState);
5452 }
5453 return {
@@ -5162,6 +5456,6 @@ export function getPostponedState(request: Request): null | PostponedState {
5456 progressiveChunkSize: request.progressiveChunkSize,
5457 resumableState: request.resumableState,
5458 replayNodes: trackedPostpones.rootNodes,
5165 - replaySlots: trackedPostpones.rootSlots,
5459 + replaySlots,
5460 };
5461 }
packages/react-server/src/forks/ReactFizzConfig.custom.js
+8 -1
@@ -31,6 +31,7 @@ export opaque type Destination = mixed;
31 export opaque type RenderState = mixed;
32 export opaque type HoistableState = mixed;
33 export opaque type ResumableState = mixed;
34 +export opaque type PreambleState = mixed;
35 export opaque type FormatContext = mixed;
36 export opaque type HeadersDescriptor = mixed;
37 export type {TransitionStatus};
@@ -79,11 +80,17 @@ export const writeCompletedBoundaryInstruction =
80 export const writeClientRenderBoundaryInstruction =
81 $$$config.writeClientRenderBoundaryInstruction;
82 export const NotPendingTransition = $$$config.NotPendingTransition;
83 +export const createPreambleState = $$$config.createPreambleState;
84 +export const canHavePreamble = $$$config.canHavePreamble;
85 +export const isPreambleContext = $$$config.isPreambleContext;
86 +export const isPreambleReady = $$$config.isPreambleReady;
87 +export const hoistPreambleState = $$$config.hoistPreambleState;
88
89 // -------------------------
90 // Resources
91 // -------------------------
86 -export const writePreamble = $$$config.writePreamble;
92 +export const writePreambleStart = $$$config.writePreambleStart;
93 +export const writePreambleEnd = $$$config.writePreambleEnd;
94 export const writeHoistables = $$$config.writeHoistables;
95 export const writeHoistablesForBoundary = $$$config.writeHoistablesForBoundary;
96 export const writePostamble = $$$config.writePostamble;
scripts/error-codes/codes.json
+2 -1
@@ -529,5 +529,6 @@
529 "541": "Compared context values must be arrays",
530 "542": "Suspense Exception: This is not a real error! It's an implementation detail of `useActionState` to interrupt the current render. You must either rethrow it immediately, or move the `useActionState` call outside of the `try/catch` block. Capturing without rethrowing will lead to unexpected behavior.\n\nTo handle async errors, wrap your component in an error boundary.",
531 "543": "Expected a ResourceEffectUpdate to be pushed together with ResourceEffectIdentity. This is a bug in React.",
532 - "544": "Found a pair with an auto name. This is a bug in React."
532 + "544": "Found a pair with an auto name. This is a bug in React.",
533 + "545": "The %s tag may only be rendered once."
534 }