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 (
+
+
+
+ );
+ }
+
+ 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 (
+
+
+
+ );
+ }
+
+ 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 (
+
+
+
+ );
+ }
+
+ 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 (
+
+
+
+ );
+ }
+
+ 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 =