diff --git a/packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js b/packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js index 04aaf0c2ac706..4b8841a06e67b 100644 --- a/packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js +++ b/packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js @@ -135,8 +135,7 @@ export type RenderState = { // be null or empty when resuming. // preamble chunks - htmlChunks: null | Array, - headChunks: null | Array, + preamble: PreambleState, // external runtime script chunks externalRuntimeScript: null | ExternalRuntimeScript, @@ -442,8 +441,7 @@ export function createRenderState( segmentPrefix: stringToPrecomputedChunk(idPrefix + 'S:'), boundaryPrefix: stringToPrecomputedChunk(idPrefix + 'B:'), startInlineScript: inlineScriptWithNonce, - htmlChunks: null, - headChunks: null, + preamble: createPreambleState(), externalRuntimeScript: externalRuntimeScript, bootstrapChunks: bootstrapChunks, @@ -686,6 +684,19 @@ export function completeResumableState(resumableState: ResumableState): void { resumableState.bootstrapModules = undefined; } +export type PreambleState = { + htmlChunks: null | Array, + headChunks: null | Array, + bodyChunks: null | Array, +}; +export function createPreambleState(): PreambleState { + return { + htmlChunks: null, + headChunks: null, + bodyChunks: null, + }; +} + // Constants for the insertion mode we're currently writing in. We don't encode all HTML5 insertion // modes. We only include the variants as they matter for the sake of our purposes. // 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. // still makes sense const HTML_HTML_MODE = 1; // Used for the if it is at the top level. const HTML_MODE = 2; -const SVG_MODE = 3; -const MATHML_MODE = 4; -const HTML_TABLE_MODE = 5; -const HTML_TABLE_BODY_MODE = 6; -const HTML_TABLE_ROW_MODE = 7; -const HTML_COLGROUP_MODE = 8; +const HTML_HEAD_MODE = 3; +const SVG_MODE = 4; +const MATHML_MODE = 5; +const HTML_TABLE_MODE = 6; +const HTML_TABLE_BODY_MODE = 7; +const HTML_TABLE_ROW_MODE = 8; +const HTML_COLGROUP_MODE = 9; // We have a greater than HTML_TABLE_MODE check elsewhere. If you add more cases here, make sure it // still makes sense -type InsertionMode = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8; +type InsertionMode = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9; const NO_SCOPE = /* */ 0b00; const NOSCRIPT_SCOPE = /* */ 0b01; @@ -728,6 +740,10 @@ function createFormatContext( }; } +export function canHavePreamble(formatContext: FormatContext): boolean { + return formatContext.insertionMode < HTML_MODE; +} + export function createRootFormatContext(namespaceURI?: string): FormatContext { const insertionMode = namespaceURI === 'http://www.w3.org/2000/svg' @@ -792,27 +808,42 @@ export function getChildFormatContext( null, parentContext.tagScope, ); + case 'head': + if (parentContext.insertionMode < HTML_MODE) { + // We are either at the root or inside the tag and can enter + // the scope + return createFormatContext( + HTML_HEAD_MODE, + null, + parentContext.tagScope, + ); + } + break; + case 'html': + if (parentContext.insertionMode === ROOT_HTML_MODE) { + return createFormatContext( + HTML_HTML_MODE, + null, + parentContext.tagScope, + ); + } + break; } if (parentContext.insertionMode >= HTML_TABLE_MODE) { // Whatever tag this was, it wasn't a table parent or other special parent, so we must have // entered plain HTML again. return createFormatContext(HTML_MODE, null, parentContext.tagScope); } - if (parentContext.insertionMode === ROOT_HTML_MODE) { - if (type === 'html') { - // We've emitted the root and is now in mode. - return createFormatContext(HTML_HTML_MODE, null, parentContext.tagScope); - } else { - // We've emitted the root and is now in plain HTML mode. - return createFormatContext(HTML_MODE, null, parentContext.tagScope); - } - } else if (parentContext.insertionMode === HTML_HTML_MODE) { - // We've emitted the document element and is now in plain HTML mode. + if (parentContext.insertionMode < HTML_MODE) { return createFormatContext(HTML_MODE, null, parentContext.tagScope); } return parentContext; } +export function isPreambleContext(formatContext: FormatContext): boolean { + return formatContext.insertionMode === HTML_HEAD_MODE; +} + export function makeId( resumableState: ResumableState, treeId: string, @@ -3185,12 +3216,18 @@ function pushStartHead( target: Array, props: Object, renderState: RenderState, + preambleState: null | PreambleState, insertionMode: InsertionMode, ): ReactNodeList { - if (insertionMode < HTML_MODE && renderState.headChunks === null) { + if (insertionMode < HTML_MODE) { // This is the Document.head and should be part of the preamble - renderState.headChunks = []; - return pushStartGenericElement(renderState.headChunks, props, 'head'); + const preamble = preambleState || renderState.preamble; + + if (preamble.headChunks) { + throw new Error(`The ${'``'} tag may only be rendered once.`); + } + preamble.headChunks = []; + return pushStartGenericElement(preamble.headChunks, props, 'head'); } else { // This is deep and is likely just an error. we emit it inline though. // Validation should warn that this tag is the the wrong spot. @@ -3198,16 +3235,47 @@ function pushStartHead( } } +function pushStartBody( + target: Array, + props: Object, + renderState: RenderState, + preambleState: null | PreambleState, + insertionMode: InsertionMode, +): ReactNodeList { + if (insertionMode < HTML_MODE) { + // This is the Document.body + const preamble = preambleState || renderState.preamble; + + if (preamble.bodyChunks) { + throw new Error(`The ${'``'} tag may only be rendered once.`); + } + + preamble.bodyChunks = []; + return pushStartGenericElement(preamble.bodyChunks, props, 'body'); + } else { + // This is deep and is likely just an error. we emit it inline though. + // Validation should warn that this tag is the the wrong spot. + return pushStartGenericElement(target, props, 'body'); + } +} + function pushStartHtml( target: Array, props: Object, renderState: RenderState, + preambleState: null | PreambleState, insertionMode: InsertionMode, ): ReactNodeList { - if (insertionMode === ROOT_HTML_MODE && renderState.htmlChunks === null) { - // This is the Document.documentElement and should be part of the preamble - renderState.htmlChunks = [DOCTYPE]; - return pushStartGenericElement(renderState.htmlChunks, props, 'html'); + if (insertionMode === ROOT_HTML_MODE) { + // This is the Document.documentElement + const preamble = preambleState || renderState.preamble; + + if (preamble.htmlChunks) { + throw new Error(`The ${'``'} tag may only be rendered once.`); + } + + preamble.htmlChunks = [DOCTYPE]; + return pushStartGenericElement(preamble.htmlChunks, props, 'html'); } else { // This is deep and is likely just an error. we emit it inline though. // Validation should warn that this tag is the the wrong spot. @@ -3562,6 +3630,7 @@ export function pushStartInstance( props: Object, resumableState: ResumableState, renderState: RenderState, + preambleState: null | PreambleState, hoistableState: null | HoistableState, formatContext: FormatContext, textEmbedded: boolean, @@ -3729,6 +3798,15 @@ export function pushStartInstance( target, props, renderState, + preambleState, + formatContext.insertionMode, + ); + case 'body': + return pushStartBody( + target, + props, + renderState, + preambleState, formatContext.insertionMode, ); case 'html': { @@ -3736,6 +3814,7 @@ export function pushStartInstance( target, props, renderState, + preambleState, formatContext.insertionMode, ); } @@ -3814,10 +3893,50 @@ export function pushEndInstance( return; } break; + case 'head': + if (formatContext.insertionMode <= HTML_HTML_MODE) { + return; + } + break; } target.push(endChunkForTag(type)); } +export function hoistPreambleState( + renderState: RenderState, + preambleState: PreambleState, +) { + const rootPreamble = renderState.preamble; + if (rootPreamble.htmlChunks === null) { + rootPreamble.htmlChunks = preambleState.htmlChunks; + } + if (rootPreamble.headChunks === null) { + rootPreamble.headChunks = preambleState.headChunks; + } + if (rootPreamble.bodyChunks === null) { + rootPreamble.bodyChunks = preambleState.bodyChunks; + } +} + +export function isPreambleReady( + renderState: RenderState, + // This means there are unfinished Suspense boundaries which could contain + // a preamble. In the case of DOM we constrain valid programs to only having + // one instance of each singleton so we can determine the preamble is ready + // as long as we have chunks for each of these tags. + hasPendingPreambles: boolean, +): boolean { + const preamble = renderState.preamble; + return ( + // There are no remaining boundaries which might contain a preamble so + // the preamble is as complete as it is going to get + hasPendingPreambles === false || + // we have a head and body tag. we don't need to wait for any more + // because it would be invalid to render additional copies of these tags + !!(preamble.headChunks && preamble.bodyChunks) + ); +} + function writeBootstrap( destination: Destination, renderState: RenderState, @@ -4033,6 +4152,7 @@ export function writeStartSegment( switch (formatContext.insertionMode) { case ROOT_HTML_MODE: case HTML_HTML_MODE: + case HTML_HEAD_MODE: case HTML_MODE: { writeChunk(destination, startSegmentHTML); writeChunk(destination, renderState.segmentPrefix); @@ -4091,6 +4211,7 @@ export function writeEndSegment( switch (formatContext.insertionMode) { case ROOT_HTML_MODE: case HTML_HTML_MODE: + case HTML_HEAD_MODE: case HTML_MODE: { return writeChunkAndReturn(destination, endSegmentHTML); } @@ -4679,7 +4800,7 @@ function preloadLateStyles(this: Destination, styleQueue: StyleQueue) { // flush the entire preamble in a single pass. This probably should be modified // in the future to be backpressure sensitive but that requires a larger refactor // of the flushing code in Fizz. -export function writePreamble( +export function writePreambleStart( destination: Destination, resumableState: ResumableState, renderState: RenderState, @@ -4700,8 +4821,10 @@ export function writePreamble( internalPreinitScript(resumableState, renderState, src, chunks); } - const htmlChunks = renderState.htmlChunks; - const headChunks = renderState.headChunks; + const preamble = renderState.preamble; + + const htmlChunks = preamble.htmlChunks; + const headChunks = preamble.headChunks; let i = 0; @@ -4773,12 +4896,31 @@ export function writePreamble( writeChunk(destination, hoistableChunks[i]); } hoistableChunks.length = 0; +} - if (htmlChunks && headChunks === null) { +// We don't bother reporting backpressure at the moment because we expect to +// flush the entire preamble in a single pass. This probably should be modified +// in the future to be backpressure sensitive but that requires a larger refactor +// of the flushing code in Fizz. +export function writePreambleEnd( + destination: Destination, + renderState: RenderState, +): void { + const preamble = renderState.preamble; + const htmlChunks = preamble.htmlChunks; + const headChunks = preamble.headChunks; + if (htmlChunks || headChunks) { // we have an but we inserted an implicit tag. We need // to close it since the main content won't have it writeChunk(destination, endChunkForTag('head')); } + + const bodyChunks = preamble.bodyChunks; + if (bodyChunks) { + for (let i = 0; i < bodyChunks.length; i++) { + writeChunk(destination, bodyChunks[i]); + } + } } // We don't bother reporting backpressure at the moment because we expect to diff --git a/packages/react-dom-bindings/src/server/ReactFizzConfigDOMLegacy.js b/packages/react-dom-bindings/src/server/ReactFizzConfigDOMLegacy.js index e4cd9ca93d99e..491d322793941 100644 --- a/packages/react-dom-bindings/src/server/ReactFizzConfigDOMLegacy.js +++ b/packages/react-dom-bindings/src/server/ReactFizzConfigDOMLegacy.js @@ -13,6 +13,7 @@ import type { StyleQueue, Resource, HeadersDescriptor, + PreambleState, } from './ReactFizzConfigDOM'; import { @@ -43,8 +44,7 @@ export type RenderState = { segmentPrefix: PrecomputedChunk, boundaryPrefix: PrecomputedChunk, startInlineScript: PrecomputedChunk, - htmlChunks: null | Array, - headChunks: null | Array, + preamble: PreambleState, externalRuntimeScript: null | any, bootstrapChunks: Array, importMapChunks: Array, @@ -96,8 +96,7 @@ export function createRenderState( segmentPrefix: renderState.segmentPrefix, boundaryPrefix: renderState.boundaryPrefix, startInlineScript: renderState.startInlineScript, - htmlChunks: renderState.htmlChunks, - headChunks: renderState.headChunks, + preamble: renderState.preamble, externalRuntimeScript: renderState.externalRuntimeScript, bootstrapChunks: renderState.bootstrapChunks, importMapChunks: renderState.importMapChunks, @@ -134,6 +133,7 @@ export const doctypeChunk: PrecomputedChunk = stringToPrecomputedChunk(''); export type { ResumableState, HoistableState, + PreambleState, FormatContext, } from './ReactFizzConfigDOM'; @@ -156,8 +156,10 @@ export { writeCompletedRoot, createRootFormatContext, createResumableState, + createPreambleState, createHoistableState, - writePreamble, + writePreambleStart, + writePreambleEnd, writeHoistables, writePostamble, hoistHoistables, @@ -165,6 +167,10 @@ export { completeResumableState, emitEarlyPreloads, supportsClientAPIs, + canHavePreamble, + hoistPreambleState, + isPreambleReady, + isPreambleContext, } from './ReactFizzConfigDOM'; import escapeTextForBrowser from './escapeTextForBrowser'; diff --git a/packages/react-dom/src/__tests__/ReactDOMFizzDeferredValue-test.js b/packages/react-dom/src/__tests__/ReactDOMFizzDeferredValue-test.js index de83a8f0a528d..cceef34e03d66 100644 --- a/packages/react-dom/src/__tests__/ReactDOMFizzDeferredValue-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMFizzDeferredValue-test.js @@ -114,9 +114,11 @@ describe('ReactDOMFizzForm', () => { function App() { return ( - }> - - +
+ }> + + +
); } diff --git a/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js b/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js index 058473b96c7e1..ac6374600fc64 100644 --- a/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js @@ -1282,15 +1282,17 @@ describe('ReactDOMFizzServer', () => { function App({showMore}) { return ( - - {a} - {b} - {showMore ? ( - - C - - ) : null} - +
+ + {a} + {b} + {showMore ? ( + + C + + ) : null} + +
); } @@ -1308,12 +1310,14 @@ describe('ReactDOMFizzServer', () => { // We're not hydrated yet. expect(ref.current).toBe(null); - expect(getVisibleChildren(container)).toEqual([ - 'Loading A', - // TODO: This is incorrect. It should be "Loading B" but Fizz SuspenseList - // isn't implemented fully yet. - B, - ]); + expect(getVisibleChildren(container)).toEqual( +
+ Loading A + {/* // TODO: This is incorrect. It should be "Loading B" but Fizz SuspenseList + // isn't implemented fully yet. */} + B +
, + ); // Add more rows before we've hydrated the first two. root.render(); @@ -1323,13 +1327,15 @@ describe('ReactDOMFizzServer', () => { expect(ref.current).toBe(null); // We haven't resolved yet. - expect(getVisibleChildren(container)).toEqual([ - 'Loading A', - // TODO: This is incorrect. It should be "Loading B" but Fizz SuspenseList - // isn't implemented fully yet. - B, - 'Loading C', - ]); + expect(getVisibleChildren(container)).toEqual( +
+ Loading A + {/* // TODO: This is incorrect. It should be "Loading B" but Fizz SuspenseList + // isn't implemented fully yet. */} + B + Loading C +
, + ); await act(async () => { await resolveText('A'); @@ -1337,11 +1343,13 @@ describe('ReactDOMFizzServer', () => { await waitForAll([]); - expect(getVisibleChildren(container)).toEqual([ - A, - B, - C, - ]); + expect(getVisibleChildren(container)).toEqual( +
+ A + B + C +
, + ); const span = container.getElementsByTagName('span')[0]; expect(ref.current).toBe(span); @@ -1470,16 +1478,18 @@ describe('ReactDOMFizzServer', () => { await act(() => { const {pipe} = renderToPipeableStream( // We use two nested boundaries to flush out coverage of an old reentrancy bug. - - }> - <> - -
- -
- +
+ + }> + <> + +
+ +
+ +
- , +
, { identifierPrefix: 'A_', onShellReady() { @@ -1493,12 +1503,14 @@ describe('ReactDOMFizzServer', () => { await act(() => { const {pipe} = renderToPipeableStream( - }> - -
- -
-
, +
+ }> + +
+ +
+
+
, { identifierPrefix: 'B_', onShellReady() { @@ -1511,8 +1523,12 @@ describe('ReactDOMFizzServer', () => { }); expect(getVisibleChildren(container)).toEqual([ -
Loading A...
, -
Loading B...
, +
+
Loading A...
+
, +
+
Loading B...
+
, ]); await act(() => { @@ -1520,9 +1536,13 @@ describe('ReactDOMFizzServer', () => { }); expect(getVisibleChildren(container)).toEqual([ -
Loading A...
, +
+
Loading A...
+
,
- This will show B:
B
+
+ This will show B:
B
+
, ]); @@ -1535,10 +1555,14 @@ describe('ReactDOMFizzServer', () => { expect(getVisibleChildren(container)).toEqual([
- This will show A:
A
+
+ This will show A:
A
+
,
- This will show B:
B
+
+ This will show B:
B
+
, ]); }); @@ -2087,15 +2111,21 @@ describe('ReactDOMFizzServer', () => { it('client renders a boundary if it errors before finishing the fallback', async () => { function App({isClient}) { return ( - -
- }> -

- {isClient ? : } -

-
-
-
+
+ +
+ }> +

+ {isClient ? ( + + ) : ( + + )} +

+
+
+
+
); } @@ -2132,7 +2162,7 @@ describe('ReactDOMFizzServer', () => { await waitForAll([]); // We're still loading because we're waiting for the server to stream more content. - expect(getVisibleChildren(container)).toEqual('Loading root...'); + expect(getVisibleChildren(container)).toEqual(
Loading root...
); expect(loggedErrors).toEqual([]); @@ -2145,7 +2175,7 @@ describe('ReactDOMFizzServer', () => { // We still can't render it on the client because we haven't unblocked the parent. await waitForAll([]); - expect(getVisibleChildren(container)).toEqual('Loading root...'); + expect(getVisibleChildren(container)).toEqual(
Loading root...
); // Unblock the loading state await act(() => { @@ -2153,7 +2183,11 @@ describe('ReactDOMFizzServer', () => { }); // Now we're able to show the inner boundary. - expect(getVisibleChildren(container)).toEqual(
Loading...
); + expect(getVisibleChildren(container)).toEqual( +
+
Loading...
+
, + ); // That will let us client render it instead. await waitForAll([]); @@ -2170,6 +2204,7 @@ describe('ReactDOMFizzServer', () => { 'Suspense', 'div', 'Suspense', + 'div', 'App', ]), ], @@ -2185,7 +2220,9 @@ describe('ReactDOMFizzServer', () => { // The client rendered HTML is now in place. expect(getVisibleChildren(container)).toEqual(
-

Hello

+
+

Hello

+
, ); @@ -2195,30 +2232,36 @@ describe('ReactDOMFizzServer', () => { it('should be able to abort the fallback if the main content finishes first', async () => { await act(() => { const {pipe} = renderToPipeableStream( - }> -
- - - Inner -
- }> - -
- -
, +
+ }> +
+ + + Inner +
+ }> + +
+
+
+ , ); pipe(writable); }); - expect(getVisibleChildren(container)).toEqual('Loading Outer'); + expect(getVisibleChildren(container)).toEqual(
Loading Outer
); // We should have received a partial segment containing the a partial of the fallback. expect(container.innerHTML).toContain('Inner'); await act(() => { resolveText('Hello'); }); // We should've been able to display the content without waiting for the rest of the fallback. - expect(getVisibleChildren(container)).toEqual(
Hello
); + expect(getVisibleChildren(container)).toEqual( +
+
Hello
+
, + ); }); it('calls getServerSnapshot instead of getSnapshot', async () => { @@ -5285,15 +5328,17 @@ describe('ReactDOMFizzServer', () => { it('does not insert text separators even when adjacent text is in a delayed segment', async () => { function App({name}) { return ( - -
- hello - - world, - - ! -
-
+
+ +
+ hello + + world, + + ! +
+
+
); } @@ -5311,19 +5356,11 @@ describe('ReactDOMFizzServer', () => { const div = stripExternalRuntimeInNodes( container.children, renderOptions.unstable_externalRuntimeSrc, - )[0]; + )[0].children[0]; expect(div.outerHTML).toEqual( '
helloworld, Foo!
', ); - // there may be either: - // - an external runtime script and deleted nodes with data attributes - // - extra script nodes containing fizz instructions at the end of container - expect( - Array.from(container.childNodes).filter(e => e.tagName !== 'SCRIPT') - .length, - ).toBe(3); - expect(div.childNodes.length).toBe(3); const b = div.childNodes[1]; expect(b.childNodes.length).toBe(2); @@ -5339,8 +5376,10 @@ describe('ReactDOMFizzServer', () => { await waitForAll([]); expect(errors).toEqual([]); expect(getVisibleChildren(container)).toEqual( -
- helloworld, {'Foo'}! +
+
+ helloworld, {'Foo'}! +
, ); }); @@ -5348,12 +5387,14 @@ describe('ReactDOMFizzServer', () => { it('works with multiple adjacent segments', async () => { function App() { return ( - -
- h - w -
-
+
+ +
+ h + w +
+
+
); } @@ -5377,7 +5418,7 @@ describe('ReactDOMFizzServer', () => { stripExternalRuntimeInNodes( container.children, renderOptions.unstable_externalRuntimeSrc, - )[0].outerHTML, + )[0].children[0].outerHTML, ).toEqual('
helloworld
'); const errors = []; @@ -5389,19 +5430,23 @@ describe('ReactDOMFizzServer', () => { await waitForAll([]); expect(errors).toEqual([]); expect(getVisibleChildren(container)).toEqual( -
{['h', 'ello', 'w', 'orld']}
, +
+
{['h', 'ello', 'w', 'orld']}
+
, ); }); it('works when some segments are flushed and others are patched', async () => { function App() { return ( - -
- h - w -
-
+
+ +
+ h + w +
+
+
); } @@ -5422,7 +5467,7 @@ describe('ReactDOMFizzServer', () => { stripExternalRuntimeInNodes( container.children, renderOptions.unstable_externalRuntimeSrc, - )[0].outerHTML, + )[0].children[0].outerHTML, ).toEqual('
helloworld
'); const errors = []; @@ -5437,7 +5482,9 @@ describe('ReactDOMFizzServer', () => { await waitForAll([]); expect(errors).toEqual([]); expect(getVisibleChildren(container)).toEqual( -
{['h', 'ello', 'w', 'orld']}
, +
+
{['h', 'ello', 'w', 'orld']}
+
, ); }); @@ -6073,11 +6120,13 @@ describe('ReactDOMFizzServer', () => { function App() { return ( - - - - - +
+ + + + + +
); } @@ -6107,7 +6156,7 @@ describe('ReactDOMFizzServer', () => { await promiseC; }); - expect(getVisibleChildren(container)).toEqual('Loading...'); + expect(getVisibleChildren(container)).toEqual(
Loading...
); expect(reportedServerErrors.length).toBe(1); expect(reportedServerErrors[0].message).toBe('Oops!'); @@ -6122,7 +6171,7 @@ describe('ReactDOMFizzServer', () => { }, }); await waitForAll([]); - expect(getVisibleChildren(container)).toEqual('Oops!'); + expect(getVisibleChildren(container)).toEqual(
Oops!
); // Because this is rethrown on the client, it is not a recoverable error. expect(reportedClientErrors.length).toBe(0); // It is caught by the error boundary. @@ -8792,4 +8841,904 @@ describe('ReactDOMFizzServer', () => { ), ]); }); + + it('can suspend inside the tag', async () => { + function BlockedOn({value, children}) { + readText(value); + return children; + } + + function App() { + return ( + + + }> + + + + + + +
hello world
+ + + ); + } + + await act(() => { + const {pipe} = renderToPipeableStream(); + pipe(writable); + }); + + expect(getVisibleChildren(document)).toEqual( + + + + + +
hello world
+ + , + ); + + await act(() => { + resolveText('head'); + }); + + expect(getVisibleChildren(document)).toEqual( + + + + + +
hello world
+ + , + ); + + const root = ReactDOMClient.hydrateRoot(document, ); + await waitForAll([]); + + expect(getVisibleChildren(document)).toEqual( + + + + + +
hello world
+ + , + ); + + await act(() => { + root.unmount(); + }); + await waitForAll([]); + + expect(getVisibleChildren(document)).toEqual( + + + + , + ); + }); + + it('can server render Suspense before, after, and around ', async () => { + function BlockedOn({value, children}) { + readText(value); + return children; + } + + function App() { + return ( + <> + +
before
+
+ + + + + + + +
hello world
+ + +
+
+ +
after
+
+ + ); + } + + let content = ''; + writable.on('data', chunk => (content += chunk)); + + let shellReady = false; + await act(() => { + const {pipe} = renderToPipeableStream(, { + onShellReady: () => { + shellReady = true; + }, + }); + pipe(writable); + }); + + // When we Suspend above the body we block the shell because the root HTML scope + // is considered "reconciliation" mode whereby we should stay on the prior view + // (the prior page for instance) rather than showing the fallback (semantically) + expect(shellReady).toBe(true); + expect(content).toBe(''); + + await act(() => { + resolveText('html'); + }); + expect(content).toMatch(/^/); + expect(getVisibleChildren(document)).toEqual( + + + + + +
before
+
hello world
+
after
+ + , + ); + }); + + it('can server render Suspense before, after, and around ', async () => { + function BlockedOn({value, children}) { + readText(value); + return children; + } + + function App() { + return ( + + + + + + + + +
hello world
+ +
+
+ + + + + + ); + } + + let content = ''; + writable.on('data', chunk => (content += chunk)); + + let shellReady = false; + await act(() => { + const {pipe} = renderToPipeableStream(, { + onShellReady() { + shellReady = true; + }, + }); + pipe(writable); + }); + + expect(shellReady).toBe(true); + expect(content).toBe(''); + + await act(() => { + resolveText('body'); + }); + expect(content).toMatch(/^/); + expect(getVisibleChildren(document)).toEqual( + + + + + + + +
hello world
+ + + , + ); + }); + + it('can server render Suspense before, after, and around ', async () => { + function BlockedOn({value, children}) { + readText(value); + return children; + } + + function App() { + return ( + + + + + + + + + + + + + + + + + +
hello world
+ + + ); + } + + let content = ''; + writable.on('data', chunk => (content += chunk)); + + let shellReady = false; + await act(() => { + const {pipe} = renderToPipeableStream(, { + onShellReady() { + shellReady = true; + }, + }); + pipe(writable); + }); + + expect(shellReady).toBe(true); + expect(content).toBe(''); + + await act(() => { + resolveText('head'); + }); + expect(content).toMatch(/^/); + expect(getVisibleChildren(document)).toEqual( + + + + + + + + + +
hello world
+ + , + ); + }); + + it('will render fallback Document when erroring a boundary above the body', async () => { + function Boom() { + throw new Error('Boom!'); + } + + function App() { + return ( + + + hello error + + + }> + + + + hello world + + + + ); + } + + let content = ''; + writable.on('data', chunk => (content += chunk)); + + let shellReady = false; + const errors = []; + await act(() => { + const {pipe} = renderToPipeableStream(, { + onShellReady() { + shellReady = true; + }, + onError(e) { + errors.push(e); + }, + }); + pipe(writable); + }); + + expect(shellReady).toBe(true); + expect(content).toMatch(/^/); + expect(errors).toEqual([new Error('Boom!')]); + expect(getVisibleChildren(document)).toEqual( + + + + hello error + + , + ); + }); + + it('will hoist resources and hositables from a primary tree into the of a client rendered fallback', async () => { + function Boom() { + throw new Error('Boom!'); + } + + function App() { + return ( + <> + + + + + {/* we have to make this a non-hoistable because we don't current emit + hoistables inside fallbacks because we have no way to clean them up + on hydration */} + + + + hello error + + + }> + + + + hello world + + + + + + + ); + } + + let content = ''; + writable.on('data', chunk => (content += chunk)); + + let shellReady = false; + const errors = []; + await act(() => { + const {pipe} = renderToPipeableStream(, { + onShellReady() { + shellReady = true; + }, + onError(e) { + errors.push(e); + }, + }); + pipe(writable); + }); + + expect(shellReady).toBe(true); + expect(content).toMatch(/^/); + expect(errors).toEqual([new Error('Boom!')]); + expect(getVisibleChildren(document)).toEqual( + + + + + + + + + + hello error + + , + ); + }); + + it('Will wait to flush Document chunks until all boundaries which might contain a preamble are errored or resolved', async () => { + let rejectFirst; + const firstPromise = new Promise((_, reject) => { + rejectFirst = reject; + }); + function First({children}) { + use(firstPromise); + return children; + } + + let resolveSecond; + const secondPromise = new Promise(resolve => { + resolveSecond = resolve; + }); + function Second({children}) { + use(secondPromise); + return children; + } + + const hangingPromise = new Promise(() => {}); + function Hanging({children}) { + use(hangingPromise); + return children; + } + + function App() { + return ( + <> + loading...}> + inner loading...}> + + first + + + + loading...}> +
+ + second + +
+
+
+ loading...}> + + third + + +
+ + ); + } + + let content = ''; + writable.on('data', chunk => (content += chunk)); + + let shellReady = false; + const errors = []; + await act(() => { + const {pipe} = renderToPipeableStream(, { + onShellReady() { + shellReady = true; + }, + onError(e) { + errors.push(e); + }, + }); + pipe(writable); + }); + + expect(shellReady).toBe(true); + expect(content).toBe(''); + + await act(() => { + resolveSecond(); + }); + expect(content).toBe(''); + + await act(() => { + rejectFirst('Boom!'); + }); + expect(content.length).toBeGreaterThan(0); + expect(errors).toEqual(['Boom!']); + + expect(getVisibleChildren(container)).toEqual([ + inner loading..., +
+ second +
, +
+ loading... +
, + ]); + }); + + it('Can render a fallback alongside a non-fallback body', async () => { + function Boom() { + throw new Error('Boom!'); + } + + function App() { + return ( + + + + + }> + + + + + + +
fallback body
+ + }> + +
primary body
+ +
+ + ); + } + + let content = ''; + writable.on('data', chunk => (content += chunk)); + + let shellReady = false; + const errors = []; + await act(() => { + const {pipe} = renderToPipeableStream(, { + onShellReady() { + shellReady = true; + }, + onError(e) { + errors.push(e); + }, + }); + pipe(writable); + }); + + expect(shellReady).toBe(true); + expect(content).toMatch(/^/); + expect(errors).toEqual([new Error('Boom!')]); + + expect(getVisibleChildren(document)).toEqual( + + + + + +
primary body
+ + , + ); + }); + + it('Can render a fallback alongside a non-fallback head', async () => { + function Boom() { + throw new Error('Boom!'); + } + + function App() { + return ( + + + + + }> + + + + + +
fallback body
+ + }> + + +
primary body
+ +
+ + ); + } + + let content = ''; + writable.on('data', chunk => (content += chunk)); + + let shellReady = false; + const errors = []; + await act(() => { + const {pipe} = renderToPipeableStream(, { + onShellReady() { + shellReady = true; + }, + onError(e) { + errors.push(e); + }, + }); + pipe(writable); + }); + + expect(shellReady).toBe(true); + expect(content).toMatch(/^/); + expect(errors).toEqual([new Error('Boom!')]); + + expect(getVisibleChildren(document)).toEqual( + + + + + +
fallback body
+ + , + ); + }); + + it('Can render a outside of a containing ', async () => { + function App() { + return ( + <> + + + + hello world + + + + + + + + ); + } + + let content = ''; + writable.on('data', chunk => (content += chunk)); + + let shellReady = false; + await act(() => { + const {pipe} = renderToPipeableStream(, { + onShellReady() { + shellReady = true; + }, + }); + pipe(writable); + }); + + expect(shellReady).toBe(true); + expect(content).toMatch(/^/); + + expect(getVisibleChildren(document)).toEqual( + + + + + + hello world + + , + ); + }); + + it('can render preamble tags in deeply nested indirect component trees', async () => { + function App() { + return ( + + +
+ + ); + } + + let loadLanguage; + const langPromise = new Promise(r => { + loadLanguage = r; + }); + function Html({children}) { + return ( + {children}}> + {children} + + ); + } + function FallbackHtml({children}) { + return {children}; + } + function MainHtml({children}) { + const lang = use(langPromise); + return {children}; + } + + let loadMetadata; + const metadataPromise = new Promise(r => { + loadMetadata = r; + }); + function DocumentMetadata() { + return ( + }> + + + ); + } + function FallbackDocumentMetadata() { + return ( + + + + ); + } + function MainDocumentMetadata() { + const metadata = use(metadataPromise); + return ( + + {metadata.map(m => ( + + ))} + + ); + } + + let loadMainContent; + const mainContentPromise = new Promise(r => { + loadMainContent = r; + }); + function Main() { + return ( + }> + + + ); + } + function Skeleton() { + return ( + +
Skeleton UI
+ + ); + } + function PrimaryContent() { + const content = use(mainContentPromise); + return ( + +
{content}
+ + ); + } + + let content = ''; + writable.on('data', chunk => (content += chunk)); + + let shellReady = false; + const errors = []; + await act(() => { + const {pipe} = renderToPipeableStream(, { + onShellReady() { + shellReady = true; + }, + onError(e) { + errors.push(e); + }, + }); + pipe(writable); + }); + + expect(shellReady).toBe(true); + expect(content).toBe(''); + + await act(() => { + loadLanguage('es'); + }); + expect(content).toBe(''); + + await act(() => { + loadMainContent('This is soooo cool!'); + }); + expect(content).toBe(''); + + await act(() => { + loadMetadata(['author', 'published date']); + }); + expect(content).toMatch(/^/); + + expect(getVisibleChildren(document)).toEqual( + + + + + + +
This is soooo cool!
+ + , + ); + }); + + it('will flush the preamble as soon as a complete preamble is available', async () => { + function BlockedOn({value, children}) { + readText(value); + return children; + } + + function App() { + return ( + <> + +
+ +
+
+ + + +
+ +
+ + +
+ + + + + + + + +
+ +
+
+ + ); + } + + let content = ''; + writable.on('data', chunk => (content += chunk)); + + let shellReady = false; + await act(() => { + const {pipe} = renderToPipeableStream(, { + onShellReady() { + shellReady = true; + }, + }); + pipe(writable); + }); + + expect(shellReady).toBe(true); + expect(content).toBe(''); + + await act(() => { + resolveText('body'); + }); + expect(content).toBe(''); + + await act(() => { + resolveText('head'); + }); + expect(content).toMatch(/^/); + + expect(getVisibleChildren(document)).toEqual( + + + + + + loading before... +
body
+ loading after... + + , + ); + }); }); diff --git a/packages/react-dom/src/__tests__/ReactDOMFizzServerNode-test.js b/packages/react-dom/src/__tests__/ReactDOMFizzServerNode-test.js index f890840f32b32..e97b4a29a7497 100644 --- a/packages/react-dom/src/__tests__/ReactDOMFizzServerNode-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMFizzServerNode-test.js @@ -442,9 +442,11 @@ describe('ReactDOMFizzServerNode', () => { await act(() => { ReactDOMFizzServer.renderToPipeableStream( - - - +
+ + + +
, ).pipe(writable); }); @@ -501,16 +503,20 @@ describe('ReactDOMFizzServerNode', () => { await act(() => { ReactDOMFizzServer.renderToPipeableStream( - - - +
+ + + +
, ).pipe(writable0); ReactDOMFizzServer.renderToPipeableStream( - - - +
+ + + +
, ).pipe(writable1); }); @@ -564,7 +570,7 @@ describe('ReactDOMFizzServerNode', () => { const {writable, output, completed} = getTestWritable(); await act(() => { ReactDOMFizzServer.renderToPipeableStream( - <> +
@@ -575,7 +581,7 @@ describe('ReactDOMFizzServerNode', () => { - , +
, ).pipe(writable); }); diff --git a/packages/react-dom/src/__tests__/ReactDOMFizzStaticBrowser-test.js b/packages/react-dom/src/__tests__/ReactDOMFizzStaticBrowser-test.js index 535bd5d7ba815..f973a5ed4d6e0 100644 --- a/packages/react-dom/src/__tests__/ReactDOMFizzStaticBrowser-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMFizzStaticBrowser-test.js @@ -5,6 +5,7 @@ * LICENSE file in the root directory of this source tree. * * @emails react-core + * @jest-environment ./scripts/jest/ReactDOMServerIntegrationEnvironment */ 'use strict'; @@ -22,6 +23,7 @@ global.ReadableStream = global.TextEncoder = require('util').TextEncoder; global.TextDecoder = require('util').TextDecoder; +let JSDOM; let React; let ReactDOM; let ReactDOMFizzServer; @@ -34,6 +36,7 @@ let act; describe('ReactDOMFizzStaticBrowser', () => { beforeEach(() => { jest.resetModules(); + JSDOM = require('jsdom').JSDOM; Scheduler = require('scheduler'); patchMessageChannel(Scheduler); @@ -49,6 +52,9 @@ describe('ReactDOMFizzStaticBrowser', () => { }); afterEach(() => { + if (typeof global.window.__restoreGlobalScope === 'function') { + global.window.__restoreGlobalScope(); + } document.body.removeChild(container); }); @@ -129,6 +135,40 @@ describe('ReactDOMFizzStaticBrowser', () => { await insertNodesAndExecuteScripts(temp, container, null); } + async function readIntoNewDocument(stream) { + const content = await readContent(stream); + const jsdom = new JSDOM(content, { + runScripts: 'dangerously', + }); + const originalWindow = global.window; + const originalDocument = global.document; + const originalNavigator = global.navigator; + const originalNode = global.Node; + const originalAddEventListener = global.addEventListener; + const originalMutationObserver = global.MutationObserver; + global.window = jsdom.window; + global.document = global.window.document; + global.navigator = global.window.navigator; + global.Node = global.window.Node; + global.addEventListener = global.window.addEventListener; + global.MutationObserver = global.window.MutationObserver; + global.window.__restoreGlobalScope = () => { + global.window = originalWindow; + global.document = originalDocument; + global.navigator = originalNavigator; + global.Node = originalNode; + global.addEventListener = originalAddEventListener; + global.MutationObserver = originalMutationObserver; + }; + } + + async function readIntoCurrentDocument(stream) { + const content = await readContent(stream); + const temp = document.createElement('div'); + temp.innerHTML = content; + await insertNodesAndExecuteScripts(temp, document.body, null); + } + it('should call prerender', async () => { const result = await serverAct(() => ReactDOMFizzStatic.prerender(
hello world
), @@ -293,7 +333,7 @@ describe('ReactDOMFizzStaticBrowser', () => { const prelude = await readContent(result.prelude); expect(prelude).toContain('Loading'); - expect(errors).toEqual(['The operation was aborted.']); + expect(errors).toEqual(['This operation was aborted']); }); // @gate !enableHalt @@ -393,7 +433,7 @@ describe('ReactDOMFizzStaticBrowser', () => { if (gate(flags => flags.enableHalt)) { const {prelude} = await streamPromise; const content = await readContent(prelude); - expect(errors).toEqual(['The operation was aborted.']); + expect(errors).toEqual(['This operation was aborted']); expect(content).toBe(''); } else { let caughtError = null; @@ -402,8 +442,8 @@ describe('ReactDOMFizzStaticBrowser', () => { } catch (error) { caughtError = error; } - expect(caughtError.message).toBe('The operation was aborted.'); - expect(errors).toEqual(['The operation was aborted.']); + expect(caughtError.message).toBe('This operation was aborted'); + expect(errors).toEqual(['This operation was aborted']); } }); @@ -1719,13 +1759,15 @@ describe('ReactDOMFizzStaticBrowser', () => { function App() { return ( - - - - - - - +
+ + + + + + + +
); } @@ -1735,7 +1777,7 @@ describe('ReactDOMFizzStaticBrowser', () => { const postponedState = JSON.stringify(prerendered.postponed); await readIntoContainer(prerendered.prelude); - expect(getVisibleChildren(container)).toEqual('loading...'); + expect(getVisibleChildren(container)).toEqual(
loading...
); isPrerendering = false; @@ -1744,7 +1786,7 @@ describe('ReactDOMFizzStaticBrowser', () => { ); await readIntoContainer(dynamic); - expect(getVisibleChildren(container)).toEqual('hello'); + expect(getVisibleChildren(container)).toEqual(
hello
); }); // @gate enableHalt @@ -1772,9 +1814,11 @@ describe('ReactDOMFizzStaticBrowser', () => { function App() { return ( - - - +
+ + + +
); } @@ -1790,12 +1834,11 @@ describe('ReactDOMFizzStaticBrowser', () => { }); controller.abort(); - const prerendered = await pendingResult; const postponedState = JSON.stringify(prerendered.postponed); await readIntoContainer(prerendered.prelude); - expect(getVisibleChildren(container)).toEqual('Loading A'); + expect(getVisibleChildren(container)).toEqual(
Loading A
); await resolveA(); @@ -1821,7 +1864,7 @@ describe('ReactDOMFizzStaticBrowser', () => { const postponedState2 = JSON.stringify(prerendered2.postponed); await readIntoContainer(prerendered2.prelude); - expect(getVisibleChildren(container)).toEqual('Loading B'); + expect(getVisibleChildren(container)).toEqual(
Loading B
); await resolveB(); @@ -1830,6 +1873,344 @@ describe('ReactDOMFizzStaticBrowser', () => { ); await readIntoContainer(dynamic); - expect(getVisibleChildren(container)).toEqual('Hello'); + expect(getVisibleChildren(container)).toEqual(
Hello
); + }); + + // @gate enableHalt + it('can prerender a preamble', async () => { + const errors = []; + + let resolveA; + const promiseA = new Promise(r => (resolveA = r)); + let resolveB; + const promiseB = new Promise(r => (resolveB = r)); + + async function ComponentA() { + await promiseA; + return ( + + + + ); + } + + async function ComponentB() { + await promiseB; + return 'Hello'; + } + + function App() { + return ( + + + + + + + + + + ); + } + + const controller = new AbortController(); + let pendingResult; + await serverAct(async () => { + pendingResult = ReactDOMFizzStatic.prerender(, { + signal: controller.signal, + onError(x) { + errors.push(x.message); + }, + }); + }); + + controller.abort(); + + const prerendered = await pendingResult; + const postponedState = JSON.stringify(prerendered.postponed); + + await readIntoNewDocument(prerendered.prelude); + expect(getVisibleChildren(document)).toEqual( + + + Loading A + , + ); + + await resolveA(); + + expect(prerendered.postponed).not.toBe(null); + + const controller2 = new AbortController(); + await serverAct(async () => { + pendingResult = ReactDOMFizzStatic.resumeAndPrerender( + , + JSON.parse(postponedState), + { + signal: controller2.signal, + onError(x) { + errors.push(x.message); + }, + }, + ); + }); + + controller2.abort(); + + const prerendered2 = await pendingResult; + const postponedState2 = JSON.stringify(prerendered2.postponed); + + await readIntoCurrentDocument(prerendered2.prelude); + expect(getVisibleChildren(document)).toEqual( + + + Loading B + , + ); + + await resolveB(); + + const dynamic = await serverAct(() => + ReactDOMFizzServer.resume(, JSON.parse(postponedState2)), + ); + + await readIntoCurrentDocument(dynamic); + expect(getVisibleChildren(document)).toEqual( + + + Hello + , + ); + }); + + it('can suspend inside tag', async () => { + const promise = new Promise(() => {}); + + function App() { + return ( + + + }> + + + + +
hello
+ + + ); + } + + function Metadata() { + React.use(promise); + return ; + } + + const controller = new AbortController(); + let pendingResult; + const errors = []; + await serverAct(() => { + pendingResult = ReactDOMFizzStatic.prerender(, { + signal: controller.signal, + onError: e => { + errors.push(e.message); + }, + }); + }); + + controller.abort(new Error('boom')); + + const prerendered = await pendingResult; + + await readIntoNewDocument(prerendered.prelude); + expect(getVisibleChildren(document)).toEqual( + + + + + +
hello
+ + , + ); + + expect(errors).toEqual(['boom']); + }); + + // @gate enableHalt + it('will render fallback Document when erroring a boundary above the body', async () => { + let isPrerendering = true; + const promise = new Promise(() => {}); + + function Boom() { + if (isPrerendering) { + React.use(promise); + } + throw new Error('Boom!'); + } + + function App() { + return ( + + + hello error + + + }> + + + + hello world + + + + ); + } + + const controller = new AbortController(); + let pendingResult; + const errors = []; + await serverAct(() => { + pendingResult = ReactDOMFizzStatic.prerender(, { + signal: controller.signal, + onError: e => { + errors.push(e.message); + }, + }); + }); + + controller.abort(); + + const prerendered = await pendingResult; + + expect(errors).toEqual(['This operation was aborted']); + const content = await readContent(prerendered.prelude); + expect(content).toBe(''); + + isPrerendering = false; + const postponedState = JSON.stringify(prerendered.postponed); + + const resumeErrors = []; + const dynamic = await serverAct(() => + ReactDOMFizzServer.resume(, JSON.parse(postponedState), { + onError: e => { + resumeErrors.push(e.message); + }, + }), + ); + + expect(resumeErrors).toEqual(['Boom!']); + await readIntoNewDocument(dynamic); + + expect(getVisibleChildren(document)).toEqual( + + + + hello error + + , + ); + }); + + // @gate enableHalt + it('can omit a preamble with an empty shell if no preamble is ready when prerendering finishes', async () => { + const errors = []; + + let resolveA; + const promiseA = new Promise(r => (resolveA = r)); + let resolveB; + const promiseB = new Promise(r => (resolveB = r)); + + async function ComponentA() { + await promiseA; + return ( + + + + ); + } + + async function ComponentB() { + await promiseB; + return 'Hello'; + } + + function App() { + return ( + + + + + + + + ); + } + + const controller = new AbortController(); + let pendingResult; + await serverAct(async () => { + pendingResult = ReactDOMFizzStatic.prerender(, { + signal: controller.signal, + onError(x) { + errors.push(x.message); + }, + }); + }); + + controller.abort(); + + const prerendered = await pendingResult; + const postponedState = JSON.stringify(prerendered.postponed); + + const content = await readContent(prerendered.prelude); + expect(content).toBe(''); + + await resolveA(); + + expect(prerendered.postponed).not.toBe(null); + + const controller2 = new AbortController(); + await serverAct(async () => { + pendingResult = ReactDOMFizzStatic.resumeAndPrerender( + , + JSON.parse(postponedState), + { + signal: controller2.signal, + onError(x) { + errors.push(x.message); + }, + }, + ); + }); + + controller2.abort(); + + const prerendered2 = await pendingResult; + const postponedState2 = JSON.stringify(prerendered2.postponed); + + await readIntoNewDocument(prerendered2.prelude); + expect(getVisibleChildren(document)).toEqual( + + + Loading B + , + ); + + await resolveB(); + + const dynamic = await serverAct(() => + ReactDOMFizzServer.resume(, JSON.parse(postponedState2)), + ); + + await readIntoCurrentDocument(dynamic); + expect(getVisibleChildren(document)).toEqual( + + + Hello + , + ); }); }); diff --git a/packages/react-markup/src/ReactFizzConfigMarkup.js b/packages/react-markup/src/ReactFizzConfigMarkup.js index 048e54933930d..99e9921c8190a 100644 --- a/packages/react-markup/src/ReactFizzConfigMarkup.js +++ b/packages/react-markup/src/ReactFizzConfigMarkup.js @@ -12,6 +12,7 @@ import type {ReactNodeList} from 'shared/ReactTypes'; import type { RenderState, ResumableState, + PreambleState, HoistableState, FormatContext, } from 'react-dom-bindings/src/server/ReactFizzConfigDOM'; @@ -42,6 +43,7 @@ export type { RenderState, ResumableState, HoistableState, + PreambleState, FormatContext, } from 'react-dom-bindings/src/server/ReactFizzConfigDOM'; @@ -64,8 +66,10 @@ export { createRootFormatContext, createRenderState, createResumableState, + createPreambleState, createHoistableState, - writePreamble, + writePreambleStart, + writePreambleEnd, writeHoistables, writePostamble, hoistHoistables, @@ -73,6 +77,10 @@ export { completeResumableState, emitEarlyPreloads, doctypeChunk, + canHavePreamble, + hoistPreambleState, + isPreambleReady, + isPreambleContext, } from 'react-dom-bindings/src/server/ReactFizzConfigDOM'; import escapeTextForBrowser from 'react-dom-bindings/src/server/escapeTextForBrowser'; @@ -83,6 +91,7 @@ export function pushStartInstance( props: Object, resumableState: ResumableState, renderState: RenderState, + preambleState: null | PreambleState, hoistableState: null | HoistableState, formatContext: FormatContext, textEmbedded: boolean, @@ -113,6 +122,7 @@ export function pushStartInstance( props, resumableState, renderState, + preambleState, hoistableState, formatContext, textEmbedded, diff --git a/packages/react-noop-renderer/src/ReactNoopServer.js b/packages/react-noop-renderer/src/ReactNoopServer.js index 4e2832e4f2bfe..964cf4509df6d 100644 --- a/packages/react-noop-renderer/src/ReactNoopServer.js +++ b/packages/react-noop-renderer/src/ReactNoopServer.js @@ -53,6 +53,7 @@ type Destination = { type RenderState = null; type HoistableState = null; +type PreambleState = null; const POP = Buffer.from('/', 'utf8'); @@ -264,7 +265,8 @@ const ReactNoopServer = ReactFizzServer({ boundary.status = 'client-render'; }, - writePreamble() {}, + writePreambleStart() {}, + writePreambleEnd() {}, writeHoistables() {}, writeHoistablesForBoundary() {}, writePostamble() {}, @@ -273,6 +275,19 @@ const ReactNoopServer = ReactFizzServer({ return null; }, emitEarlyPreloads() {}, + createPreambleState(): PreambleState { + return null; + }, + canHavePreamble() { + return false; + }, + hoistPreambleState() {}, + isPreambleReady() { + return true; + }, + isPreambleContext() { + return false; + }, }); type Options = { diff --git a/packages/react-server/src/ReactFizzServer.js b/packages/react-server/src/ReactFizzServer.js index fd2b2f74a989a..4c02d06e600a9 100644 --- a/packages/react-server/src/ReactFizzServer.js +++ b/packages/react-server/src/ReactFizzServer.js @@ -27,6 +27,7 @@ import type {LazyComponent as LazyComponentType} from 'react/src/ReactLazy'; import type { RenderState, ResumableState, + PreambleState, FormatContext, HoistableState, } from './ReactFizzConfig'; @@ -68,10 +69,12 @@ import { pushSegmentFinale, getChildFormatContext, writeHoistables, - writePreamble, + writePreambleStart, + writePreambleEnd, writePostamble, hoistHoistables, createHoistableState, + createPreambleState, supportsRequestStorage, requestStorage, pushFormStateMarkerIsMatching, @@ -80,6 +83,10 @@ import { completeResumableState, emitEarlyPreloads, bindToConsole, + canHavePreamble, + hoistPreambleState, + isPreambleReady, + isPreambleContext, } from './ReactFizzConfig'; import { constructClassInstance, @@ -222,6 +229,8 @@ type SuspenseBoundary = { fallbackAbortableTasks: Set, // used to cancel task on the fallback if the boundary completes or gets canceled. contentState: HoistableState, fallbackState: HoistableState, + contentPreamble: null | Preamble, + fallbackPreamble: null | Preamble, trackedContentKeyPath: null | KeyNode, // used to track the path for replay nodes trackedFallbackNode: null | ReplayNode, // used to track the fallback for replay nodes errorDigest: ?string, // the error hash if it errors @@ -238,6 +247,7 @@ type RenderTask = { ping: () => void, blockedBoundary: Root | SuspenseBoundary, blockedSegment: Segment, // the segment we'll write to + blockedPreamble: null | Preamble, hoistableState: null | HoistableState, // Boundary state we'll mutate while rendering. This may not equal the state of the blockedBoundary abortSet: Set, // the abortable set that this task belongs to keyPath: Root | KeyNode, // the path of all parent keys currently rendering @@ -268,6 +278,7 @@ type ReplayTask = { ping: () => void, blockedBoundary: Root | SuspenseBoundary, blockedSegment: null, // we don't write to anything when we replay + blockedPreamble: null, hoistableState: null | HoistableState, // Boundary state we'll mutate while rendering. This may not equal the state of the blockedBoundary abortSet: Set, // the abortable set that this task belongs to keyPath: Root | KeyNode, // the path of all parent keys currently rendering @@ -302,6 +313,7 @@ type Segment = { +index: number, // the index within the parent's chunks or 0 at the root +chunks: Array, +children: Array, + +preambleChildren: Array, // The context that this segment was created in. parentFormatContext: FormatContext, // If this segment represents a fallback, this is the content that will replace that fallback. @@ -330,6 +342,7 @@ export opaque type Request = { allPendingTasks: number, // when it reaches zero, we can close the connection. pendingRootTasks: number, // when this reaches zero, we've finished at least the root boundary. completedRootSegment: null | Segment, // Completed but not yet flushed root segments. + completedPreambleSegments: null | Array>, // contains the ready-to-flush segments that make up the preamble abortableTasks: Set, pingedTasks: Array, // High priority tasks that should be worked on first. // Queues to flush in order of priority @@ -361,6 +374,8 @@ export opaque type Request = { didWarnForKey?: null | WeakSet, }; +type Preamble = PreambleState; + // This is a default heuristic for how to split up the HTML content into progressive // loading. Our goal is to be able to display additional new content about every 500ms. // Faster than that is unnecessary and should be throttled on the client. It also @@ -426,6 +441,7 @@ function RequestInstance( this.allPendingTasks = 0; this.pendingRootTasks = 0; this.completedRootSegment = null; + this.completedPreambleSegments = null; this.abortableTasks = abortSet; this.pingedTasks = pingedTasks; this.clientRenderedBoundaries = ([]: Array); @@ -493,6 +509,7 @@ export function createRequest( null, rootSegment, null, + null, request.abortableTasks, null, rootFormatContext, @@ -594,6 +611,7 @@ export function resumeRequest( null, rootSegment, null, + null, request.abortableTasks, null, postponedState.rootFormatContext, @@ -695,6 +713,8 @@ function pingTask(request: Request, task: Task): void { function createSuspenseBoundary( request: Request, fallbackAbortableTasks: Set, + contentPreamble: null | Preamble, + fallbackPreamble: null | Preamble, ): SuspenseBoundary { const boundary: SuspenseBoundary = { status: PENDING, @@ -707,6 +727,8 @@ function createSuspenseBoundary( errorDigest: null, contentState: createHoistableState(), fallbackState: createHoistableState(), + contentPreamble, + fallbackPreamble, trackedContentKeyPath: null, trackedFallbackNode: null, }; @@ -726,6 +748,7 @@ function createRenderTask( childIndex: number, blockedBoundary: Root | SuspenseBoundary, blockedSegment: Segment, + blockedPreamble: null | Preamble, hoistableState: null | HoistableState, abortSet: Set, keyPath: Root | KeyNode, @@ -750,6 +773,7 @@ function createRenderTask( ping: () => pingTask(request, task), blockedBoundary, blockedSegment, + blockedPreamble, hoistableState, abortSet, keyPath, @@ -802,6 +826,7 @@ function createReplayTask( ping: () => pingTask(request, task), blockedBoundary, blockedSegment: null, + blockedPreamble: null, hoistableState, abortSet, keyPath, @@ -832,11 +857,12 @@ function createPendingSegment( ): Segment { return { status: PENDING, + parentFlushed: false, id: -1, // lazily assigned later index, - parentFlushed: false, chunks: [], children: [], + preambleChildren: [], parentFormatContext, boundary, lastPushedText, @@ -1116,6 +1142,7 @@ function renderSuspenseBoundary( const prevKeyPath = task.keyPath; const parentBoundary = task.blockedBoundary; + const parentPreamble = task.blockedPreamble; const parentHoistableState = task.hoistableState; const parentSegment = task.blockedSegment; @@ -1127,10 +1154,21 @@ function renderSuspenseBoundary( const content: ReactNodeList = props.children; const fallbackAbortSet: Set = new Set(); - const newBoundary = createSuspenseBoundary(request, fallbackAbortSet); + let newBoundary: SuspenseBoundary; + if (canHavePreamble(task.formatContext)) { + newBoundary = createSuspenseBoundary( + request, + fallbackAbortSet, + createPreambleState(), + createPreambleState(), + ); + } else { + newBoundary = createSuspenseBoundary(request, fallbackAbortSet, null, null); + } if (request.trackedPostpones !== null) { newBoundary.trackedContentKeyPath = keyPath; } + const insertionIndex = parentSegment.chunks.length; // The children of the boundary segment is actually the fallback. const boundarySegment = createPendingSegment( @@ -1179,6 +1217,7 @@ function renderSuspenseBoundary( newBoundary.trackedFallbackNode = fallbackReplayNode; task.blockedSegment = boundarySegment; + task.blockedPreamble = newBoundary.fallbackPreamble; task.keyPath = fallbackKeyPath; boundarySegment.status = RENDERING; try { @@ -1199,6 +1238,7 @@ function renderSuspenseBoundary( throw thrownValue; } finally { task.blockedSegment = parentSegment; + task.blockedPreamble = parentPreamble; task.keyPath = prevKeyPath; } @@ -1211,6 +1251,7 @@ function renderSuspenseBoundary( -1, newBoundary, contentRootSegment, + newBoundary.contentPreamble, newBoundary.contentState, task.abortSet, keyPath, @@ -1238,6 +1279,7 @@ function renderSuspenseBoundary( // context switching. We just need to temporarily switch which boundary and which segment // we're writing to. If something suspends, it'll spawn new suspended task with that context. task.blockedBoundary = newBoundary; + task.blockedPreamble = newBoundary.contentPreamble; task.hoistableState = newBoundary.contentState; task.blockedSegment = contentRootSegment; task.keyPath = keyPath; @@ -1259,6 +1301,13 @@ function renderSuspenseBoundary( // Therefore we won't need the fallback. We early return so that we don't have to create // the fallback. newBoundary.status = COMPLETED; + if (request.pendingRootTasks === 0 && task.blockedPreamble) { + // The root is complete and this boundary may contribute part of the preamble. + // We eagerly attempt to prepare the preamble here because we expect most requests + // to have few boundaries which contribute preambles and it allow us to do this + // preparation work during the work phase rather than the when flushing. + preparePreamble(request); + } return; } } catch (thrownValue: mixed) { @@ -1312,6 +1361,7 @@ function renderSuspenseBoundary( // We do need to fallthrough to create the fallback though. } finally { task.blockedBoundary = parentBoundary; + task.blockedPreamble = parentPreamble; task.hoistableState = parentHoistableState; task.blockedSegment = parentSegment; task.keyPath = prevKeyPath; @@ -1327,6 +1377,7 @@ function renderSuspenseBoundary( -1, parentBoundary, boundarySegment, + newBoundary.fallbackPreamble, newBoundary.fallbackState, fallbackAbortSet, fallbackKeyPath, @@ -1366,7 +1417,22 @@ function replaySuspenseBoundary( const fallback: ReactNodeList = props.fallback; const fallbackAbortSet: Set = new Set(); - const resumedBoundary = createSuspenseBoundary(request, fallbackAbortSet); + let resumedBoundary: SuspenseBoundary; + if (canHavePreamble(task.formatContext)) { + resumedBoundary = createSuspenseBoundary( + request, + fallbackAbortSet, + createPreambleState(), + createPreambleState(), + ); + } else { + resumedBoundary = createSuspenseBoundary( + request, + fallbackAbortSet, + null, + null, + ); + } resumedBoundary.parentFlushed = true; // We restore the same id of this boundary as was used during prerender. resumedBoundary.rootSegmentID = id; @@ -1481,12 +1547,52 @@ function replaySuspenseBoundary( !disableLegacyContext ? task.legacyContext : emptyContextObject, __DEV__ && enableOwnerStacks ? task.debugTask : null, ); + pushComponentStack(suspendedFallbackTask); // TODO: This should be queued at a separate lower priority queue so that we only work // on preparing fallbacks if we don't have any more main content to task on. request.pingedTasks.push(suspendedFallbackTask); } +function renderPreamble( + request: Request, + task: Task, + blockedSegment: Segment, + node: ReactNodeList, +): void { + const preambleSegment = createPendingSegment( + request, + 0, + null, + task.formatContext, + false, + false, + ); + blockedSegment.preambleChildren.push(preambleSegment); + // @TODO we can just attempt to render in the current task rather than spawning a new one + const preambleTask = createRenderTask( + request, + null, + node, + -1, + task.blockedBoundary, + preambleSegment, + task.blockedPreamble, + task.hoistableState, + request.abortableTasks, + task.keyPath, + task.formatContext, + task.context, + task.treeContext, + task.componentStack, + task.isFallback, + !disableLegacyContext ? task.legacyContext : emptyContextObject, + __DEV__ && enableOwnerStacks ? task.debugTask : null, + ); + pushComponentStack(preambleTask); + request.pingedTasks.push(preambleTask); +} + function renderHostElement( request: Request, task: Task, @@ -1513,12 +1619,14 @@ function renderHostElement( task.keyPath = prevKeyPath; } else { // Render + // RenderTask always has a preambleState const children = pushStartInstance( segment.chunks, type, props, request.resumableState, request.renderState, + task.blockedPreamble, task.hoistableState, task.formatContext, segment.lastPushedText, @@ -1527,12 +1635,20 @@ function renderHostElement( segment.lastPushedText = false; const prevContext = task.formatContext; const prevKeyPath = task.keyPath; - task.formatContext = getChildFormatContext(prevContext, type, props); task.keyPath = keyPath; - // We use the non-destructive form because if something suspends, we still - // need to pop back up and finish this subtree of HTML. - renderNode(request, task, children, -1); + const newContext = (task.formatContext = getChildFormatContext( + prevContext, + type, + props, + )); + if (isPreambleContext(newContext)) { + renderPreamble(request, task, segment, children); + } else { + // We use the non-destructive form because if something suspends, we still + // need to pop back up and finish this subtree of HTML. + renderNode(request, task, children, -1); + } // We expect that errors will fatal the whole task and that we don't need // the correct context. Therefore this is not in a finally. @@ -3356,6 +3472,7 @@ function spawnNewSuspendedRenderTask( task.childIndex, task.blockedBoundary, newSegment, + task.blockedPreamble, task.hoistableState, task.abortSet, task.keyPath, @@ -3712,6 +3829,18 @@ function erroredTask( // We reuse the same queue for errors. request.clientRenderedBoundaries.push(boundary); } + + if ( + request.pendingRootTasks === 0 && + request.trackedPostpones === null && + boundary.contentPreamble !== null + ) { + // The root is complete and this boundary may contribute part of the preamble. + // We eagerly attempt to prepare the preamble here because we expect most requests + // to have few boundaries which contribute preambles and it allow us to do this + // preparation work during the work phase rather than the when flushing. + preparePreamble(request); + } } } @@ -3742,7 +3871,12 @@ function abortRemainingSuspenseBoundary( errorInfo: ThrownInfo, wasAborted: boolean, ): void { - const resumedBoundary = createSuspenseBoundary(request, new Set()); + const resumedBoundary = createSuspenseBoundary( + request, + new Set(), + null, + null, + ); resumedBoundary.parentFlushed = true; // We restore the same id of this boundary as was used during prerender. resumedBoundary.rootSegmentID = rootSegmentID; @@ -4038,6 +4172,13 @@ function completeShell(request: Request) { const shellComplete = true; safelyEmitEarlyPreloads(request, shellComplete); } + if (request.trackedPostpones === null) { + // When the shell is complete it will be possible to flush. We attempt to prepre + // the Preamble here in case it is ready for flushing. + // We exclude prerenders because these cannot flush until after completeAll has been called + preparePreamble(request); + } + // We have completed the shell so the shell can't error anymore. request.onShellError = noop; const onShellReady = request.onShellReady; @@ -4060,6 +4201,11 @@ function completeAll(request: Request) { request.completedRootSegment === null || request.completedRootSegment.status !== POSTPONED; safelyEmitEarlyPreloads(request, shellComplete); + + // When the shell is complete it will be possible to flush. We attempt to prepre + // the Preamble here in case it is ready for flushing + preparePreamble(request); + const onAllReady = request.onAllReady; onAllReady(); } @@ -4137,6 +4283,18 @@ function finishedTask( if (boundary.status === COMPLETED) { boundary.fallbackAbortableTasks.forEach(abortTaskSoft, request); boundary.fallbackAbortableTasks.clear(); + + if ( + request.pendingRootTasks === 0 && + request.trackedPostpones === null && + boundary.contentPreamble !== null + ) { + // The root is complete and this boundary may contribute part of the preamble. + // We eagerly attempt to prepare the preamble here because we expect most requests + // to have few boundaries which contribute preambles and it allow us to do this + // preparation work during the work phase rather than the when flushing. + preparePreamble(request); + } } } else { if (segment !== null && segment.parentFlushed) { @@ -4477,19 +4635,137 @@ export function performWork(request: Request): void { } } +function preparePreambleFromSubtree( + request: Request, + segment: Segment, + collectedPreambleSegments: Array>, +): boolean { + if (segment.preambleChildren.length) { + collectedPreambleSegments.push(segment.preambleChildren); + } + let pendingPreambles = false; + for (let i = 0; i < segment.children.length; i++) { + const nextSegment = segment.children[i]; + pendingPreambles = + preparePreambleFromSegment( + request, + nextSegment, + collectedPreambleSegments, + ) || pendingPreambles; + } + return pendingPreambles; +} + +function preparePreambleFromSegment( + request: Request, + segment: Segment, + collectedPreambleSegments: Array>, +): boolean { + const boundary = segment.boundary; + if (boundary === null) { + // This segment is not a boundary, let's check it's children + return preparePreambleFromSubtree( + request, + segment, + collectedPreambleSegments, + ); + } + + const preamble = boundary.contentPreamble; + const fallbackPreamble = boundary.fallbackPreamble; + + if (preamble === null || fallbackPreamble === null) { + // This boundary cannot have a preamble so it can't block the flushing of + // the preamble. + return false; + } + + const status = boundary.status; + + switch (status) { + case COMPLETED: { + // This boundary is complete. It might have inner boundaries which are pending + // and able to provide a preamble so we have to check it's children + hoistPreambleState(request.renderState, preamble); + const boundaryRootSegment = boundary.completedSegments[0]; + if (!boundaryRootSegment) { + // Using the same error from flushSegment to avoid making a new one since conceptually the problem is still the same + throw new Error( + 'A previously unvisited boundary must have exactly one root segment. This is a bug in React.', + ); + } + return preparePreambleFromSubtree( + request, + boundaryRootSegment, + collectedPreambleSegments, + ); + } + case POSTPONED: { + // This segment is postponed. When prerendering we consider this pending still because + // it can resume. If we're rendering then this is equivalent to errored. + if (request.trackedPostpones !== null) { + // This boundary won't contribute a preamble to the current prerender + return true; + } + // Expected fallthrough + } + case CLIENT_RENDERED: { + if (segment.status === COMPLETED) { + // This boundary is errored so if it contains a preamble we should include it + hoistPreambleState(request.renderState, fallbackPreamble); + return preparePreambleFromSubtree( + request, + segment, + collectedPreambleSegments, + ); + } + // Expected fallthrough + } + default: + // This boundary is still pending and might contain a preamble + return true; + } +} + +function preparePreamble(request: Request) { + if ( + request.completedRootSegment && + request.completedPreambleSegments === null + ) { + const collectedPreambleSegments: Array> = []; + const hasPendingPreambles = preparePreambleFromSegment( + request, + request.completedRootSegment, + collectedPreambleSegments, + ); + if (isPreambleReady(request.renderState, hasPendingPreambles)) { + request.completedPreambleSegments = collectedPreambleSegments; + } + } +} + function flushPreamble( request: Request, destination: Destination, rootSegment: Segment, + preambleSegments: Array>, ) { + // The preamble is ready. const willFlushAllSegments = request.allPendingTasks === 0 && request.trackedPostpones === null; - writePreamble( + writePreambleStart( destination, request.resumableState, request.renderState, willFlushAllSegments, ); + for (let i = 0; i < preambleSegments.length; i++) { + const segments = preambleSegments[i]; + for (let j = 0; j < segments.length; j++) { + flushSegment(request, destination, segments[j], null); + } + } + writePreambleEnd(destination, request.renderState); } function flushSubtree( @@ -4825,11 +5101,21 @@ function flushCompletedQueues( const completedRootSegment = request.completedRootSegment; if (completedRootSegment !== null) { if (completedRootSegment.status === POSTPONED) { - // We postponed the root, so we write nothing. return; } - flushPreamble(request, destination, completedRootSegment); + const completedPreambleSegments = request.completedPreambleSegments; + if (completedPreambleSegments === null) { + // The preamble isn't ready yet even though the root is so we omit flushing + return; + } + + flushPreamble( + request, + destination, + completedRootSegment, + completedPreambleSegments, + ); flushSegment(request, destination, completedRootSegment, null); request.completedRootSegment = null; writeCompletedRoot(destination, request.renderState); @@ -5147,13 +5433,21 @@ export function getPostponedState(request: Request): null | PostponedState { request.trackedPostpones = null; return null; } + let replaySlots: ResumeSlots; if ( request.completedRootSegment !== null && - request.completedRootSegment.status === POSTPONED + // The Root postponed + (request.completedRootSegment.status === POSTPONED || + // Or the Preamble was not available + request.completedPreambleSegments === null) ) { - // We postponed the root so we didn't flush anything. + // This is necessary for the pending preamble case and is idempotent for the + // postponed root case + replaySlots = request.completedRootSegment.id; + // We either postponed the root or we did not have a preamble to flush resetResumableState(request.resumableState, request.renderState); } else { + replaySlots = trackedPostpones.rootSlots; completeResumableState(request.resumableState); } return { @@ -5162,6 +5456,6 @@ export function getPostponedState(request: Request): null | PostponedState { progressiveChunkSize: request.progressiveChunkSize, resumableState: request.resumableState, replayNodes: trackedPostpones.rootNodes, - replaySlots: trackedPostpones.rootSlots, + replaySlots, }; } diff --git a/packages/react-server/src/forks/ReactFizzConfig.custom.js b/packages/react-server/src/forks/ReactFizzConfig.custom.js index be14f349aa286..d386d04e39819 100644 --- a/packages/react-server/src/forks/ReactFizzConfig.custom.js +++ b/packages/react-server/src/forks/ReactFizzConfig.custom.js @@ -31,6 +31,7 @@ export opaque type Destination = mixed; export opaque type RenderState = mixed; export opaque type HoistableState = mixed; export opaque type ResumableState = mixed; +export opaque type PreambleState = mixed; export opaque type FormatContext = mixed; export opaque type HeadersDescriptor = mixed; export type {TransitionStatus}; @@ -79,11 +80,17 @@ export const writeCompletedBoundaryInstruction = export const writeClientRenderBoundaryInstruction = $$$config.writeClientRenderBoundaryInstruction; export const NotPendingTransition = $$$config.NotPendingTransition; +export const createPreambleState = $$$config.createPreambleState; +export const canHavePreamble = $$$config.canHavePreamble; +export const isPreambleContext = $$$config.isPreambleContext; +export const isPreambleReady = $$$config.isPreambleReady; +export const hoistPreambleState = $$$config.hoistPreambleState; // ------------------------- // Resources // ------------------------- -export const writePreamble = $$$config.writePreamble; +export const writePreambleStart = $$$config.writePreambleStart; +export const writePreambleEnd = $$$config.writePreambleEnd; export const writeHoistables = $$$config.writeHoistables; export const writeHoistablesForBoundary = $$$config.writeHoistablesForBoundary; export const writePostamble = $$$config.writePostamble; diff --git a/scripts/error-codes/codes.json b/scripts/error-codes/codes.json index 8109e2431fd31..8fa3d190ecb70 100644 --- a/scripts/error-codes/codes.json +++ b/scripts/error-codes/codes.json @@ -529,5 +529,6 @@ "541": "Compared context values must be arrays", "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.", "543": "Expected a ResourceEffectUpdate to be pushed together with ResourceEffectIdentity. This is a bug in React.", - "544": "Found a pair with an auto name. This is a bug in React." + "544": "Found a pair with an auto name. This is a bug in React.", + "545": "The %s tag may only be rendered once." }