From b25bcd460f98a0b89e5a7199a6c88112163d961f Mon Sep 17 00:00:00 2001 From: Josh Story Date: Fri, 17 Jan 2025 10:54:11 -0800 Subject: [PATCH] [Fizz] Support Suspense boundaries anywhere (#32069) Suspense is meant to be composable but there has been a lonstanding limitation with using Suspense above the `` 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 `` 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 `` 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((
hello world
)) ``` will produce an HTML document like this ```html <-- this is the comment Node representing the outermost Suspense
hello world
<$--/$--> ``` 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). --- .../src/server/ReactFizzConfigDOM.js | 206 ++- .../src/server/ReactFizzConfigDOMLegacy.js | 16 +- .../ReactDOMFizzDeferredValue-test.js | 8 +- .../src/__tests__/ReactDOMFizzServer-test.js | 1187 +++++++++++++++-- .../__tests__/ReactDOMFizzServerNode-test.js | 28 +- .../ReactDOMFizzStaticBrowser-test.js | 421 +++++- .../react-markup/src/ReactFizzConfigMarkup.js | 12 +- .../src/ReactNoopServer.js | 17 +- packages/react-server/src/ReactFizzServer.js | 324 ++++- .../src/forks/ReactFizzConfig.custom.js | 9 +- scripts/error-codes/codes.json | 3 +- 11 files changed, 2022 insertions(+), 209 deletions(-) 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." }