From a2766387efe68b318b23d8c35c70b850d1e6a250 Mon Sep 17 00:00:00 2001 From: Josh Story Date: Sat, 28 May 2022 08:30:38 -0700 Subject: [PATCH] [Fizz] Improve text separator byte efficiency (#24630) * [Fizz] Improve text separator byte efficiency Previously text separators were inserted following any Text node in Fizz. This increases bytes sent when streaming and in some cases such as title elements these separators are not interpreted as comment nodes and leak into the visual aspects of a page as escaped text. The reason simple tracking on the last pushed type doesn't work is that Segments can be filled in asynchronously later and so you cannot know in a single pass whether the preceding content was a text node or not. This commit adds a concept of TextEmbedding which provides a best effort signal to Segments on whether they are embedded within text. This allows the later resolution of that Segment to add text separators when possibly necessary but avoid them when they are surely not. The current implementation can only "peek" head if the segment is a the Root Segment or a Suspense Boundary Segment. In these cases we know there is no trailing text embedding and we can eliminate the separator at the end of the segment if the last emitted element was Text. In normal Segments we cannot peek and thus have to assume there might be a trailing text embedding and we issue a separator defensively. This should be rare in practice as it is assumed most components that will cause segment creation will also emit some markup at the edges. * [Fizz] Improve separator efficiency when flushing delayed segments The method by which we get segment markup into the DOM differs depending on when the Segment resolves. If a Segment resolves before flushing begins for it's parent it will be emitted inline with the parent markup. In these cases separators may be necessary because they are how we clue the browser into breakup up text into distinct nodes that will later match up with what will be hydrated on the client. If a Segment resolves after flushing has happened a script will be used to patch up the DOM in the client. when this happens if there are any text nodes on the boundary of the patch they won't be "merged" and thus will continue to have distinct representation as Nodes in the DOM. Thus we can avoid doing any separators at the boundaries in these cases. After applying these changes the only time you will get text separators as follows * in between serial text nodes that emit at the same time - these are necessary and cannot be eliminated unless we stop relying on the browser to automatically parse the correct text nodes when processing this HTML * after a final text node in a non-boundary segment that resolves before it's parent has flushed - these are sometimes extraneous, like when the next emitted thing is a non-Text node. In all other cases text separators should be omitted which means the general byte efficiency of this approach should be pretty good --- .../src/__tests__/ReactDOMFizzServer-test.js | 422 ++++++++++++++++++ .../ReactDOMServerIntegrationElements-test.js | 57 ++- .../src/__tests__/ReactDOMUseId-test.js | 2 - .../ReactDOMLegacyServerStreamConfig.js | 11 - .../src/server/ReactDOMServerFormatConfig.js | 25 +- .../ReactDOMServerLegacyFormatConfig.js | 25 +- .../server/ReactNativeServerFormatConfig.js | 13 +- .../src/ReactNoopServer.js | 16 +- .../ReactDOMServerFB-test.internal.js | 4 +- packages/react-server/src/ReactFizzServer.js | 62 ++- .../forks/ReactServerFormatConfig.custom.js | 1 + 11 files changed, 579 insertions(+), 59 deletions(-) diff --git a/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js b/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js index 9a6cfd457c8d7..56ae7c8a09652 100644 --- a/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js @@ -234,6 +234,11 @@ describe('ReactDOMFizzServer', () => { return readText(text); } + function AsyncTextWrapped({as, text}) { + const As = as; + return {readText(text)}; + } + // @gate experimental it('should asynchronously load a lazy component', async () => { let resolveA; @@ -3577,4 +3582,421 @@ describe('ReactDOMFizzServer', () => { , ); }); + + describe('text separators', () => { + // To force performWork to start before resolving AsyncText but before piping we need to wait until + // after scheduleWork which currently uses setImmediate to delay performWork + function afterImmediate() { + return new Promise(resolve => { + setImmediate(resolve); + }); + } + + // @gate experimental + it('it only includes separators between adjacent text nodes', async () => { + function App({name}) { + return ( +
+ helloworld, {name}! +
+ ); + } + + await act(async () => { + const {pipe} = ReactDOMFizzServer.renderToPipeableStream( + , + ); + pipe(writable); + }); + + expect(container.innerHTML).toEqual( + '
helloworld, Foo!
', + ); + const errors = []; + ReactDOMClient.hydrateRoot(container, , { + onRecoverableError(error) { + errors.push(error.message); + }, + }); + expect(Scheduler).toFlushAndYield([]); + expect(errors).toEqual([]); + expect(getVisibleChildren(container)).toEqual( +
+ helloworld, {'Foo'}! +
, + ); + }); + + // @gate experimental + it('it does not insert text separators even when adjacent text is in a delayed segment', async () => { + function App({name}) { + return ( + +
+ hello + + world, + + ! +
+
+ ); + } + + await act(async () => { + const {pipe} = ReactDOMFizzServer.renderToPipeableStream( + , + ); + pipe(writable); + }); + + expect(document.getElementById('app-div').outerHTML).toEqual( + '
helloworld, !
', + ); + + await act(() => resolveText('Foo')); + + expect(container.firstElementChild.outerHTML).toEqual( + '
helloworld, Foo!
', + ); + // there are extra script nodes at the end of container + expect(container.childNodes.length).toBe(5); + const div = container.childNodes[1]; + expect(div.childNodes.length).toBe(3); + const b = div.childNodes[1]; + expect(b.childNodes.length).toBe(2); + expect(b.childNodes[0]).toMatchInlineSnapshot('world, '); + expect(b.childNodes[1]).toMatchInlineSnapshot('Foo'); + + const errors = []; + ReactDOMClient.hydrateRoot(container, , { + onRecoverableError(error) { + errors.push(error.message); + }, + }); + expect(Scheduler).toFlushAndYield([]); + expect(errors).toEqual([]); + expect(getVisibleChildren(container)).toEqual( +
+ helloworld, {'Foo'}! +
, + ); + }); + + // @gate experimental + it('it works with multiple adjacent segments', async () => { + function App() { + return ( + +
+ h + w +
+
+ ); + } + + await act(async () => { + const {pipe} = ReactDOMFizzServer.renderToPipeableStream(); + pipe(writable); + }); + + expect(document.getElementById('app-div').outerHTML).toEqual( + '
hw
', + ); + + await act(() => resolveText('orld')); + + expect(document.getElementById('app-div').outerHTML).toEqual( + '
hworld
', + ); + + await act(() => resolveText('ello')); + expect(container.firstElementChild.outerHTML).toEqual( + '
helloworld
', + ); + + const errors = []; + ReactDOMClient.hydrateRoot(container, , { + onRecoverableError(error) { + errors.push(error.message); + }, + }); + expect(Scheduler).toFlushAndYield([]); + expect(errors).toEqual([]); + expect(getVisibleChildren(container)).toEqual( +
{['h', 'ello', 'w', 'orld']}
, + ); + }); + + // @gate experimental + it('it works when some segments are flushed and others are patched', async () => { + function App() { + return ( + +
+ h + w +
+
+ ); + } + + await act(async () => { + const {pipe} = ReactDOMFizzServer.renderToPipeableStream(); + await afterImmediate(); + await act(() => resolveText('ello')); + pipe(writable); + }); + + expect(document.getElementById('app-div').outerHTML).toEqual( + '
hellow
', + ); + + await act(() => resolveText('orld')); + + expect(container.firstElementChild.outerHTML).toEqual( + '
helloworld
', + ); + + const errors = []; + ReactDOMClient.hydrateRoot(container, , { + onRecoverableError(error) { + errors.push(error.message); + }, + }); + expect(Scheduler).toFlushAndYield([]); + expect(errors).toEqual([]); + expect(getVisibleChildren(container)).toEqual( +
{['h', 'ello', 'w', 'orld']}
, + ); + }); + + // @gate experimental + it('it does not prepend a text separators if the segment follows a non-Text Node', async () => { + function App() { + return ( + +
+ hello + + + +
+
+ ); + } + + await act(async () => { + const {pipe} = ReactDOMFizzServer.renderToPipeableStream(); + await afterImmediate(); + await act(() => resolveText('world')); + pipe(writable); + }); + + expect(container.firstElementChild.outerHTML).toEqual( + '
helloworld
', + ); + + const errors = []; + ReactDOMClient.hydrateRoot(container, , { + onRecoverableError(error) { + errors.push(error.message); + }, + }); + expect(Scheduler).toFlushAndYield([]); + expect(errors).toEqual([]); + expect(getVisibleChildren(container)).toEqual( +
+ helloworld +
, + ); + }); + + // @gate experimental + it('it does not prepend a text separators if the segments first emission is a non-Text Node', async () => { + function App() { + return ( + +
+ hello + +
+
+ ); + } + + await act(async () => { + const {pipe} = ReactDOMFizzServer.renderToPipeableStream(); + await afterImmediate(); + await act(() => resolveText('world')); + pipe(writable); + }); + + expect(container.firstElementChild.outerHTML).toEqual( + '
helloworld
', + ); + + const errors = []; + ReactDOMClient.hydrateRoot(container, , { + onRecoverableError(error) { + errors.push(error.message); + }, + }); + expect(Scheduler).toFlushAndYield([]); + expect(errors).toEqual([]); + expect(getVisibleChildren(container)).toEqual( +
+ helloworld +
, + ); + }); + + // @gate experimental + it('should not insert separators for text inside Suspense boundaries even if they would otherwise be considered text-embedded', async () => { + function App() { + return ( + +
+ start + + firststart + + firstend + + + secondstart + + + + + end +
+
+ ); + } + + await act(async () => { + const {pipe} = ReactDOMFizzServer.renderToPipeableStream(); + await afterImmediate(); + await act(() => resolveText('world')); + pipe(writable); + }); + + expect(document.getElementById('app-div').outerHTML).toEqual( + '
start[loading first][loading second]end
', + ); + + await act(async () => { + resolveText('first suspended'); + }); + + expect(document.getElementById('app-div').outerHTML).toEqual( + '
startfirststartfirst suspendedfirstend[loading second]end
', + ); + + const errors = []; + ReactDOMClient.hydrateRoot(container, , { + onRecoverableError(error) { + errors.push(error.message); + }, + }); + expect(Scheduler).toFlushAndYield([]); + expect(errors).toEqual([]); + expect(getVisibleChildren(container)).toEqual( +
+ {'start'} + {'firststart'} + {'first suspended'} + {'firstend'} + {'[loading second]'} + {'end'} +
, + ); + + await act(async () => { + resolveText('second suspended'); + }); + + expect(container.firstElementChild.outerHTML).toEqual( + '
startfirststartfirst suspendedfirstendsecondstartsecond suspendedend
', + ); + + expect(Scheduler).toFlushAndYield([]); + expect(errors).toEqual([]); + expect(getVisibleChildren(container)).toEqual( +
+ {'start'} + {'firststart'} + {'first suspended'} + {'firstend'} + {'secondstart'} + second suspended + {'end'} +
, + ); + }); + + // @gate experimental + it('(only) includes extraneous text separators in segments that complete before flushing, followed by nothing or a non-Text node', async () => { + function App() { + return ( +
+ + hello + + + + + + + hello + +
+
+ + +
+
+
+ ); + } + + await act(async () => { + const {pipe} = ReactDOMFizzServer.renderToPipeableStream(); + await afterImmediate(); + await act(() => resolveText('world')); + pipe(writable); + }); + + expect(container.innerHTML).toEqual( + '
helloworldworldhelloworld
world
', + ); + + const errors = []; + ReactDOMClient.hydrateRoot(container, , { + onRecoverableError(error) { + errors.push(error.message); + }, + }); + expect(Scheduler).toFlushAndYield([]); + expect(errors).toEqual([]); + expect(getVisibleChildren(container)).toEqual( +
+ {/* first boundary */} + {'hello'} + {'world'} + {/* second boundary */} + {'world'} + {/* third boundary */} + {'hello'} + {'world'} +
+ {/* fourth boundary */} + {'world'} +
+
, + ); + }); + }); }); diff --git a/packages/react-dom/src/__tests__/ReactDOMServerIntegrationElements-test.js b/packages/react-dom/src/__tests__/ReactDOMServerIntegrationElements-test.js index 6a777f3f4325e..0dcfbbd78c8a3 100644 --- a/packages/react-dom/src/__tests__/ReactDOMServerIntegrationElements-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMServerIntegrationElements-test.js @@ -101,7 +101,7 @@ describe('ReactDOMServerIntegration', () => { ) { // For plain server markup result we have comments between. // If we're able to hydrate, they remain. - expect(e.childNodes.length).toBe(render === streamRender ? 6 : 5); + expect(e.childNodes.length).toBe(5); expectTextNode(e.childNodes[0], ' '); expectTextNode(e.childNodes[2], ' '); expectTextNode(e.childNodes[4], ' '); @@ -119,8 +119,8 @@ describe('ReactDOMServerIntegration', () => { TextMore Text , ); - expect(e.childNodes.length).toBe(render === streamRender ? 3 : 2); - const spanNode = e.childNodes[render === streamRender ? 2 : 1]; + expect(e.childNodes.length).toBe(2); + const spanNode = e.childNodes[1]; expectTextNode(e.childNodes[0], 'Text'); expect(spanNode.tagName).toBe('SPAN'); expect(spanNode.childNodes.length).toBe(1); @@ -147,19 +147,19 @@ describe('ReactDOMServerIntegration', () => { itRenders('a custom element with text', async render => { const e = await render(Text); expect(e.tagName).toBe('CUSTOM-ELEMENT'); - expect(e.childNodes.length).toBe(render === streamRender ? 2 : 1); + expect(e.childNodes.length).toBe(1); expectNode(e.firstChild, TEXT_NODE_TYPE, 'Text'); }); itRenders('a leading blank child with a text sibling', async render => { const e = await render(
{''}foo
); - expect(e.childNodes.length).toBe(render === streamRender ? 2 : 1); + expect(e.childNodes.length).toBe(1); expectTextNode(e.childNodes[0], 'foo'); }); itRenders('a trailing blank child with a text sibling', async render => { const e = await render(
foo{''}
); - expect(e.childNodes.length).toBe(render === streamRender ? 2 : 1); + expect(e.childNodes.length).toBe(1); expectTextNode(e.childNodes[0], 'foo'); }); @@ -176,7 +176,7 @@ describe('ReactDOMServerIntegration', () => { render === streamRender ) { // In the server render output there's a comment between them. - expect(e.childNodes.length).toBe(render === streamRender ? 4 : 3); + expect(e.childNodes.length).toBe(3); expectTextNode(e.childNodes[0], 'foo'); expectTextNode(e.childNodes[2], 'bar'); } else { @@ -203,7 +203,7 @@ describe('ReactDOMServerIntegration', () => { render === streamRender ) { // In the server render output there's a comment between them. - expect(e.childNodes.length).toBe(render === streamRender ? 6 : 5); + expect(e.childNodes.length).toBe(5); expectTextNode(e.childNodes[0], 'a'); expectTextNode(e.childNodes[2], 'b'); expectTextNode(e.childNodes[4], 'c'); @@ -240,7 +240,11 @@ describe('ReactDOMServerIntegration', () => { e , ); - if (render === serverRender || render === clientRenderOnServerString) { + if ( + render === serverRender || + render === streamRender || + render === clientRenderOnServerString + ) { // In the server render output there's comments between text nodes. expect(e.childNodes.length).toBe(5); expectTextNode(e.childNodes[0], 'a'); @@ -249,15 +253,6 @@ describe('ReactDOMServerIntegration', () => { expectTextNode(e.childNodes[3].childNodes[0], 'c'); expectTextNode(e.childNodes[3].childNodes[2], 'd'); expectTextNode(e.childNodes[4], 'e'); - } else if (render === streamRender) { - // In the server render output there's comments after each text node. - expect(e.childNodes.length).toBe(7); - expectTextNode(e.childNodes[0], 'a'); - expectTextNode(e.childNodes[2], 'b'); - expect(e.childNodes[4].childNodes.length).toBe(4); - expectTextNode(e.childNodes[4].childNodes[0], 'c'); - expectTextNode(e.childNodes[4].childNodes[2], 'd'); - expectTextNode(e.childNodes[5], 'e'); } else { expect(e.childNodes.length).toBe(4); expectTextNode(e.childNodes[0], 'a'); @@ -296,7 +291,7 @@ describe('ReactDOMServerIntegration', () => { render === streamRender ) { // In the server markup there's a comment between. - expect(e.childNodes.length).toBe(render === streamRender ? 4 : 3); + expect(e.childNodes.length).toBe(3); expectTextNode(e.childNodes[0], 'foo'); expectTextNode(e.childNodes[2], '40'); } else { @@ -335,13 +330,13 @@ describe('ReactDOMServerIntegration', () => { itRenders('null children as blank', async render => { const e = await render(
{null}foo
); - expect(e.childNodes.length).toBe(render === streamRender ? 2 : 1); + expect(e.childNodes.length).toBe(1); expectTextNode(e.childNodes[0], 'foo'); }); itRenders('false children as blank', async render => { const e = await render(
{false}foo
); - expect(e.childNodes.length).toBe(render === streamRender ? 2 : 1); + expect(e.childNodes.length).toBe(1); expectTextNode(e.childNodes[0], 'foo'); }); @@ -353,7 +348,7 @@ describe('ReactDOMServerIntegration', () => { {false} , ); - expect(e.childNodes.length).toBe(render === streamRender ? 2 : 1); + expect(e.childNodes.length).toBe(1); expectTextNode(e.childNodes[0], 'foo'); }); @@ -740,10 +735,10 @@ describe('ReactDOMServerIntegration', () => { , ); expect(e.id).toBe('parent'); - expect(e.childNodes.length).toBe(render === streamRender ? 4 : 3); + expect(e.childNodes.length).toBe(3); const child1 = e.childNodes[0]; const textNode = e.childNodes[1]; - const child2 = e.childNodes[render === streamRender ? 3 : 2]; + const child2 = e.childNodes[2]; expect(child1.id).toBe('child1'); expect(child1.childNodes.length).toBe(0); expectTextNode(textNode, ' '); @@ -757,10 +752,10 @@ describe('ReactDOMServerIntegration', () => { async render => { // prettier-ignore const e = await render(
); // eslint-disable-line no-multi-spaces - expect(e.childNodes.length).toBe(render === streamRender ? 5 : 3); + expect(e.childNodes.length).toBe(3); const textNode1 = e.childNodes[0]; - const child = e.childNodes[render === streamRender ? 2 : 1]; - const textNode2 = e.childNodes[render === streamRender ? 3 : 2]; + const child = e.childNodes[1]; + const textNode2 = e.childNodes[2]; expect(e.id).toBe('parent'); expectTextNode(textNode1, ' '); expect(child.id).toBe('child'); @@ -783,9 +778,7 @@ describe('ReactDOMServerIntegration', () => { ) { // For plain server markup result we have comments between. // If we're able to hydrate, they remain. - expect(parent.childNodes.length).toBe( - render === streamRender ? 6 : 5, - ); + expect(parent.childNodes.length).toBe(5); expectTextNode(parent.childNodes[0], 'a'); expectTextNode(parent.childNodes[2], 'b'); expectTextNode(parent.childNodes[4], 'c'); @@ -817,7 +810,7 @@ describe('ReactDOMServerIntegration', () => { render === clientRenderOnServerString || render === streamRender ) { - expect(e.childNodes.length).toBe(render === streamRender ? 4 : 3); + expect(e.childNodes.length).toBe(3); expectTextNode(e.childNodes[0], 'Text1"'); expectTextNode(e.childNodes[2], 'Text2"'); } else { @@ -868,7 +861,7 @@ describe('ReactDOMServerIntegration', () => { ); if (render === serverRender || render === streamRender) { // We have three nodes because there is a comment between them. - expect(e.childNodes.length).toBe(render === streamRender ? 4 : 3); + expect(e.childNodes.length).toBe(3); // Everything becomes LF when parsed from server HTML. // Null character is ignored. expectNode(e.childNodes[0], TEXT_NODE_TYPE, 'foo\nbar'); diff --git a/packages/react-dom/src/__tests__/ReactDOMUseId-test.js b/packages/react-dom/src/__tests__/ReactDOMUseId-test.js index 4148eeaa2e640..53d972119081b 100644 --- a/packages/react-dom/src/__tests__/ReactDOMUseId-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMUseId-test.js @@ -343,7 +343,6 @@ describe('useId', () => { id="container" > :R0:, :R0H1:, :R0H2: -
`); }); @@ -369,7 +368,6 @@ describe('useId', () => { id="container" > :R0: - `); }); diff --git a/packages/react-dom/src/server/ReactDOMLegacyServerStreamConfig.js b/packages/react-dom/src/server/ReactDOMLegacyServerStreamConfig.js index 55418357f447f..6f471a5552a56 100644 --- a/packages/react-dom/src/server/ReactDOMLegacyServerStreamConfig.js +++ b/packages/react-dom/src/server/ReactDOMLegacyServerStreamConfig.js @@ -24,7 +24,6 @@ export function flushBuffered(destination: Destination) {} export function beginWriting(destination: Destination) {} -let prevWasCommentSegmenter = false; export function writeChunk( destination: Destination, chunk: Chunk | PrecomputedChunk, @@ -36,16 +35,6 @@ export function writeChunkAndReturn( destination: Destination, chunk: Chunk | PrecomputedChunk, ): boolean { - if (prevWasCommentSegmenter) { - prevWasCommentSegmenter = false; - if (chunk[0] !== '<') { - destination.push(''); - } - } - if (chunk === '') { - prevWasCommentSegmenter = true; - return true; - } return destination.push(chunk); } diff --git a/packages/react-dom/src/server/ReactDOMServerFormatConfig.js b/packages/react-dom/src/server/ReactDOMServerFormatConfig.js index c8d6d35ceb6be..06fda6a65709c 100644 --- a/packages/react-dom/src/server/ReactDOMServerFormatConfig.js +++ b/packages/react-dom/src/server/ReactDOMServerFormatConfig.js @@ -284,13 +284,30 @@ export function pushTextInstance( target: Array, text: string, responseState: ResponseState, -): void { + textEmbedded: boolean, +): boolean { if (text === '') { // Empty text doesn't have a DOM node representation and the hydration is aware of this. - return; + return textEmbedded; + } + if (textEmbedded) { + target.push(textSeparator); + } + target.push(stringToChunk(encodeHTMLTextNode(text))); + return true; +} + +// Called when Fizz is done with a Segment. Currently the only purpose is to conditionally +// emit a text separator when we don't know for sure it is safe to omit +export function pushSegmentFinale( + target: Array, + responseState: ResponseState, + lastPushedText: boolean, + textEmbedded: boolean, +): void { + if (lastPushedText && textEmbedded) { + target.push(textSeparator); } - // TODO: Avoid adding a text separator in common cases. - target.push(stringToChunk(encodeHTMLTextNode(text)), textSeparator); } const styleNameCache: Map = new Map(); diff --git a/packages/react-dom/src/server/ReactDOMServerLegacyFormatConfig.js b/packages/react-dom/src/server/ReactDOMServerLegacyFormatConfig.js index c3d09f481fb62..9d430f7b482c1 100644 --- a/packages/react-dom/src/server/ReactDOMServerLegacyFormatConfig.js +++ b/packages/react-dom/src/server/ReactDOMServerLegacyFormatConfig.js @@ -12,6 +12,7 @@ import type {FormatContext} from './ReactDOMServerFormatConfig'; import { createResponseState as createResponseStateImpl, pushTextInstance as pushTextInstanceImpl, + pushSegmentFinale as pushSegmentFinaleImpl, writeStartCompletedSuspenseBoundary as writeStartCompletedSuspenseBoundaryImpl, writeStartClientRenderedSuspenseBoundary as writeStartClientRenderedSuspenseBoundaryImpl, writeEndCompletedSuspenseBoundary as writeEndCompletedSuspenseBoundaryImpl, @@ -105,11 +106,31 @@ export function pushTextInstance( target: Array, text: string, responseState: ResponseState, -): void { + textEmbedded: boolean, +): boolean { if (responseState.generateStaticMarkup) { target.push(stringToChunk(escapeTextForBrowser(text))); + return false; + } else { + return pushTextInstanceImpl(target, text, responseState, textEmbedded); + } +} + +export function pushSegmentFinale( + target: Array, + responseState: ResponseState, + lastPushedText: boolean, + textEmbedded: boolean, +): void { + if (responseState.generateStaticMarkup) { + return; } else { - pushTextInstanceImpl(target, text, responseState); + return pushSegmentFinaleImpl( + target, + responseState, + lastPushedText, + textEmbedded, + ); } } diff --git a/packages/react-native-renderer/src/server/ReactNativeServerFormatConfig.js b/packages/react-native-renderer/src/server/ReactNativeServerFormatConfig.js index e2b5557201637..470fed5ce6d0a 100644 --- a/packages/react-native-renderer/src/server/ReactNativeServerFormatConfig.js +++ b/packages/react-native-renderer/src/server/ReactNativeServerFormatConfig.js @@ -122,7 +122,9 @@ export function pushTextInstance( target: Array, text: string, responseState: ResponseState, -): void { + // This Renderer does not use this argument + textEmbedded: boolean, +): boolean { target.push( INSTANCE, RAW_TEXT, // Type @@ -130,6 +132,7 @@ export function pushTextInstance( // TODO: props { text: text } END, // End of children ); + return false; } export function pushStartInstance( @@ -156,6 +159,14 @@ export function pushEndInstance( target.push(END); } +// In this Renderer this is a noop +export function pushSegmentFinale( + target: Array, + responseState: ResponseState, + lastPushedText: boolean, + textEmbedded: boolean, +): void {} + export function writeCompletedRoot( destination: Destination, responseState: ResponseState, diff --git a/packages/react-noop-renderer/src/ReactNoopServer.js b/packages/react-noop-renderer/src/ReactNoopServer.js index 5bd7e79cf7242..8cbd0c84dba71 100644 --- a/packages/react-noop-renderer/src/ReactNoopServer.js +++ b/packages/react-noop-renderer/src/ReactNoopServer.js @@ -98,12 +98,18 @@ const ReactNoopServer = ReactFizzServer({ return null; }, - pushTextInstance(target: Array, text: string): void { + pushTextInstance( + target: Array, + text: string, + responseState: ResponseState, + textEmbedded: boolean, + ): boolean { const textInstance: TextInstance = { text, hidden: false, }; target.push(Buffer.from(JSON.stringify(textInstance), 'utf8'), POP); + return false; }, pushStartInstance( target: Array, @@ -128,6 +134,14 @@ const ReactNoopServer = ReactFizzServer({ target.push(POP); }, + // This is a noop in ReactNoop + pushSegmentFinale( + target: Array, + responseState: ResponseState, + lastPushedText: boolean, + textEmbedded: boolean, + ): void {}, + writeCompletedRoot( destination: Destination, responseState: ResponseState, diff --git a/packages/react-server-dom-relay/src/__tests__/ReactDOMServerFB-test.internal.js b/packages/react-server-dom-relay/src/__tests__/ReactDOMServerFB-test.internal.js index 7444ae6f90934..8c7c6844ab96f 100644 --- a/packages/react-server-dom-relay/src/__tests__/ReactDOMServerFB-test.internal.js +++ b/packages/react-server-dom-relay/src/__tests__/ReactDOMServerFB-test.internal.js @@ -93,9 +93,7 @@ describe('ReactDOMServerFB', () => { await jest.runAllTimers(); const result = readResult(stream); - expect(result).toMatchInlineSnapshot( - `"
Done
"`, - ); + expect(result).toMatchInlineSnapshot(`"
Done
"`); }); it('should throw an error when an error is thrown at the root', () => { diff --git a/packages/react-server/src/ReactFizzServer.js b/packages/react-server/src/ReactFizzServer.js index d206a69e48c3d..5b0a3de560a66 100644 --- a/packages/react-server/src/ReactFizzServer.js +++ b/packages/react-server/src/ReactFizzServer.js @@ -56,6 +56,7 @@ import { pushEndInstance, pushStartCompletedSuspenseBoundary, pushEndCompletedSuspenseBoundary, + pushSegmentFinale, UNINITIALIZED_SUSPENSE_BOUNDARY_ID, assignSuspenseBoundaryID, getChildFormatContext, @@ -169,6 +170,9 @@ type Segment = { formatContext: FormatContext, // If this segment represents a fallback, this is the content that will replace that fallback. +boundary: null | SuspenseBoundary, + // used to discern when text separator boundaries are needed + lastPushedText: boolean, + textEmbedded: boolean, }; const OPEN = 0; @@ -267,7 +271,15 @@ export function createRequest( onFatalError: onFatalError === undefined ? noop : onFatalError, }; // This segment represents the root fallback. - const rootSegment = createPendingSegment(request, 0, null, rootFormatContext); + const rootSegment = createPendingSegment( + request, + 0, + null, + rootFormatContext, + // Root segments are never embedded in Text on either edge + false, + false, + ); // There is no parent so conceptually, we're unblocked to flush this segment. rootSegment.parentFlushed = true; const rootTask = createTask( @@ -346,6 +358,8 @@ function createPendingSegment( index: number, boundary: null | SuspenseBoundary, formatContext: FormatContext, + lastPushedText: boolean, + textEmbedded: boolean, ): Segment { return { status: PENDING, @@ -356,6 +370,8 @@ function createPendingSegment( children: [], formatContext, boundary, + lastPushedText, + textEmbedded, }; } @@ -459,8 +475,13 @@ function renderSuspenseBoundary( insertionIndex, newBoundary, parentSegment.formatContext, + // boundaries never require text embedding at their edges because comment nodes bound them + false, + false, ); parentSegment.children.push(boundarySegment); + // The parentSegment has a child Segment at this index so we reset the lastPushedText marker on the parent + parentSegment.lastPushedText = false; // This segment is the actual child content. We can start rendering that immediately. const contentRootSegment = createPendingSegment( @@ -468,6 +489,9 @@ function renderSuspenseBoundary( 0, null, parentSegment.formatContext, + // boundaries never require text embedding at their edges because comment nodes bound them + false, + false, ); // We mark the root segment as having its parent flushed. It's not really flushed but there is // no parent segment so there's nothing to wait on. @@ -486,6 +510,12 @@ function renderSuspenseBoundary( try { // We use the safe form because we don't handle suspending here. Only error handling. renderNode(request, task, content); + pushSegmentFinale( + contentRootSegment.chunks, + request.responseState, + contentRootSegment.lastPushedText, + contentRootSegment.textEmbedded, + ); contentRootSegment.status = COMPLETED; queueCompletedSegment(newBoundary, contentRootSegment); if (newBoundary.pendingTasks === 0) { @@ -561,15 +591,18 @@ function renderHostElement( request.responseState, segment.formatContext, ); + segment.lastPushedText = false; const prevContext = segment.formatContext; segment.formatContext = getChildFormatContext(prevContext, type, props); // 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); + // 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. segment.formatContext = prevContext; pushEndInstance(segment.chunks, type, props); + segment.lastPushedText = false; popComponentStackInDEV(task); } @@ -1216,15 +1249,23 @@ function renderNodeDestructive( } if (typeof node === 'string') { - pushTextInstance(task.blockedSegment.chunks, node, request.responseState); + const segment = task.blockedSegment; + segment.lastPushedText = pushTextInstance( + task.blockedSegment.chunks, + node, + request.responseState, + segment.lastPushedText, + ); return; } if (typeof node === 'number') { - pushTextInstance( + const segment = task.blockedSegment; + segment.lastPushedText = pushTextInstance( task.blockedSegment.chunks, '' + node, request.responseState, + segment.lastPushedText, ); return; } @@ -1268,8 +1309,14 @@ function spawnNewSuspendedTask( insertionIndex, null, segment.formatContext, + // Adopt the parent segment's leading text embed + segment.lastPushedText, + // Assume we are text embedded at the trailing edge + true, ); segment.children.push(newSegment); + // Reset lastPushedText for current Segment since the new Segment "consumed" it + segment.lastPushedText = false; const newTask = createTask( request, task.node, @@ -1545,6 +1592,12 @@ function retryTask(request: Request, task: Task): void { // We call the destructive form that mutates this task. That way if something // suspends again, we can reuse the same task instead of spawning a new one. renderNodeDestructive(request, task, task.node); + pushSegmentFinale( + segment.chunks, + request.responseState, + segment.lastPushedText, + segment.textEmbedded, + ); task.abortSet.delete(task); segment.status = COMPLETED; @@ -1625,6 +1678,9 @@ function flushSubtree( // We're emitting a placeholder for this segment to be filled in later. // Therefore we'll need to assign it an ID - to refer to it by. const segmentID = (segment.id = request.nextSegmentId++); + // When this segment finally completes it won't be embedded in text since it will flush separately + segment.lastPushedText = false; + segment.textEmbedded = false; return writePlaceholder(destination, request.responseState, segmentID); } case COMPLETED: { diff --git a/packages/react-server/src/forks/ReactServerFormatConfig.custom.js b/packages/react-server/src/forks/ReactServerFormatConfig.custom.js index 035490fb3f65a..ecb3218ea1dad 100644 --- a/packages/react-server/src/forks/ReactServerFormatConfig.custom.js +++ b/packages/react-server/src/forks/ReactServerFormatConfig.custom.js @@ -43,6 +43,7 @@ export const pushStartCompletedSuspenseBoundary = $$$hostConfig.pushStartCompletedSuspenseBoundary; export const pushEndCompletedSuspenseBoundary = $$$hostConfig.pushEndCompletedSuspenseBoundary; +export const pushSegmentFinale = $$$hostConfig.pushSegmentFinale; export const writeCompletedRoot = $$$hostConfig.writeCompletedRoot; export const writePlaceholder = $$$hostConfig.writePlaceholder; export const writeStartCompletedSuspenseBoundary =