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 =