diff --git a/packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js b/packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js index a93c32a947f..f55e536c0c1 100644 --- a/packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js +++ b/packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js @@ -150,6 +150,7 @@ export type RenderState = { placeholderPrefix: PrecomputedChunk, segmentPrefix: PrecomputedChunk, boundaryPrefix: PrecomputedChunk, + listPrefix: PrecomputedChunk, // inline script streaming format, unused if using external runtime / data startInlineScript: PrecomputedChunk, @@ -491,6 +492,7 @@ export function createRenderState( placeholderPrefix: stringToPrecomputedChunk(idPrefix + 'P:'), segmentPrefix: stringToPrecomputedChunk(idPrefix + 'S:'), boundaryPrefix: stringToPrecomputedChunk(idPrefix + 'B:'), + listPrefix: stringToPrecomputedChunk(idPrefix + 'L:'), startInlineScript: inlineScriptWithNonce, startInlineStyle: inlineStyleWithNonce, preamble: createPreambleState(), @@ -4573,6 +4575,100 @@ export function pushEndActivityBoundary( target.push(endActivityBoundary); } +// SuspenseList with hidden tails are encoded as comments. +const startSuspenseListBoundary = stringToPrecomputedChunk(''); +const endSuspenseListBoundary = stringToPrecomputedChunk(''); + +export function pushStartSuspenseListBoundary( + target: Array, + renderState: RenderState, +): void { + target.push(startSuspenseListBoundary); +} + +export function pushEndSuspenseListBoundary( + target: Array, + renderState: RenderState, +): void { + target.push(endSuspenseListBoundary); +} + +const pendingSuspenseList1 = stringToPrecomputedChunk( + ''); + +const clientRenderedSuspenseListError1 = stringToPrecomputedChunk(''); + +export function writePendingSuspenseListMarker( + destination: Destination, + renderState: RenderState, + id: number, +): boolean { + writeChunk(destination, pendingSuspenseList1); + + if (id === null) { + throw new Error( + 'An ID must have been assigned before we can complete the boundary.', + ); + } + + writeChunk(destination, renderState.listPrefix); + writeChunk(destination, stringToChunk(id.toString(16))); + return writeChunkAndReturn(destination, pendingSuspenseList2); +} +export function writeClientRenderedSuspenseListMarker( + destination: Destination, + renderState: RenderState, + errorDigest: ?string, + errorMessage: ?string, + errorStack: ?string, + errorComponentStack: ?string, +): boolean { + writeChunk(destination, clientRenderedSuspenseListError1); + if (errorDigest) { + writeChunk(destination, clientRenderedSuspenseListError1A); + writeChunk(destination, stringToChunk(escapeTextForBrowser(errorDigest))); + writeChunk(destination, clientRenderedSuspenseListErrorAttrInterstitial); + } + if (__DEV__) { + if (errorMessage) { + writeChunk(destination, clientRenderedSuspenseListError1B); + writeChunk( + destination, + stringToChunk(escapeTextForBrowser(errorMessage)), + ); + writeChunk(destination, clientRenderedSuspenseListErrorAttrInterstitial); + } + if (errorStack) { + writeChunk(destination, clientRenderedSuspenseListError1C); + writeChunk(destination, stringToChunk(escapeTextForBrowser(errorStack))); + writeChunk(destination, clientRenderedSuspenseListErrorAttrInterstitial); + } + if (errorComponentStack) { + writeChunk(destination, clientRenderedSuspenseListError1D); + writeChunk( + destination, + stringToChunk(escapeTextForBrowser(errorComponentStack)), + ); + writeChunk(destination, clientRenderedSuspenseListErrorAttrInterstitial); + } + } + return writeChunkAndReturn(destination, clientRenderedSuspenseListError2); +} + // Suspense boundaries are encoded as comments. const startCompletedSuspenseBoundary = stringToPrecomputedChunk(''); const startPendingSuspenseBoundary1 = stringToPrecomputedChunk( @@ -4920,11 +5016,13 @@ const completeBoundaryData2 = stringToPrecomputedChunk('" data-sid="'); const completeBoundaryData3a = stringToPrecomputedChunk('" data-sty="'); const completeBoundaryDataEnd = dataElementQuotedEnd; -export function writeCompletedBoundaryInstruction( +function writeCompletedInstruction( destination: Destination, resumableState: ResumableState, renderState: RenderState, - id: number, + prefix: PrecomputedChunk, + boundaryId: number, + segmentId: number, hoistableState: HoistableState, ): boolean { const requiresStyleInsertion = renderState.stylesToHoist; @@ -5009,10 +5107,8 @@ export function writeCompletedBoundaryInstruction( } } - const idChunk = stringToChunk(id.toString(16)); - - writeChunk(destination, renderState.boundaryPrefix); - writeChunk(destination, idChunk); + writeChunk(destination, prefix); + writeChunk(destination, stringToChunk(boundaryId.toString(16))); // Write function arguments, which are string and array literals if (scriptFormat) { @@ -5021,7 +5117,7 @@ export function writeCompletedBoundaryInstruction( writeChunk(destination, completeBoundaryData2); } writeChunk(destination, renderState.segmentPrefix); - writeChunk(destination, idChunk); + writeChunk(destination, stringToChunk(segmentId.toString(16))); if (requiresStyleInsertion) { // Script and data writers must format this differently: // - script writer emits an array literal, whose string elements are @@ -5050,6 +5146,64 @@ export function writeCompletedBoundaryInstruction( return writeBootstrap(destination, renderState) && writeMore; } +export function writeCompletedBoundaryInstruction( + destination: Destination, + resumableState: ResumableState, + renderState: RenderState, + id: number, + hoistableState: HoistableState, +): boolean { + return writeCompletedInstruction( + destination, + resumableState, + renderState, + renderState.boundaryPrefix, + id, + id, + hoistableState, + ); +} + +export function writeAppendListInstruction( + destination: Destination, + resumableState: ResumableState, + renderState: RenderState, + suspenseListId: number, + segmentId: number, + hoistableState: HoistableState, + forwards: boolean, +): boolean { + return writeCompletedInstruction( + destination, + resumableState, + renderState, + renderState.listPrefix, + suspenseListId, + segmentId, + hoistableState, + ); +} + +export function writeCompletedListInstruction( + destination: Destination, + resumableState: ResumableState, + renderState: RenderState, + suspenseListId: number, + segmentId: number, + hoistableState: HoistableState, + forwards: boolean, +): boolean { + return writeCompletedInstruction( + destination, + resumableState, + renderState, + renderState.listPrefix, + suspenseListId, + segmentId, + hoistableState, + ); +} + const clientRenderScriptFunctionOnly = stringToPrecomputedChunk(clientRenderFunction); @@ -5070,10 +5224,11 @@ const clientRenderData4 = stringToPrecomputedChunk('" data-stck="'); const clientRenderData5 = stringToPrecomputedChunk('" data-cstck="'); const clientRenderDataEnd = dataElementQuotedEnd; -export function writeClientRenderBoundaryInstruction( +function writeClientRenderInstruction( destination: Destination, resumableState: ResumableState, renderState: RenderState, + prefix: PrecomputedChunk, id: number, errorDigest: ?string, errorMessage: ?string, @@ -5102,7 +5257,7 @@ export function writeClientRenderBoundaryInstruction( writeChunk(destination, clientRenderData1); } - writeChunk(destination, renderState.boundaryPrefix); + writeChunk(destination, prefix); writeChunk(destination, stringToChunk(id.toString(16))); if (scriptFormat) { // " needs to be inserted for scripts, since ArgInterstitual does not contain @@ -5190,6 +5345,52 @@ export function writeClientRenderBoundaryInstruction( } } +export function writeClientRenderBoundaryInstruction( + destination: Destination, + resumableState: ResumableState, + renderState: RenderState, + id: number, + errorDigest: ?string, + errorMessage: ?string, + errorStack: ?string, + errorComponentStack: ?string, +): boolean { + return writeClientRenderInstruction( + destination, + resumableState, + renderState, + renderState.boundaryPrefix, + id, + errorDigest, + errorMessage, + errorStack, + errorComponentStack, + ); +} + +export function writeClientRenderListInstruction( + destination: Destination, + resumableState: ResumableState, + renderState: RenderState, + id: number, + errorDigest: ?string, + errorMessage: ?string, + errorStack: ?string, + errorComponentStack: ?string, +): boolean { + return writeClientRenderInstruction( + destination, + resumableState, + renderState, + renderState.listPrefix, + id, + errorDigest, + errorMessage, + errorStack, + errorComponentStack, + ); +} + const regexForJSStringsInInstructionScripts = /[<\u2028\u2029]/g; function escapeJSStringsForInstructionScripts(input: string): string { const escaped = JSON.stringify(input); diff --git a/packages/react-dom-bindings/src/server/ReactFizzConfigDOMLegacy.js b/packages/react-dom-bindings/src/server/ReactFizzConfigDOMLegacy.js index d48e9a8dd93..a2cd74c20da 100644 --- a/packages/react-dom-bindings/src/server/ReactFizzConfigDOMLegacy.js +++ b/packages/react-dom-bindings/src/server/ReactFizzConfigDOMLegacy.js @@ -24,6 +24,8 @@ import { pushSegmentFinale as pushSegmentFinaleImpl, pushStartActivityBoundary as pushStartActivityBoundaryImpl, pushEndActivityBoundary as pushEndActivityBoundaryImpl, + pushStartSuspenseListBoundary as pushStartSuspenseListBoundaryImpl, + pushEndSuspenseListBoundary as pushEndSuspenseListBoundaryImpl, writeStartCompletedSuspenseBoundary as writeStartCompletedSuspenseBoundaryImpl, writeStartClientRenderedSuspenseBoundary as writeStartClientRenderedSuspenseBoundaryImpl, writeEndCompletedSuspenseBoundary as writeEndCompletedSuspenseBoundaryImpl, @@ -48,6 +50,7 @@ export type RenderState = { placeholderPrefix: PrecomputedChunk, segmentPrefix: PrecomputedChunk, boundaryPrefix: PrecomputedChunk, + listPrefix: PrecomputedChunk, startInlineScript: PrecomputedChunk, startInlineStyle: PrecomputedChunk, preamble: PreambleState, @@ -105,6 +108,7 @@ export function createRenderState( placeholderPrefix: renderState.placeholderPrefix, segmentPrefix: renderState.segmentPrefix, boundaryPrefix: renderState.boundaryPrefix, + listPrefix: renderState.listPrefix, startInlineScript: renderState.startInlineScript, startInlineStyle: renderState.startInlineStyle, preamble: renderState.preamble, @@ -165,6 +169,11 @@ export { writeClientRenderBoundaryInstruction, writeStartPendingSuspenseBoundary, writeEndPendingSuspenseBoundary, + writePendingSuspenseListMarker, + writeClientRenderedSuspenseListMarker, + writeAppendListInstruction, + writeCompletedListInstruction, + writeClientRenderListInstruction, writeHoistablesForBoundary, writePlaceholder, writeCompletedRoot, @@ -259,6 +268,28 @@ export function pushEndActivityBoundary( pushEndActivityBoundaryImpl(target, renderState); } +export function pushStartSuspenseListBoundary( + target: Array, + renderState: RenderState, +): void { + if (renderState.generateStaticMarkup) { + // A completed boundary is done and doesn't need a representation in the HTML + // if we're not going to be hydrating it. + return; + } + pushStartSuspenseListBoundaryImpl(target, renderState); +} + +export function pushEndSuspenseListBoundary( + target: Array, + renderState: RenderState, +): void { + if (renderState.generateStaticMarkup) { + return; + } + pushEndSuspenseListBoundaryImpl(target, renderState); +} + export function writeStartCompletedSuspenseBoundary( destination: Destination, renderState: RenderState, diff --git a/packages/react-dom-bindings/src/server/fizz-instruction-set/ReactDOMFizzInstructionSetShared.js b/packages/react-dom-bindings/src/server/fizz-instruction-set/ReactDOMFizzInstructionSetShared.js index 86d8801b420..a025859723d 100644 --- a/packages/react-dom-bindings/src/server/fizz-instruction-set/ReactDOMFizzInstructionSetShared.js +++ b/packages/react-dom-bindings/src/server/fizz-instruction-set/ReactDOMFizzInstructionSetShared.js @@ -377,10 +377,6 @@ export function clientRenderBoundary( // E.g. because the parent was hydrated. return; } - // Find the boundary around the fallback. This is always the previous node. - const suspenseNode = suspenseIdNode.previousSibling; - // Tag it to be client rendered. - suspenseNode.data = SUSPENSE_FALLBACK_START_DATA; // assign error metadata to first sibling const dataset = suspenseIdNode.dataset; if (errorDigest) dataset['dgst'] = errorDigest; @@ -388,6 +384,16 @@ export function clientRenderBoundary( if (errorStack) dataset['stck'] = errorStack; if (errorComponentStack) dataset['cstck'] = errorComponentStack; // Tell React to retry it if the parent already hydrated. + let suspenseNode; + if (suspenseIdNode.dataset['lst'] != null) { + // SuspenseList retries on the template node. + suspenseNode = suspenseIdNode; + } else { + // Find the boundary around the fallback. This is always the previous node. + suspenseNode = suspenseIdNode.previousSibling; + // Tag it to be client rendered. + suspenseNode.data = SUSPENSE_FALLBACK_START_DATA; + } if (suspenseNode['_reactRetry']) { suspenseNode['_reactRetry'](); } diff --git a/packages/react-dom/src/__tests__/ReactDOMFizzSuspenseList-test.js b/packages/react-dom/src/__tests__/ReactDOMFizzSuspenseList-test.js index 0e2fff5cd45..44824c007c5 100644 --- a/packages/react-dom/src/__tests__/ReactDOMFizzSuspenseList-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMFizzSuspenseList-test.js @@ -94,6 +94,7 @@ describe('ReactDOMFizzSuspenseList', () => { // We also want to execute any scripts that are embedded. // We assume that we have now received a proper fragment of HTML. const bufferedContent = buffer; + console.log(bufferedContent); buffer = ''; const temp = document.createElement('body'); temp.innerHTML = bufferedContent; @@ -133,8 +134,8 @@ describe('ReactDOMFizzSuspenseList', () => { return Component; } - // @gate enableSuspenseList - it('shows content forwards by default', async () => { + // @gate enableSuspenseList && enableFizzSuspenseListTail + fit('shows content forwards but hidden tail by default', async () => { const A = createAsyncText('A'); const B = createAsyncText('B'); const C = createAsyncText('C'); @@ -173,13 +174,7 @@ describe('ReactDOMFizzSuspenseList', () => { 'Loading C', ]); - expect(getVisibleChildren(container)).toEqual( -
- Loading A - Loading B - Loading C -
, - ); + expect(getVisibleChildren(container)).toEqual(
); await serverAct(() => A.resolve()); assertLog(['A']); @@ -187,8 +182,6 @@ describe('ReactDOMFizzSuspenseList', () => { expect(getVisibleChildren(container)).toEqual(
A - Loading B - Loading C
, ); @@ -986,4 +979,621 @@ describe('ReactDOMFizzSuspenseList', () => { expect(hasErrored).toBe(false); expect(hasCompleted).toBe(true); }); + + // @gate enableSuspenseList && enableFizzSuspenseListTail + it('can stream in "forwards" with tail "hidden" with boundaries', async () => { + const A = createAsyncText('A'); + const B = createAsyncText('B'); + const C = createAsyncText('C'); + + function Foo() { + return ( + + ); + } + + await C.resolve(); + + await serverAct(async () => { + const {pipe} = ReactDOMFizzServer.renderToPipeableStream(); + pipe(writable); + }); + + assertLog([ + 'Suspend! [A]', + 'Suspend! [B]', // TODO: Defer rendering the content after fallback if previous suspended, + 'C', + 'Loading A', + 'Loading B', + 'Loading C', + ]); + + expect(getVisibleChildren(container)).toEqual(
); + + await serverAct(() => A.resolve()); + assertLog(['A']); + + expect(getVisibleChildren(container)).toEqual( +
+ A +
, + ); + + await serverAct(() => B.resolve()); + assertLog(['B']); + + expect(getVisibleChildren(container)).toEqual( +
+ A + B + C +
, + ); + }); + + // @gate enableSuspenseList && enableFizzSuspenseListTail + it('can stream in "forwards" with tail "hidden" without boundaries', async () => { + const A = createAsyncText('A'); + const B = createAsyncText('B'); + const C = createAsyncText('C'); + + function Foo() { + return ( +
+ ); + } + + await C.resolve(); + + await serverAct(async () => { + const {pipe} = ReactDOMFizzServer.renderToPipeableStream(); + pipe(writable); + }); + + assertLog([ + 'Suspend! [A]', + 'Suspend! [B]', // TODO: Defer rendering the content after fallback if previous suspended, + 'C', + ]); + + expect(getVisibleChildren(container)).toEqual(
); + + await serverAct(() => A.resolve()); + assertLog(['A']); + + expect(getVisibleChildren(container)).toEqual( +
+ A +
, + ); + + await serverAct(() => B.resolve()); + assertLog(['B']); + + expect(getVisibleChildren(container)).toEqual( +
+ A + B + C +
, + ); + }); + + // @gate enableSuspenseList && enableFizzSuspenseListTail + it('can stream in "backwards" with tail "hidden" with boundaries', async () => { + const A = createAsyncText('A'); + const B = createAsyncText('B'); + const C = createAsyncText('C'); + + function Foo() { + return ( +
+ ); + } + + await C.resolve(); + + await serverAct(async () => { + const {pipe} = ReactDOMFizzServer.renderToPipeableStream(); + pipe(writable); + }); + + assertLog([ + 'Suspend! [A]', + 'Suspend! [B]', // TODO: Defer rendering the content after fallback if previous suspended, + 'C', + 'Loading A', + 'Loading B', + 'Loading C', + ]); + + expect(getVisibleChildren(container)).toEqual(
); + + await serverAct(() => A.resolve()); + assertLog(['A']); + + expect(getVisibleChildren(container)).toEqual( +
+ A +
, + ); + + await serverAct(() => B.resolve()); + assertLog(['B']); + + expect(getVisibleChildren(container)).toEqual( +
+ C + B + A +
, + ); + }); + + // @gate enableSuspenseList && enableFizzSuspenseListTail + it('can stream in "backwards" with tail "hidden" without boundaries', async () => { + const A = createAsyncText('A'); + const B = createAsyncText('B'); + const C = createAsyncText('C'); + + function Foo() { + return ( +
+ ); + } + + await C.resolve(); + + await serverAct(async () => { + const {pipe} = ReactDOMFizzServer.renderToPipeableStream(); + pipe(writable); + }); + + assertLog([ + 'Suspend! [A]', + 'Suspend! [B]', // TODO: Defer rendering the content after fallback if previous suspended, + 'C', + ]); + + expect(getVisibleChildren(container)).toEqual(
); + + await serverAct(() => A.resolve()); + assertLog(['A']); + + expect(getVisibleChildren(container)).toEqual( +
+ A +
, + ); + + await serverAct(() => B.resolve()); + assertLog(['B']); + + expect(getVisibleChildren(container)).toEqual( +
+ C + B + A +
, + ); + }); + + // @gate enableSuspenseList && enableFizzSuspenseListTail + it('can stream in "forwards" with tail "collapsed"', async () => { + const A = createAsyncText('A'); + const B = createAsyncText('B'); + const C = createAsyncText('C'); + const LoadingA = createAsyncText('Loading A'); + const LoadingB = createAsyncText('Loading B'); + const LoadingC = createAsyncText('Loading C'); + + function Foo() { + return ( +
+ ); + } + + await C.resolve(); + + await serverAct(async () => { + const {pipe} = ReactDOMFizzServer.renderToPipeableStream(); + pipe(writable); + }); + + assertLog([ + 'Suspend! [A]', + 'Suspend! [B]', // TODO: Defer rendering the content after fallback if previous suspended, + 'C', + 'Suspend! [Loading A]', + 'Suspend! [Loading B]', + 'Suspend! [Loading C]', + ]); + + // We need the loading state for the first row before the shell completes. + expect(getVisibleChildren(container)).toEqual(undefined); + + await serverAct(() => LoadingA.resolve()); + + assertLog(['Loading A']); + + expect(getVisibleChildren(container)).toEqual( +
+ Loading A +
, + ); + + await serverAct(() => A.resolve()); + assertLog(['A']); + + // We can't show A yet because we don't yet have the loading state for the next row. + expect(getVisibleChildren(container)).toEqual( +
+ Loading A +
, + ); + + await serverAct(() => LoadingB.resolve()); + assertLog(['Loading B']); + + expect(getVisibleChildren(container)).toEqual( +
+ A + Loading B +
, + ); + + await serverAct(() => B.resolve()); + assertLog(['B']); + + expect(getVisibleChildren(container)).toEqual( +
+ A + B + C +
, + ); + }); + + // @gate enableSuspenseList && enableFizzSuspenseListTail + it('can stream in "backwards" with tail "collapsed"', async () => { + const A = createAsyncText('A'); + const B = createAsyncText('B'); + const C = createAsyncText('C'); + const LoadingA = createAsyncText('Loading A'); + const LoadingB = createAsyncText('Loading B'); + const LoadingC = createAsyncText('Loading C'); + + function Foo() { + return ( +
+ ); + } + + await C.resolve(); + + await serverAct(async () => { + const {pipe} = ReactDOMFizzServer.renderToPipeableStream(); + pipe(writable); + }); + + assertLog([ + 'Suspend! [A]', + 'Suspend! [B]', // TODO: Defer rendering the content after fallback if previous suspended, + 'C', + 'Suspend! [Loading A]', + 'Suspend! [Loading B]', + 'Suspend! [Loading C]', + ]); + + // We need the loading state for the first row before the shell completes. + expect(getVisibleChildren(container)).toEqual(undefined); + + await serverAct(() => LoadingA.resolve()); + + assertLog(['Loading A']); + + expect(getVisibleChildren(container)).toEqual( +
+ Loading A +
, + ); + + await serverAct(() => A.resolve()); + assertLog(['A']); + + // We can't show A yet because we don't yet have the loading state for the next row. + expect(getVisibleChildren(container)).toEqual( +
+ Loading A +
, + ); + + await serverAct(() => LoadingB.resolve()); + assertLog(['Loading B']); + + expect(getVisibleChildren(container)).toEqual( +
+ Loading B + A +
, + ); + + await serverAct(() => B.resolve()); + assertLog(['B']); + + expect(getVisibleChildren(container)).toEqual( +
+ C + B + A +
, + ); + }); + + // @gate enableSuspenseList + it('can stream in a single row "forwards" with tail "collapsed"', async () => { + // This is a special case since there's no second loading state to unblock the first row. + const A = createAsyncText('A'); + const LoadingA = createAsyncText('Loading A'); + + function Foo() { + return ( +
+ ); + } + + await serverAct(async () => { + const {pipe} = ReactDOMFizzServer.renderToPipeableStream(); + pipe(writable); + }); + + assertLog(['Suspend! [A]', 'Suspend! [Loading A]']); + + // We need the loading state for the first row before the shell completes. + expect(getVisibleChildren(container)).toEqual(undefined); + + await serverAct(() => LoadingA.resolve()); + + assertLog(['Loading A']); + + expect(getVisibleChildren(container)).toEqual( +
+ Loading A +
, + ); + + await serverAct(() => A.resolve()); + assertLog(['A']); + + expect(getVisibleChildren(container)).toEqual( +
+ A +
, + ); + }); + + // @gate enableSuspenseList && enableFizzSuspenseListTail + it('can stream in sync rows "forwards" with tail "collapsed"', async () => { + // Notably, this doesn't currently work if the fallbacks are blocked. + // We need the fallbacks to unblock previous rows and we don't know we won't + // need them until later. Needs some resolution at the end. + function Foo() { + return ( +
+ + }> + + + }> + + + }> + + + +
+ ); + } + + await serverAct(async () => { + const {pipe} = ReactDOMFizzServer.renderToPipeableStream(); + pipe(writable); + }); + + assertLog(['A', 'B', 'C', 'Loading A', 'Loading B']); + + expect(getVisibleChildren(container)).toEqual( +
+ A + B + C +
, + ); + }); + + // @gate enableSuspenseList + it('can stream in sync rows "forwards" with tail "collapsed" without boundaries', async () => { + function Foo() { + return ( +
+ + + + + +
+ ); + } + + await serverAct(async () => { + const {pipe} = ReactDOMFizzServer.renderToPipeableStream(); + pipe(writable); + }); + + assertLog(['A', 'B', 'C']); + + expect(getVisibleChildren(container)).toEqual( +
+ A + B + C +
, + ); + }); + + // @gate enableSuspenseList + it('inserts text separators (comments) for text nodes (forwards)', async () => { + function Foo() { + return ( +
+ {['A', 'B', 'C']} +
+ ); + } + + await serverAct(async () => { + const {pipe} = ReactDOMFizzServer.renderToPipeableStream(); + pipe(writable); + }); + + expect(getVisibleChildren(container)).toEqual(
{['A', 'B', 'C']}
); + + const textNodes = 3; + const boundaryComments = 0; + const textSeparators = gate(flags => flags.enableFizzSuspenseListTail) + ? textNodes // One after each node. + : textNodes - 1; // One between each node + const suspenseListComments = gate(flags => flags.enableFizzSuspenseListTail) + ? 2 + : 0; + expect(container.firstChild.childNodes.length).toBe( + textNodes + textSeparators + boundaryComments + suspenseListComments, + ); + }); + + // @gate enableSuspenseList + it('inserts text separators (comments) for text nodes (backwards)', async () => { + function Foo() { + return ( +
+ {['C', 'B', 'A']} +
+ ); + } + + await serverAct(async () => { + const {pipe} = ReactDOMFizzServer.renderToPipeableStream(); + pipe(writable); + }); + + expect(getVisibleChildren(container)).toEqual(
{['A', 'B', 'C']}
); + + const textNodes = 3; + const boundaryComments = 0; + const textSeparators = textNodes; // One after each node. + const suspenseListComments = gate(flags => flags.enableFizzSuspenseListTail) + ? 2 + : 0; + expect(container.firstChild.childNodes.length).toBe( + textNodes + textSeparators + boundaryComments + suspenseListComments, + ); + }); + + // @gate enableSuspenseList + it('inserts text separators (comments) for text nodes (legacy)', async () => { + function Foo() { + return ( +
+ + {['A', 'B', 'C']} + +
+ ); + } + + await serverAct(async () => { + const {pipe} = ReactDOMFizzServer.renderToPipeableStream(); + pipe(writable); + }); + + expect(getVisibleChildren(container)).toEqual(
{['A', 'B', 'C']}
); + + const textNodes = 3; + const boundaryComments = 0; + const textSeparators = textNodes; // One after each node. + const suspenseListComments = gate(flags => flags.enableFizzSuspenseListTail) + ? 2 + : 0; + expect(container.firstChild.childNodes.length).toBe( + textNodes + textSeparators + boundaryComments + suspenseListComments, + ); + }); }); diff --git a/packages/react-dom/src/__tests__/ReactDOMUseId-test.js b/packages/react-dom/src/__tests__/ReactDOMUseId-test.js index 08c48d5f0d7..e5ac22dabb5 100644 --- a/packages/react-dom/src/__tests__/ReactDOMUseId-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMUseId-test.js @@ -601,7 +601,6 @@ describe('useId', () => { > A -
`); @@ -618,7 +617,6 @@ describe('useId', () => { > A -
`); }); diff --git a/packages/react-markup/src/ReactFizzConfigMarkup.js b/packages/react-markup/src/ReactFizzConfigMarkup.js index 7dbe5592f33..fd706c75437 100644 --- a/packages/react-markup/src/ReactFizzConfigMarkup.js +++ b/packages/react-markup/src/ReactFizzConfigMarkup.js @@ -65,6 +65,11 @@ export { writeClientRenderBoundaryInstruction, writeStartPendingSuspenseBoundary, writeEndPendingSuspenseBoundary, + writePendingSuspenseListMarker, + writeClientRenderedSuspenseListMarker, + writeAppendListInstruction, + writeCompletedListInstruction, + writeClientRenderListInstruction, writeHoistablesForBoundary, writePlaceholder, createRootFormatContext, @@ -182,6 +187,21 @@ export function pushEndActivityBoundary( return; } +export function pushStartSuspenseListBoundary( + target: Array, + renderState: RenderState, +): void { + // Markup doesn't have any instructions. + return; +} + +export function pushEndSuspenseListBoundary( + target: Array, + renderState: RenderState, +): void { + // Markup doesn't have any instructions. + return; +} export function writeStartCompletedSuspenseBoundary( destination: Destination, renderState: RenderState, diff --git a/packages/react-noop-renderer/src/ReactNoopServer.js b/packages/react-noop-renderer/src/ReactNoopServer.js index 1793180cc76..1e4c71ccaa0 100644 --- a/packages/react-noop-renderer/src/ReactNoopServer.js +++ b/packages/react-noop-renderer/src/ReactNoopServer.js @@ -34,6 +34,10 @@ type ActivityInstance = { children: Array, }; +type SuspenseListInstance = { + children: Array, +}; + type SuspenseInstance = { state: 'pending' | 'complete' | 'client-render', children: Array, @@ -200,6 +204,36 @@ const ReactNoopServer = ReactFizzServer({ target.push(POP); }, + pushStartSuspenseListBoundary( + target: Array, + renderState: RenderState, + ): void { + const suspenseListInstance: SuspenseListInstance = { + children: [], + }; + target.push(Buffer.from(JSON.stringify(suspenseListInstance), 'utf8')); + }, + + pushEndSuspenseListBoundary( + target: Array, + renderState: RenderState, + ): void { + target.push(POP); + }, + + writePendingSuspenseList( + destination: Destination, + renderState: RenderState, + ): boolean { + return true; + }, + writeClientRenderedSuspenseList( + destination: Destination, + renderState: RenderState, + ): boolean { + return true; + }, + writeStartCompletedSuspenseBoundary( destination: Destination, renderState: RenderState, @@ -318,6 +352,45 @@ const ReactNoopServer = ReactFizzServer({ return true; }, + writeAppendListInstruction( + destination: Destination, + renderState: RenderState, + boundary: SuspenseInstance, + contentSegmentID: number, + ): boolean { + const segment = destination.segments.get(contentSegmentID); + if (!segment) { + throw new Error('Missing segment.'); + } + boundary.children = segment.children; + boundary.state = 'appended'; + return true; + }, + + writeCompletedListInstruction( + destination: Destination, + renderState: RenderState, + boundary: SuspenseInstance, + contentSegmentID: number, + ): boolean { + const segment = destination.segments.get(contentSegmentID); + if (!segment) { + throw new Error('Missing segment.'); + } + boundary.children = segment.children; + boundary.state = 'complete'; + return true; + }, + + writeClientRenderedSuspenseListMarker( + destination: Destination, + renderState: RenderState, + boundary: SuspenseInstance, + ): boolean { + boundary.status = 'client-render'; + return true; + }, + writePreambleStart() {}, writePreambleEnd() {}, writeHoistables() {}, diff --git a/packages/react-server/src/ReactFizzServer.js b/packages/react-server/src/ReactFizzServer.js index 4ad48d79fba..3730d36b31d 100644 --- a/packages/react-server/src/ReactFizzServer.js +++ b/packages/react-server/src/ReactFizzServer.js @@ -13,6 +13,7 @@ import type { PrecomputedChunk, } from './ReactServerStreamConfig'; import type { + ReactKey, ReactNodeList, ReactContext, ReactConsumerType, @@ -27,7 +28,7 @@ import type { SuspenseProps, SuspenseListProps, SuspenseListRevealOrder, - ReactKey, + SuspenseListTailMode, } from 'shared/ReactTypes'; import type {LazyComponent as LazyComponentType} from 'react/src/ReactLazy'; import type { @@ -61,6 +62,10 @@ import { writePlaceholder, pushStartActivityBoundary, pushEndActivityBoundary, + pushStartSuspenseListBoundary, + pushEndSuspenseListBoundary, + writePendingSuspenseListMarker, + writeClientRenderedSuspenseListMarker, writeStartCompletedSuspenseBoundary, writeStartPendingSuspenseBoundary, writeStartClientRenderedSuspenseBoundary, @@ -72,6 +77,9 @@ import { writeClientRenderBoundaryInstruction, writeCompletedBoundaryInstruction, writeCompletedSegmentInstruction, + writeClientRenderListInstruction, + writeAppendListInstruction, + writeCompletedListInstruction, writeHoistablesForBoundary, pushTextInstance, pushStartInstance, @@ -182,6 +190,7 @@ import { enableAsyncIterableChildren, enableViewTransition, enableFizzBlockingRender, + enableFizzSuspenseListTail, enableAsyncDebugInfo, enableCPUSuspense, } from 'shared/ReactFeatureFlags'; @@ -234,12 +243,26 @@ type LegacyContext = { [key: string]: any, }; +// This type is only used for SuspenseLists that may have hidden tail rows and therefore +// add new segments to the end of the list as it streams. +type SuspenseList = { + id: number, + forwards: boolean, + totalRows: number, + completedRows: number, +}; + +type SuspenseListRowMode = 0 | 1; + +const INDEPENDENT = 0; // Each boundary is revealed independently and when they're all done, the next row can unblock. +const TOGETHER = 1; // All the boundaries within this row must be revealed together. + type SuspenseListRow = { pendingTasks: number, // The number of tasks, previous rows and inner suspense boundaries blocking this row. boundaries: null | Array, // The boundaries in this row waiting to be unblocked by the previous row. (null means this row is not blocked) hoistables: HoistableState, // Any dependencies that this row depends on. Future rows need to also depend on it. inheritedHoistables: null | HoistableState, // Any dependencies that previous row depend on, that new boundaries of this row needs. - together: boolean, // All the boundaries within this row must be revealed together. + mode: SuspenseListRowMode, next: null | SuspenseListRow, // The next row blocked by this one. }; @@ -250,7 +273,9 @@ type SuspenseBoundary = { rootSegmentID: number, parentFlushed: boolean, pendingTasks: number, // when it reaches zero we can show this boundary's content + list: null | SuspenseList, // if this boundary is an implicit boundary around a row, then this is the suspense list that it's added to. row: null | SuspenseListRow, // the row that this boundary blocks from completing. + preceedingRow: null | SuspenseListRow, // a preceeding row that can't complete until this boundary is complete (other than being blocked by that row). completedSegments: Array, // completed but not yet flushed segments. byteSize: number, // used to determine whether to inline children boundaries. defer: boolean, // never inline deferred boundaries @@ -795,6 +820,7 @@ function pingTask(request: Request, task: Task): void { function createSuspenseBoundary( request: Request, + list: null | SuspenseList, row: null | SuspenseListRow, fallbackAbortableTasks: Set, preamble: null | Preamble, @@ -805,7 +831,9 @@ function createSuspenseBoundary( rootSegmentID: -1, parentFlushed: false, pendingTasks: 0, + list: list, row: row, + preceedingRow: null, completedSegments: [], byteSize: 0, defer: defer, @@ -1295,6 +1323,7 @@ function renderSuspenseBoundary( const fallbackAbortSet: Set = new Set(); const newBoundary = createSuspenseBoundary( request, + null, task.row, fallbackAbortSet, canHavePreamble(task.formatContext) ? createPreamble() : null, @@ -1487,7 +1516,7 @@ function renderSuspenseBoundary( } } else { const boundaryRow = prevRow; - if (boundaryRow !== null && boundaryRow.together) { + if (boundaryRow !== null && boundaryRow.mode === TOGETHER) { tryToResolveTogetherRow(request, boundaryRow); } } @@ -1596,6 +1625,7 @@ function replaySuspenseBoundary( const fallbackAbortSet: Set = new Set(); const resumedBoundary = createSuspenseBoundary( request, + null, task.row, fallbackAbortSet, canHavePreamble(task.formatContext) ? createPreamble() : null, @@ -1730,6 +1760,15 @@ function unblockSuspenseListRow( ): void { // We do this in a loop to avoid stack overflow for very long lists that get unblocked. while (unblockedRow !== null) { + if (unblockedRow.pendingTasks === 0) { + // We already finished this task, perhaps below. + if (__DEV__) { + console.error( + 'It should not be possible to unblock a row that has no pending tasks. This is a bug in React.', + ); + } + break; + } if (inheritedHoistables !== null) { // Hoist any hoistables from the previous row into the next row so that it can be // later transferred to all the rows. @@ -1742,13 +1781,27 @@ function unblockSuspenseListRow( // Unblocking the boundaries will decrement the count of this row but we keep it above // zero so they never finish this row recursively. const unblockedBoundaries = unblockedRow.boundaries; - if (unblockedBoundaries !== null) { + if ( + unblockedBoundaries !== null && + (unblockedRow.mode !== TOGETHER || + // Together rows are blocked on themselves. This is the last one, which will + // unblock the row itself. + unblockedRow.pendingTasks === 1) + ) { unblockedRow.boundaries = null; for (let i = 0; i < unblockedBoundaries.length; i++) { const unblockedBoundary = unblockedBoundaries[i]; if (inheritedHoistables !== null) { hoistHoistables(unblockedBoundary.contentState, inheritedHoistables); } + if ( + unblockedRow.mode === TOGETHER && + unblockedBoundary.pendingTasks === 1 + ) { + // We decrement the task count when we flush each boundary of a together row. + // We add it back here before finishing which decrements it again. + unblockedRow.pendingTasks++; + } finishedTask(request, unblockedBoundary, null, null); } } @@ -1807,10 +1860,21 @@ function tryToResolveTogetherRow( } } if (allCompleteAndInlinable) { + // Act as if we've flushed all but one and we're now flushing the last one. + togetherRow.pendingTasks -= boundaries.length - 1; unblockSuspenseListRow(request, togetherRow, togetherRow.hoistables); } } +function createSuspenseList(forwards: boolean): SuspenseList { + return { + id: -1, + forwards: forwards, + totalRows: 0, + completedRows: 0, + }; +} + function createSuspenseListRow( previousRow: null | SuspenseListRow, ): SuspenseListRow { @@ -1819,7 +1883,7 @@ function createSuspenseListRow( boundaries: null, hoistables: createHoistableState(), inheritedHoistables: null, - together: false, + mode: INDEPENDENT, next: null, }; if (previousRow !== null && previousRow.pendingTasks > 0) { @@ -1838,6 +1902,7 @@ function renderSuspenseListRows( keyPath: KeyNode, rows: Array, revealOrder: void | 'forwards' | 'backwards' | 'unstable_legacy-backwards', + tailMode: void | 'visible' | 'collapsed' | 'hidden', ): void { // This is a fork of renderChildrenArray that's aware of tracking rows. const prevKeyPath = task.keyPath; @@ -1915,10 +1980,233 @@ function renderSuspenseListRows( } } else { task = ((task: any): RenderTask); // Refined - if ( + + const parentSegment = task.blockedSegment; + + const forwards = revealOrder !== 'backwards' && - revealOrder !== 'unstable_legacy-backwards' - ) { + revealOrder !== 'unstable_legacy-backwards'; + + if (enableFizzSuspenseListTail && tailMode !== 'visible') { + // For hidden tails, we need to create an instance to keep track of adding rows to + // the end. For visible tails, there's no need to represent the list itself. + const suspenseList = createSuspenseList(forwards); + pushStartSuspenseListBoundary(parentSegment.chunks, request.renderState); + + const prevContext = task.formatContext; + const parentBoundary = task.blockedBoundary; + const parentPreamble = task.blockedPreamble; + const parentHoistableState = task.hoistableState; + + const defer = false; // TODO: Should we have an option to defer every row? + const abortSet: Set = new Set(); // There is never any fallbacks to abort but we could abort content rows. + + // This doesn't vary by row so we can apply it only once. + task.formatContext = getSuspenseContentFormatContext( + request.resumableState, + task.formatContext, + ); + + // We then need to create one segment for each row. Each row gets its own implicit + // SuspenseBoundary so that we can hide it. Each new row is nested recursively into + // the next boundary so that they can only complete in order. + let previousRowBoundary: null | SuspenseBoundary = null; + let previousSegment = parentSegment; + + for (let n = 0; n < totalChildren; n++) { + const i = + revealOrder === 'unstable_legacy-backwards' + ? totalChildren - 1 - n + : n; + const node = rows[i]; + const suspenseListRow = createSuspenseListRow(previousSuspenseListRow); + if (tailMode === 'collapsed') { + if (previousSuspenseListRow === null && totalChildren > 1) { + // For collapsed mode, we'll block the first row from completing until the second row's + // loading state has completed. But only if there is a second row. + suspenseListRow.boundaries = []; + } + } else { + // Hidden mode effectively acts as a together row because we'll block it on its own boundary. + suspenseListRow.mode = TOGETHER; + if (previousSuspenseListRow === null) { + // If this is the first row, then it won't be blocked by any previous rows but + // for hidden mode, it'll be blocked by its own boundary. + suspenseListRow.boundaries = []; + } + } + + task.row = suspenseListRow; + task.treeContext = pushTreeContext(prevTreeContext, totalChildren, i); + + suspenseList.totalRows++; + + const rowBoundary = createSuspenseBoundary( + request, + suspenseList, + tailMode === 'collapsed' && previousSuspenseListRow === null + ? null + : suspenseListRow, + abortSet, + canHavePreamble(prevContext) ? createPreamble() : null, + defer, + ); + + if (tailMode === 'collapsed' && previousSuspenseListRow !== null) { + previousSuspenseListRow.pendingTasks++; + rowBoundary.preceedingRow = previousSuspenseListRow; + } + + // In backwards mode, the next boundary segment is added before the content of the row. + // The first one is added to the end of the parent segment. + const insertionIndex = + forwards || n === 0 ? previousSegment.chunks.length : 0; + const boundarySegment = createPendingSegment( + request, + insertionIndex, + rowBoundary, + prevContext, + true, // Text embedding don't matter since this will never have any content + true, + ); + // The implicit boundary is immediately completed since it doesn't have any fallback content. + boundarySegment.status = COMPLETED; + if (forwards || n === 0) { + previousSegment.children.push(boundarySegment); + } else { + previousSegment.children.splice(0, 0, boundarySegment); + } + + if (previousRowBoundary !== null) { + // Once we've added the next boundary, we can finish the previous segment. + finishedSegment(request, previousRowBoundary, previousSegment); + queueCompletedSegment(previousRowBoundary, previousSegment); + } + if (previousSuspenseListRow !== null) { + // Unblock the initial task on the previous row itself. We do this after we have had a + // chance to add the next row as a blocker for the previous row. + if (--previousSuspenseListRow.pendingTasks === 0) { + finishSuspenseListRow(request, previousSuspenseListRow); + } else if (previousSuspenseListRow.mode === TOGETHER) { + tryToResolveTogetherRow(request, previousSuspenseListRow); + } + } + + const isTopSegment = forwards ? n === 0 : n === totalChildren - 1; + const rowSegment = createPendingSegment( + request, + 0, + null, + task.formatContext, + // The top segment is going to get emitted right after last pushed thing to the parent. + // For any other row, the one above it will assume it's text embedded and so will finish + // with a comment. Therefore we can assume that we start as not having pushed text. + isTopSegment ? parentSegment.lastPushedText : false, + // Every child might end up being text embedded depending on what the previous one does. + // Even the last one might end up text embedded if all the others render null and then + // there's text after the List. + true, + ); + // We mark the row segment as having its parent flushed. It's not really flushed but there is + // no parent segment so there's nothing to wait on. + rowSegment.parentFlushed = true; + + const blockedBoundary = + tailMode === 'collapsed' && previousSuspenseListRow === null + ? // The first row of a collapsed blocks the outer parent (or shell) from completing. + // We need to at least finish a loading state. + parentBoundary + : // For hidden, the blocked boundary is the row's implicit boundary itself. + rowBoundary; + if (blockedBoundary !== null) { + task.blockedBoundary = blockedBoundary; + task.blockedPreamble = + blockedBoundary.preamble === null + ? null + : blockedBoundary.preamble.content; + task.hoistableState = blockedBoundary.contentState; + } + task.blockedSegment = rowSegment; + rowSegment.status = RENDERING; + try { + if (__DEV__) { + warnForMissingKey(request, task, node); + } + renderNode(request, task, node, i); + pushSegmentFinale( + rowSegment.chunks, + request.renderState, + rowSegment.lastPushedText, + rowSegment.textEmbedded, + ); + rowSegment.status = COMPLETED; + if ( + rowBoundary.pendingTasks === 0 && + rowBoundary.status === PENDING + ) { + // This must have been the last segment we were waiting on. This boundary is now complete. + rowBoundary.status = COMPLETED; + if (!isEligibleForOutlining(request, rowBoundary)) { + // If we have synchronously completed the boundary and it's not eligible for outlining + // then we don't have to wait for it to be flushed before we unblock future rows. + // This lets us inline small rows in order. + // Unblock the task for the implicit boundary. It will still be blocked by the row itself. + suspenseListRow.pendingTasks--; + } + } else { + const preceedingRow = rowBoundary.preceedingRow; + if (preceedingRow !== null && rowBoundary.pendingTasks === 1) { + // We're blocking a preceeding row on completing this boundary. We only have one task left. + // That must be the preceeding row that blocks us from showing. We can now unblock the preceeding row. + rowBoundary.preceedingRow = null; + if (--preceedingRow.pendingTasks === 0) { + finishSuspenseListRow(request, preceedingRow); + } + } + } + } catch (thrownValue: mixed) { + task.blockedSegment = parentSegment; + task.blockedPreamble = parentPreamble; + task.keyPath = prevKeyPath; + task.formatContext = prevContext; + if (request.status === ABORTING) { + rowSegment.status = ABORTED; + } else { + rowSegment.status = ERRORED; + } + throw thrownValue; + } + + // We nest each row into the previous row boundary's content. + previousSegment = rowSegment; + previousRowBoundary = rowBoundary; + previousSuspenseListRow = suspenseListRow; + } + if (previousRowBoundary !== null) { + // Once we've added the next boundary, we can finish the previous segment. + finishedSegment(request, previousRowBoundary, previousSegment); + queueCompletedSegment(previousRowBoundary, previousSegment); + } + if (previousSuspenseListRow !== null) { + // Unblock the initial task on the previous row itself. We do this after we have had a + // chance to add the next row as a blocker for the previous row. + if (--previousSuspenseListRow.pendingTasks === 0) { + finishSuspenseListRow(request, previousSuspenseListRow); + } else if (previousSuspenseListRow.mode === TOGETHER) { + tryToResolveTogetherRow(request, previousSuspenseListRow); + } + } + task.blockedBoundary = parentBoundary; + task.blockedPreamble = parentPreamble; + task.hoistableState = parentHoistableState; + task.blockedSegment = parentSegment; + task.formatContext = prevContext; + + // Reset lastPushedText for current Segment since the new Segments "consumed" it + parentSegment.lastPushedText = false; + + pushEndSuspenseListBoundary(parentSegment.chunks, request.renderState); + } else if (forwards) { // Forwards direction for (let i = 0; i < totalChildren; i++) { const node = rows[i]; @@ -1938,7 +2226,6 @@ function renderSuspenseListRows( // For backwards direction we need to do things a bit differently. // We give each row its own segment so that we can render the content in // reverse order but still emit it in the right order when we flush. - const parentSegment = task.blockedSegment; const childIndex = parentSegment.children.length; const insertionIndex = parentSegment.chunks.length; for (let n = 0; n < totalChildren; n++) { @@ -1956,8 +2243,13 @@ function renderSuspenseListRows( insertionIndex, null, task.formatContext, - // Assume we are text embedded at the trailing edges - i === 0 ? parentSegment.lastPushedText : true, + // The last segment is going to get emitted right after last pushed thing to the parent. + // For any other row, the one above it will assume it's text embedded and so will finish + // with a comment. Therefore we can assume that we start as not having pushed text. + i === totalChildren - 1 ? parentSegment.lastPushedText : false, + // Every child might end up being text embedded depending on what the previous one does. + // Even the last one might end up text embedded if all the others render null and then + // there's text after the List. true, ); // Insert in the beginning of the sequence, which will insert before any previous rows. @@ -2024,12 +2316,18 @@ function renderSuspenseList( ): void { const children: any = props.children; const revealOrder: SuspenseListRevealOrder = props.revealOrder; - // TODO: Support tail hidden/collapsed modes. - // const tailMode: SuspenseListTailMode = props.tail; if (revealOrder !== 'independent' && revealOrder !== 'together') { // For ordered reveal, we need to produce rows from the children. + const tailMode: SuspenseListTailMode = props.tail; if (isArray(children)) { - renderSuspenseListRows(request, task, keyPath, children, revealOrder); + renderSuspenseListRows( + request, + task, + keyPath, + children, + revealOrder, + tailMode, + ); return; } const iteratorFn = getIteratorFn(children); @@ -2050,7 +2348,14 @@ function renderSuspenseList( rows.push(step.value); step = iterator.next(); } while (!step.done); - renderSuspenseListRows(request, task, keyPath, children, revealOrder); + renderSuspenseListRows( + request, + task, + keyPath, + children, + revealOrder, + tailMode, + ); } return; } @@ -2108,7 +2413,14 @@ function renderSuspenseList( step = unwrapThenable(iterator.next()); } } - renderSuspenseListRows(request, task, keyPath, rows, revealOrder); + renderSuspenseListRows( + request, + task, + keyPath, + rows, + revealOrder, + tailMode, + ); return; } } @@ -2122,7 +2434,7 @@ function renderSuspenseList( // This will cause boundaries to block on this row, but there's nothing to // unblock them. We'll use the partial flushing pass to unblock them. newRow.boundaries = []; - newRow.together = true; + newRow.mode = TOGETHER; task.keyPath = keyPath; renderNodeDestructive(request, task, children, -1); if (--newRow.pendingTasks === 0) { @@ -4401,6 +4713,7 @@ function abortRemainingSuspenseBoundary( const resumedBoundary = createSuspenseBoundary( request, null, + null, new Set(), null, false, @@ -4813,7 +5126,7 @@ function finishedTask( if (row !== null) { if (--row.pendingTasks === 0) { finishSuspenseListRow(request, row); - } else if (row.together) { + } else if (row.mode === TOGETHER) { tryToResolveTogetherRow(request, row); } } @@ -4928,9 +5241,22 @@ function finishedTask( } } const boundaryRow = boundary.row; - if (boundaryRow !== null && boundaryRow.together) { + if (boundaryRow !== null && boundaryRow.mode === TOGETHER) { tryToResolveTogetherRow(request, boundaryRow); } + const preceedingRow = boundary.preceedingRow; + if (preceedingRow !== null && boundary.pendingTasks === 1) { + // We're blocking a preceeding row on completing this boundary. We only have one task left. + // That must be the preceeding row that blocks us from showing. We can now unblock the preceeding row. + boundary.preceedingRow = null; + if (preceedingRow.pendingTasks === 1) { + unblockSuspenseListRow( + request, + preceedingRow, + preceedingRow.hoistables, + ); + } + } } } @@ -5438,6 +5764,148 @@ function flushSegment( // we don't want to reflush the boundary segment.boundary = null; boundary.parentFlushed = true; + + const suspenseList = boundary.list; + if (suspenseList !== null) { + return flushSuspenseListSegment( + request, + destination, + suspenseList, + boundary, + hoistableState, + ); + } + + return flushSuspenseBoundarySegment( + request, + destination, + boundary, + segment, + hoistableState, + ); +} + +function flushSuspenseListSegment( + request: Request, + destination: Destination, + suspenseList: SuspenseList, + boundary: SuspenseBoundary, + hoistableState: null | HoistableState, +): boolean { + // This segment is a SuspenseList boundary row. + if (boundary.status === CLIENT_RENDERED) { + suspenseList.completedRows++; + // The suspense list didn't complete all the way. Emit a marker to continue on the client. + if (__DEV__) { + return writeClientRenderedSuspenseListMarker( + destination, + request.renderState, + boundary.errorDigest, + boundary.errorMessage, + boundary.errorStack, + boundary.errorComponentStack, + ); + } else { + return writeClientRenderedSuspenseListMarker( + destination, + request.renderState, + boundary.errorDigest, + null, + null, + null, + ); + } + } else if (boundary.status !== COMPLETED) { + if (boundary.status === PENDING) { + boundary.rootSegmentID = request.nextSegmentId++; + } + + if (boundary.completedSegments.length > 0) { + // If this is at least partially complete, we can queue it to be partially emitted early. + request.partialBoundaries.push(boundary); + } + + if (suspenseList.id === -1) { + // We lazily assign an ID to the list marker. + suspenseList.id = boundary.rootSegmentID; + // This is the first time we're emitting this list so we need to emit the marker. + return writePendingSuspenseListMarker( + destination, + request.renderState, + suspenseList.id, + ); + } else { + // This is just a partial continuation of a previously emitted list. We already have + // a marker for the list so there's nothing more to emit. + return true; + } + } else if ( + // We don't outline when we're emitting partially completed boundaries optimistically + // because it doesn't make sense to outline something if its parent is going to be + // blocked on something later in the stream anyway. + !flushingPartialBoundaries && + isEligibleForOutlining(request, boundary) && + (flushedByteSize + boundary.byteSize > request.progressiveChunkSize || + hasSuspenseyContent(boundary.contentState) || + boundary.defer) + ) { + boundary.rootSegmentID = request.nextSegmentId++; + request.completedBoundaries.push(boundary); + + if (suspenseList.id === -1) { + // We lazily assign an ID to the list marker. + suspenseList.id = boundary.rootSegmentID; + // This is the first time we're emitting this list so we need to emit the marker. + return writePendingSuspenseListMarker( + destination, + request.renderState, + suspenseList.id, + ); + } else { + // This is just a partial continuation of a previously emitted list. We already have + // a marker for the list so there's nothing more to emit. + return true; + } + } else { + // We're inlining this boundary so its bytes get counted to the current running count. + flushedByteSize += boundary.byteSize; + if (hoistableState) { + hoistHoistables(hoistableState, boundary.contentState); + } + + const row = boundary.row; + if (row !== null && isEligibleForOutlining(request, boundary)) { + // Once we have written the boundary, we can unblock the row and let future + // rows be written. This may schedule new completed boundaries. + if (--row.pendingTasks === 0) { + finishSuspenseListRow(request, row); + } + } + + suspenseList.completedRows++; + + // We can inline this row's content without any additional markers. + const completedSegments = boundary.completedSegments; + + if (completedSegments.length !== 1) { + throw new Error( + 'A previously unvisited boundary must have exactly one root segment. This is a bug in React.', + ); + } + + const contentSegment = completedSegments[0]; + // TODO: Apply a tail call optimization to avoid recursing into the list. + return flushSegment(request, destination, contentSegment, hoistableState); + } +} + +function flushSuspenseBoundarySegment( + request: Request, + destination: Destination, + boundary: SuspenseBoundary, + fallbackSegment: Segment, + hoistableState: null | HoistableState, +): boolean { // This segment is a Suspense boundary. We need to decide whether to // emit the content or the fallback now. if (boundary.status === CLIENT_RENDERED) { @@ -5474,7 +5942,7 @@ function flushSegment( ); } // Flush the fallback. - flushSubtree(request, destination, segment, hoistableState); + flushSubtree(request, destination, fallbackSegment, hoistableState); return writeEndClientRenderedSuspenseBoundary( destination, @@ -5501,7 +5969,7 @@ function flushSegment( hoistHoistables(hoistableState, boundary.fallbackState); } // Flush the fallback. - flushSubtree(request, destination, segment, hoistableState); + flushSubtree(request, destination, fallbackSegment, hoistableState); return writeEndPendingSuspenseBoundary(destination, request.renderState); } else if ( @@ -5539,7 +6007,7 @@ function flushSegment( // flushes later in this pass or in a future flush // Flush the fallback. - flushSubtree(request, destination, segment, hoistableState); + flushSubtree(request, destination, fallbackSegment, hoistableState); return writeEndPendingSuspenseBoundary(destination, request.renderState); } else { @@ -5581,28 +6049,55 @@ function flushClientRenderedBoundary( destination: Destination, boundary: SuspenseBoundary, ): boolean { - if (__DEV__) { - return writeClientRenderBoundaryInstruction( - destination, - request.resumableState, - request.renderState, - boundary.rootSegmentID, - boundary.errorDigest, - boundary.errorMessage, - boundary.errorStack, - boundary.errorComponentStack, - ); + const suspenseList = boundary.list; + if (suspenseList !== null) { + if (__DEV__) { + return writeClientRenderListInstruction( + destination, + request.resumableState, + request.renderState, + suspenseList.id, + boundary.errorDigest, + boundary.errorMessage, + boundary.errorStack, + boundary.errorComponentStack, + ); + } else { + return writeClientRenderListInstruction( + destination, + request.resumableState, + request.renderState, + suspenseList.id, + boundary.errorDigest, + null, + null, + null, + ); + } } else { - return writeClientRenderBoundaryInstruction( - destination, - request.resumableState, - request.renderState, - boundary.rootSegmentID, - boundary.errorDigest, - null, - null, - null, - ); + if (__DEV__) { + return writeClientRenderBoundaryInstruction( + destination, + request.resumableState, + request.renderState, + boundary.rootSegmentID, + boundary.errorDigest, + boundary.errorMessage, + boundary.errorStack, + boundary.errorComponentStack, + ); + } else { + return writeClientRenderBoundaryInstruction( + destination, + request.resumableState, + request.renderState, + boundary.rootSegmentID, + boundary.errorDigest, + null, + null, + null, + ); + } } } @@ -5650,13 +6145,40 @@ function flushCompletedBoundary( boundary.contentState, request.renderState, ); - return writeCompletedBoundaryInstruction( - destination, - request.resumableState, - request.renderState, - boundary.rootSegmentID, - boundary.contentState, - ); + + const suspenseList = boundary.list; + if (suspenseList !== null) { + suspenseList.completedRows++; + if (suspenseList.completedRows < suspenseList.totalRows) { + return writeAppendListInstruction( + destination, + request.resumableState, + request.renderState, + suspenseList.id, + boundary.rootSegmentID, + boundary.contentState, + suspenseList.forwards, + ); + } else { + return writeCompletedListInstruction( + destination, + request.resumableState, + request.renderState, + suspenseList.id, + boundary.rootSegmentID, + boundary.contentState, + suspenseList.forwards, + ); + } + } else { + return writeCompletedBoundaryInstruction( + destination, + request.resumableState, + request.renderState, + boundary.rootSegmentID, + boundary.contentState, + ); + } } function flushPartialBoundary( @@ -5682,7 +6204,7 @@ function flushPartialBoundary( completedSegments.splice(0, i); const row = boundary.row; - if (row !== null && row.together && boundary.pendingTasks === 1) { + if (row !== null && row.mode === TOGETHER && boundary.pendingTasks === 1) { // "together" rows are blocked on their own boundaries. // We have now flushed all the boundary's segments as partials. // We can now unblock it from blocking the row that will eventually @@ -5726,7 +6248,6 @@ function flushPartiallyCompletedSegment( 'A root segment ID must have been assigned by now. This is a bug in React.', ); } - return flushSegmentContainer(request, destination, segment, hoistableState); } else if (segmentID === boundary.rootSegmentID) { // When we emit postponed boundaries, we might have assigned the ID already diff --git a/packages/react-server/src/forks/ReactFizzConfig.custom.js b/packages/react-server/src/forks/ReactFizzConfig.custom.js index aa8ea94b579..9c9a4589aaa 100644 --- a/packages/react-server/src/forks/ReactFizzConfig.custom.js +++ b/packages/react-server/src/forks/ReactFizzConfig.custom.js @@ -67,6 +67,14 @@ export const writeCompletedRoot = $$$config.writeCompletedRoot; export const writePlaceholder = $$$config.writePlaceholder; export const pushStartActivityBoundary = $$$config.pushStartActivityBoundary; export const pushEndActivityBoundary = $$$config.pushEndActivityBoundary; +export const pushStartSuspenseListBoundary = + $$$config.pushStartSuspenseListBoundary; +export const pushEndSuspenseListBoundary = + $$$config.pushEndSuspenseListBoundary; +export const writePendingSuspenseListMarker = + $$$config.writePendingSuspenseListMarker; +export const writeClientRenderedSuspenseListMarker = + $$$config.writeClientRenderedSuspenseListMarker; export const writeStartCompletedSuspenseBoundary = $$$config.writeStartCompletedSuspenseBoundary; export const writeStartPendingSuspenseBoundary = @@ -87,6 +95,12 @@ export const writeCompletedBoundaryInstruction = $$$config.writeCompletedBoundaryInstruction; export const writeClientRenderBoundaryInstruction = $$$config.writeClientRenderBoundaryInstruction; +export const writeAppendListInstruction = $$$config.writeAppendListInstruction; +export const writeCompletedListInstruction = + $$$config.writeCompletedListInstruction; +export const writeClientRenderListInstruction = + $$$config.writeClientRenderListInstruction; + export const NotPendingTransition = $$$config.NotPendingTransition; export const createPreambleState = $$$config.createPreambleState; export const canHavePreamble = $$$config.canHavePreamble; diff --git a/packages/shared/ReactFeatureFlags.js b/packages/shared/ReactFeatureFlags.js index ebb287568af..ee144ca36df 100644 --- a/packages/shared/ReactFeatureFlags.js +++ b/packages/shared/ReactFeatureFlags.js @@ -92,6 +92,8 @@ export const enableSuspenseyImages: boolean = false; export const enableFizzBlockingRender = __EXPERIMENTAL__; // rel="expect" +export const enableFizzSuspenseListTail: boolean = false; + export const enableSrcObject = __EXPERIMENTAL__; export const enableHydrationChangeEvent = __EXPERIMENTAL__; diff --git a/packages/shared/forks/ReactFeatureFlags.native-fb.js b/packages/shared/forks/ReactFeatureFlags.native-fb.js index d9a91f8a808..3eab844f48a 100644 --- a/packages/shared/forks/ReactFeatureFlags.native-fb.js +++ b/packages/shared/forks/ReactFeatureFlags.native-fb.js @@ -76,6 +76,7 @@ export const enableGestureTransition: boolean = false; export const enableScrollEndPolyfill: boolean = true; export const enableSuspenseyImages: boolean = false; export const enableFizzBlockingRender: boolean = true; +export const enableFizzSuspenseListTail: boolean = false; export const enableSrcObject: boolean = false; export const enableHydrationChangeEvent: boolean = true; export const enableDefaultTransitionIndicator: boolean = true; diff --git a/packages/shared/forks/ReactFeatureFlags.native-oss.js b/packages/shared/forks/ReactFeatureFlags.native-oss.js index fa8f336c03f..64ae1eb75ec 100644 --- a/packages/shared/forks/ReactFeatureFlags.native-oss.js +++ b/packages/shared/forks/ReactFeatureFlags.native-oss.js @@ -67,6 +67,7 @@ export const enableGestureTransition: boolean = false; export const enableScrollEndPolyfill: boolean = true; export const enableSuspenseyImages: boolean = false; export const enableFizzBlockingRender: boolean = true; +export const enableFizzSuspenseListTail: boolean = false; export const enableSrcObject: boolean = false; export const enableHydrationChangeEvent: boolean = false; export const enableDefaultTransitionIndicator: boolean = false; diff --git a/packages/shared/forks/ReactFeatureFlags.test-renderer.js b/packages/shared/forks/ReactFeatureFlags.test-renderer.js index acf3847bd06..b78c9fdebf3 100644 --- a/packages/shared/forks/ReactFeatureFlags.test-renderer.js +++ b/packages/shared/forks/ReactFeatureFlags.test-renderer.js @@ -68,6 +68,7 @@ export const enableGestureTransition: boolean = false; export const enableScrollEndPolyfill: boolean = true; export const enableSuspenseyImages: boolean = false; export const enableFizzBlockingRender: boolean = true; +export const enableFizzSuspenseListTail: boolean = false; export const enableSrcObject: boolean = false; export const enableHydrationChangeEvent: boolean = false; export const enableDefaultTransitionIndicator: boolean = false; diff --git a/packages/shared/forks/ReactFeatureFlags.test-renderer.native-fb.js b/packages/shared/forks/ReactFeatureFlags.test-renderer.native-fb.js index 5d3a5513018..1920d8c521f 100644 --- a/packages/shared/forks/ReactFeatureFlags.test-renderer.native-fb.js +++ b/packages/shared/forks/ReactFeatureFlags.test-renderer.native-fb.js @@ -63,6 +63,7 @@ export const enableGestureTransition = false; export const enableScrollEndPolyfill = true; export const enableSuspenseyImages = false; export const enableFizzBlockingRender = true; +export const enableFizzSuspenseListTail = false; export const enableSrcObject = false; export const enableHydrationChangeEvent = false; export const enableDefaultTransitionIndicator = true; diff --git a/packages/shared/forks/ReactFeatureFlags.test-renderer.www.js b/packages/shared/forks/ReactFeatureFlags.test-renderer.www.js index 553be202c45..32700e4b59d 100644 --- a/packages/shared/forks/ReactFeatureFlags.test-renderer.www.js +++ b/packages/shared/forks/ReactFeatureFlags.test-renderer.www.js @@ -76,6 +76,7 @@ export const enableGestureTransition: boolean = false; export const enableScrollEndPolyfill: boolean = true; export const enableSuspenseyImages: boolean = false; export const enableFizzBlockingRender: boolean = true; +export const enableFizzSuspenseListTail: boolean = false; export const enableSrcObject: boolean = false; export const enableHydrationChangeEvent: boolean = false; export const enableDefaultTransitionIndicator: boolean = true; diff --git a/packages/shared/forks/ReactFeatureFlags.www.js b/packages/shared/forks/ReactFeatureFlags.www.js index 87801a9658f..9d529bc1c48 100644 --- a/packages/shared/forks/ReactFeatureFlags.www.js +++ b/packages/shared/forks/ReactFeatureFlags.www.js @@ -106,6 +106,7 @@ export const enableGestureTransition: boolean = false; export const enableSuspenseyImages: boolean = false; export const enableFizzBlockingRender: boolean = true; +export const enableFizzSuspenseListTail: boolean = false; export const enableSrcObject: boolean = false; export const enableHydrationChangeEvent: boolean = false; export const enableDefaultTransitionIndicator: boolean = true;