diff --git a/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js b/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js
index 56ae7c8a09652..a4f9c7c5d6adb 100644
--- a/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js
+++ b/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js
@@ -3795,7 +3795,7 @@ describe('ReactDOMFizzServer', () => {
});
expect(container.firstElementChild.outerHTML).toEqual(
- '
helloworld
',
+ 'helloworld
',
);
const errors = [];
@@ -3938,7 +3938,7 @@ describe('ReactDOMFizzServer', () => {
});
// @gate experimental
- it('(only) includes extraneous text separators in segments that complete before flushing, followed by nothing or a non-Text node', async () => {
+ it('excludes extraneous text separators in segments that complete before flushing, followed by nothing or a non-Text node', async () => {
function App() {
return (
@@ -3970,7 +3970,7 @@ describe('ReactDOMFizzServer', () => {
});
expect(container.innerHTML).toEqual(
- '
helloworldworldhelloworld
world
',
+ '
helloworldworldhelloworld
world
',
);
const errors = [];
@@ -3998,5 +3998,360 @@ describe('ReactDOMFizzServer', () => {
,
);
});
+
+ // @gate experimental
+ it('handles many serial adjacent segments that resolves in arbitrary order', async () => {
+ function NineText() {
+ return (
+ <>
+
+
+
+ >
+ );
+ }
+
+ function ThreeText({start}) {
+ return (
+ <>
+
+
+
+ >
+ );
+ }
+
+ function App() {
+ return (
+
+
+
+
+
+ );
+ }
+
+ await act(async () => {
+ const {pipe} = ReactDOMFizzServer.renderToPipeableStream();
+ await act(() => resolveText(1));
+ await act(() => resolveText(6));
+ await act(() => resolveText(9));
+ await afterImmediate();
+ await act(() => resolveText(2));
+ await act(() => resolveText(5));
+ await act(() => resolveText(7));
+ pipe(writable);
+ });
+
+ expect(container.innerHTML).toEqual(
+ '125679
',
+ );
+
+ await act(async () => {
+ resolveText(3);
+ resolveText(4);
+ resolveText(8);
+ });
+
+ expect(container.firstElementChild.outerHTML).toEqual(
+ '123456789
',
+ );
+
+ const errors = [];
+ ReactDOMClient.hydrateRoot(container, , {
+ onRecoverableError(error) {
+ errors.push(error.message);
+ },
+ });
+ expect(Scheduler).toFlushAndYield([]);
+ expect(errors).toEqual([]);
+ expect(getVisibleChildren(container)).toEqual(
+
+ {'1'}
+ {'2'}
+ {'3'}
+ {'4'}
+ {'5'}
+ {'6'}
+ {'7'}
+ {'8'}
+ {'9'}
+
,
+ );
+ });
+
+ // @gate experimental
+ it('handles deeply nested segments that resolves in arbitrary order', async () => {
+ function RecursiveNumber({from, steps, reverse}) {
+ if (steps === 1) {
+ return readText(from);
+ }
+
+ const num = readText(from);
+
+ return (
+ <>
+ {num}
+
+ >
+ );
+ }
+
+ function App() {
+ return (
+
+
+
+
+
+
+ );
+ }
+
+ await act(async () => {
+ const {pipe} = ReactDOMFizzServer.renderToPipeableStream();
+ await afterImmediate();
+ await act(() => resolveText(1));
+ await act(() => resolveText(2));
+ await act(() => resolveText(4));
+
+ pipe(writable);
+ });
+
+ expect(container.innerHTML).toEqual(
+ '12
',
+ );
+
+ await act(async () => {
+ resolveText(3);
+ resolveText(5);
+ resolveText(6);
+ });
+
+ expect(container.firstElementChild.outerHTML).toEqual(
+ '123654
',
+ );
+
+ const errors = [];
+ ReactDOMClient.hydrateRoot(container, , {
+ onRecoverableError(error) {
+ errors.push(error.message);
+ },
+ });
+ expect(Scheduler).toFlushAndYield([]);
+ expect(errors).toEqual([]);
+ expect(getVisibleChildren(container)).toEqual(
+
+ {'1'}
+ {'2'}
+ {'3'}
+ {'6'}
+ {'5'}
+ {'4'}
+
,
+ );
+ });
+
+ // @gate experimental
+ it('handles segments that return null', async () => {
+ function WrappedAsyncText({outer, text}) {
+ readText(outer);
+ return ;
+ }
+
+ function App() {
+ return (
+
+ );
+ }
+
+ await act(async () => {
+ const {pipe} = ReactDOMFizzServer.renderToPipeableStream();
+ await afterImmediate();
+ await act(() => resolveText('outer2'));
+ await act(() => resolveText('world'));
+ await act(() => resolveText('outer1'));
+ await act(() => resolveText(null));
+ await act(() => resolveText('hello'));
+
+ pipe(writable);
+ });
+
+ const helloWorld = 'helloworld
';
+ const testcases = 6;
+
+ expect(container.firstElementChild.outerHTML).toEqual(
+ '' +
+ new Array(testcases).fill(helloWorld).join('') +
+ '
',
+ );
+
+ const errors = [];
+ ReactDOMClient.hydrateRoot(container, , {
+ onRecoverableError(error) {
+ errors.push(error.message);
+ },
+ });
+ expect(Scheduler).toFlushAndYield([]);
+ expect(errors).toEqual([]);
+ const assertion = () => {
+ expect(getVisibleChildren(container)).toEqual(
+
+ {new Array(testcases).fill(
+
+ {'hello'}
+ {'world'}
+
,
+ )}
+
,
+ );
+ };
+ if (__DEV__) {
+ expect(assertion).toErrorDev([
+ 'Warning: Each child in a list should have a unique "key" prop.',
+ ]);
+ } else {
+ assertion();
+ }
+ });
+
+ // @gate experimental
+ it('does not add separators when otherwise adjacent text is wrapped in Suspense', 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(
+
+ {'hello'}
+ {'world'}
+
,
+ );
+ });
+
+ // @gate experimental
+ it('does not prepend separators for Suspense fallback text but will append them if followed by text', async () => {
+ function App() {
+ return (
+
+
+ hello
+
+ !
+ >
+ }>
+
+
+ !
+
+
+ );
+ }
+
+ await act(async () => {
+ const {pipe} = ReactDOMFizzServer.renderToPipeableStream();
+ await afterImmediate();
+ await act(() => resolveText('foo'));
+ pipe(writable);
+ });
+
+ expect(container.innerHTML).toEqual(
+ 'outer
hello!foo!
',
+ );
+
+ await act(() => resolveText('world'));
+
+ expect(container.children[0].outerHTML).toEqual(
+ 'helloworld!foo!
',
+ );
+
+ const errors = [];
+ ReactDOMClient.hydrateRoot(container, , {
+ onRecoverableError(error) {
+ errors.push(error.message);
+ },
+ });
+ expect(Scheduler).toFlushAndYield([]);
+ expect(errors).toEqual([]);
+ expect(getVisibleChildren(container)).toEqual(
+
+ {'hello'}
+ {/* starting the inner Suspense boundary Fallback */}
+ {'world'}
+ {'!'}
+ {'foo'}
+ {/* ending the inner Suspense boundary Fallback */}
+ {'!'}
+
,
+ );
+ });
});
});
diff --git a/packages/react-dom/src/__tests__/ReactDOMFizzServerBrowser-test.js b/packages/react-dom/src/__tests__/ReactDOMFizzServerBrowser-test.js
index 792b9611f29f2..11ef1ee1823c6 100644
--- a/packages/react-dom/src/__tests__/ReactDOMFizzServerBrowser-test.js
+++ b/packages/react-dom/src/__tests__/ReactDOMFizzServerBrowser-test.js
@@ -119,9 +119,7 @@ describe('ReactDOMFizzServer', () => {
expect(isComplete).toBe(true);
const result = await readResult(stream);
- expect(result).toMatchInlineSnapshot(
- `"Done
"`,
- );
+ expect(result).toMatchInlineSnapshot(`"Done
"`);
});
// @gate experimental
diff --git a/packages/react-dom/src/__tests__/ReactDOMFizzServerNode-test.js b/packages/react-dom/src/__tests__/ReactDOMFizzServerNode-test.js
index c1844d7ef78bf..9de3b17b0a21a 100644
--- a/packages/react-dom/src/__tests__/ReactDOMFizzServerNode-test.js
+++ b/packages/react-dom/src/__tests__/ReactDOMFizzServerNode-test.js
@@ -161,7 +161,7 @@ describe('ReactDOMFizzServer', () => {
// Then React starts writing.
pipe(writable);
expect(output.result).toMatchInlineSnapshot(
- `"testDone
"`,
+ `"testDone
"`,
);
});
diff --git a/packages/react-dom/src/server/ReactDOMServerFormatConfig.js b/packages/react-dom/src/server/ReactDOMServerFormatConfig.js
index 06fda6a65709c..00499f13050c7 100644
--- a/packages/react-dom/src/server/ReactDOMServerFormatConfig.js
+++ b/packages/react-dom/src/server/ReactDOMServerFormatConfig.js
@@ -280,6 +280,7 @@ function encodeHTMLTextNode(text: string): string {
const textSeparator = stringToPrecomputedChunk('');
+// Returns a boolean signifying whether text was actually pushed.
export function pushTextInstance(
target: Array,
text: string,
@@ -288,7 +289,7 @@ export function pushTextInstance(
): boolean {
if (text === '') {
// Empty text doesn't have a DOM node representation and the hydration is aware of this.
- return textEmbedded;
+ return false;
}
if (textEmbedded) {
target.push(textSeparator);
@@ -297,19 +298,6 @@ export function pushTextInstance(
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);
- }
-}
-
const styleNameCache: Map = new Map();
function processStyleName(styleName: string): PrecomputedChunk {
const chunk = styleNameCache.get(styleName);
@@ -1713,6 +1701,13 @@ export function writeEndSegment(
}
}
+export function writeTextSeparator(
+ destination: Destination,
+ responseState: ResponseState,
+): boolean {
+ return writeChunkAndReturn(destination, textSeparator);
+}
+
// Instruction Set
// The following code is the source scripts that we then minify and inline below,
diff --git a/packages/react-dom/src/server/ReactDOMServerLegacyFormatConfig.js b/packages/react-dom/src/server/ReactDOMServerLegacyFormatConfig.js
index 9d430f7b482c1..e64d46eeffb9f 100644
--- a/packages/react-dom/src/server/ReactDOMServerLegacyFormatConfig.js
+++ b/packages/react-dom/src/server/ReactDOMServerLegacyFormatConfig.js
@@ -12,11 +12,11 @@ import type {FormatContext} from './ReactDOMServerFormatConfig';
import {
createResponseState as createResponseStateImpl,
pushTextInstance as pushTextInstanceImpl,
- pushSegmentFinale as pushSegmentFinaleImpl,
writeStartCompletedSuspenseBoundary as writeStartCompletedSuspenseBoundaryImpl,
writeStartClientRenderedSuspenseBoundary as writeStartClientRenderedSuspenseBoundaryImpl,
writeEndCompletedSuspenseBoundary as writeEndCompletedSuspenseBoundaryImpl,
writeEndClientRenderedSuspenseBoundary as writeEndClientRenderedSuspenseBoundaryImpl,
+ writeTextSeparator as writeTextSeparatorImpl,
HTML_MODE,
} from './ReactDOMServerFormatConfig';
@@ -116,24 +116,6 @@ export function pushTextInstance(
}
}
-export function pushSegmentFinale(
- target: Array,
- responseState: ResponseState,
- lastPushedText: boolean,
- textEmbedded: boolean,
-): void {
- if (responseState.generateStaticMarkup) {
- return;
- } else {
- return pushSegmentFinaleImpl(
- target,
- responseState,
- lastPushedText,
- textEmbedded,
- );
- }
-}
-
export function writeStartCompletedSuspenseBoundary(
destination: Destination,
responseState: ResponseState,
@@ -177,3 +159,12 @@ export function writeEndClientRenderedSuspenseBoundary(
}
return writeEndClientRenderedSuspenseBoundaryImpl(destination, responseState);
}
+export function writeTextSeparator(
+ destination: Destination,
+ responseState: ResponseState,
+): boolean {
+ if (responseState.generateStaticMarkup) {
+ return true;
+ }
+ return writeTextSeparatorImpl(destination, responseState);
+}
diff --git a/packages/react-native-renderer/src/server/ReactNativeServerFormatConfig.js b/packages/react-native-renderer/src/server/ReactNativeServerFormatConfig.js
index 470fed5ce6d0a..265ca1caf0912 100644
--- a/packages/react-native-renderer/src/server/ReactNativeServerFormatConfig.js
+++ b/packages/react-native-renderer/src/server/ReactNativeServerFormatConfig.js
@@ -159,14 +159,6 @@ 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,
@@ -267,6 +259,12 @@ export function writeEndSegment(
): boolean {
return writeChunkAndReturn(destination, END);
}
+export function writeTextSeparator(
+ destination: Destination,
+ responseState: ResponseState,
+): boolean {
+ return true;
+}
// Instruction Set
diff --git a/packages/react-noop-renderer/src/ReactNoopServer.js b/packages/react-noop-renderer/src/ReactNoopServer.js
index 8cbd0c84dba71..f0afe0e2b7066 100644
--- a/packages/react-noop-renderer/src/ReactNoopServer.js
+++ b/packages/react-noop-renderer/src/ReactNoopServer.js
@@ -134,14 +134,6 @@ 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,
@@ -220,6 +212,8 @@ const ReactNoopServer = ReactFizzServer({
destination.stack.pop();
},
+ writeTextSeparator(destination: Destination, responseState: ResponseState) {},
+
writeCompletedSegmentInstruction(
destination: Destination,
responseState: ResponseState,
diff --git a/packages/react-server/src/ReactFizzServer.js b/packages/react-server/src/ReactFizzServer.js
index 5b0a3de560a66..8780f12e48dbf 100644
--- a/packages/react-server/src/ReactFizzServer.js
+++ b/packages/react-server/src/ReactFizzServer.js
@@ -48,6 +48,7 @@ import {
writeEndClientRenderedSuspenseBoundary,
writeStartSegment,
writeEndSegment,
+ writeTextSeparator,
writeClientRenderBoundaryInstruction,
writeCompletedBoundaryInstruction,
writeCompletedSegmentInstruction,
@@ -56,7 +57,6 @@ import {
pushEndInstance,
pushStartCompletedSuspenseBoundary,
pushEndCompletedSuspenseBoundary,
- pushSegmentFinale,
UNINITIALIZED_SUSPENSE_BOUNDARY_ID,
assignSuspenseBoundaryID,
getChildFormatContext,
@@ -159,6 +159,12 @@ const ERRORED = 4;
type Root = null;
+const UNKNOWN_EDGE = 0;
+const SEGMENT_EDGE = 1;
+const NODE_EDGE = 2;
+const TEXT_EDGE = 3;
+type SegmentEdge = 0 | 1 | 2 | 3;
+
type Segment = {
status: 0 | 1 | 2 | 3 | 4,
parentFlushed: boolean, // typically a segment will be flushed by its parent, except if its parent was already flushed
@@ -171,8 +177,10 @@ type Segment = {
// 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,
+ preEdge: SegmentEdge,
+ leadEdge: SegmentEdge,
+ currentEdge: SegmentEdge,
+ postEdge: SegmentEdge,
};
const OPEN = 0;
@@ -276,9 +284,8 @@ export function createRequest(
0,
null,
rootFormatContext,
- // Root segments are never embedded in Text on either edge
- false,
- false,
+ // Root segments are never embedded in anything but NODE_EDGE has the semantics we need
+ NODE_EDGE,
);
// There is no parent so conceptually, we're unblocked to flush this segment.
rootSegment.parentFlushed = true;
@@ -358,20 +365,22 @@ function createPendingSegment(
index: number,
boundary: null | SuspenseBoundary,
formatContext: FormatContext,
- lastPushedText: boolean,
- textEmbedded: boolean,
+ currentEdge: SegmentEdge,
): Segment {
return {
status: PENDING,
id: -1, // lazily assigned later
+ name: ((Math.random() * 100) | 0).toString(16),
index,
parentFlushed: false,
chunks: [],
children: [],
formatContext,
boundary,
- lastPushedText,
- textEmbedded,
+ preEdge: currentEdge,
+ leadEdge: UNKNOWN_EDGE,
+ currentEdge: UNKNOWN_EDGE,
+ postEdge: UNKNOWN_EDGE,
};
}
@@ -459,6 +468,10 @@ function renderSuspenseBoundary(
const parentBoundary = task.blockedBoundary;
const parentSegment = task.blockedSegment;
+ // Regardless of whether the boundary segment renders text at it's edges it
+ // will be wrapped in comment nodes so we can set the currentEdge to NODE_EDGE
+ parentSegment.currentEdge = NODE_EDGE;
+
// Each time we enter a suspense boundary, we split out into a new segment for
// the fallback so that we can later replace that segment with the content.
// This also lets us split out the main content even if it doesn't suspend,
@@ -475,13 +488,11 @@ function renderSuspenseBoundary(
insertionIndex,
newBoundary,
parentSegment.formatContext,
- // boundaries never require text embedding at their edges because comment nodes bound them
- false,
- false,
+ // Boudnaries are wrapped in comment nodes so regardless of the parent segment's currentEdge we
+ // use a NODE_EDGE here
+ NODE_EDGE,
);
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(
@@ -489,9 +500,9 @@ function renderSuspenseBoundary(
0,
null,
parentSegment.formatContext,
- // boundaries never require text embedding at their edges because comment nodes bound them
- false,
- false,
+ // Boudnaries are wrapped in comment nodes so regardless of the parent segment's currentEdge we
+ // use a NODE_EDGE here
+ NODE_EDGE,
);
// 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.
@@ -510,12 +521,6 @@ 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) {
@@ -584,6 +589,12 @@ function renderHostElement(
): void {
pushBuiltInComponentStackInDEV(task, type);
const segment = task.blockedSegment;
+ let immediatelyPrecedingChildSegment =
+ segment.children.length > 0 &&
+ segment.children[segment.children.length - 1].index ===
+ segment.chunks.length
+ ? segment.children[segment.children.length - 1]
+ : null;
const children = pushStartInstance(
segment.chunks,
type,
@@ -591,7 +602,13 @@ function renderHostElement(
request.responseState,
segment.formatContext,
);
- segment.lastPushedText = false;
+ if (segment.leadEdge === UNKNOWN_EDGE) {
+ segment.leadEdge = NODE_EDGE;
+ }
+ if (immediatelyPrecedingChildSegment) {
+ immediatelyPrecedingChildSegment.postEdge = NODE_EDGE;
+ }
+ segment.currentEdge = NODE_EDGE;
const prevContext = segment.formatContext;
segment.formatContext = getChildFormatContext(prevContext, type, props);
// We use the non-destructive form because if something suspends, we still
@@ -601,8 +618,17 @@ function renderHostElement(
// 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;
+ immediatelyPrecedingChildSegment =
+ segment.children.length > 0 &&
+ segment.children[segment.children.length - 1].index ===
+ segment.chunks.length
+ ? segment.children[segment.children.length - 1]
+ : null;
+ if (immediatelyPrecedingChildSegment) {
+ immediatelyPrecedingChildSegment.postEdge = NODE_EDGE;
+ }
pushEndInstance(segment.chunks, type, props);
- segment.lastPushedText = false;
+ segment.currentEdge = NODE_EDGE;
popComponentStackInDEV(task);
}
@@ -1250,23 +1276,55 @@ function renderNodeDestructive(
if (typeof node === 'string') {
const segment = task.blockedSegment;
- segment.lastPushedText = pushTextInstance(
- task.blockedSegment.chunks,
- node,
- request.responseState,
- segment.lastPushedText,
- );
+ const immediatelyPrecedingChildSegment =
+ segment.children.length > 0 &&
+ segment.children[segment.children.length - 1].index ===
+ segment.chunks.length
+ ? segment.children[segment.children.length - 1]
+ : null;
+ if (
+ pushTextInstance(
+ task.blockedSegment.chunks,
+ node,
+ request.responseState,
+ segment.currentEdge === TEXT_EDGE,
+ )
+ ) {
+ if (segment.leadEdge === UNKNOWN_EDGE) {
+ segment.leadEdge = TEXT_EDGE;
+ }
+ if (immediatelyPrecedingChildSegment) {
+ immediatelyPrecedingChildSegment.postEdge = TEXT_EDGE;
+ }
+ segment.currentEdge = TEXT_EDGE;
+ }
return;
}
if (typeof node === 'number') {
const segment = task.blockedSegment;
- segment.lastPushedText = pushTextInstance(
- task.blockedSegment.chunks,
- '' + node,
- request.responseState,
- segment.lastPushedText,
- );
+ const immediatelyPrecedingChildSegment =
+ segment.children.length > 0 &&
+ segment.children[segment.children.length - 1].index ===
+ segment.chunks.length
+ ? segment.children[segment.children.length - 1]
+ : null;
+ if (
+ pushTextInstance(
+ task.blockedSegment.chunks,
+ '' + node,
+ request.responseState,
+ segment.currentEdge === TEXT_EDGE,
+ )
+ ) {
+ if (segment.leadEdge === UNKNOWN_EDGE) {
+ segment.leadEdge = TEXT_EDGE;
+ }
+ if (immediatelyPrecedingChildSegment) {
+ immediatelyPrecedingChildSegment.postEdge = TEXT_EDGE;
+ }
+ segment.currentEdge = TEXT_EDGE;
+ }
return;
}
@@ -1310,13 +1368,13 @@ function spawnNewSuspendedTask(
null,
segment.formatContext,
// Adopt the parent segment's leading text embed
- segment.lastPushedText,
- // Assume we are text embedded at the trailing edge
- true,
+ segment.currentEdge,
);
+ if (segment.leadEdge === UNKNOWN_EDGE) {
+ segment.leadEdge = SEGMENT_EDGE;
+ }
+ segment.currentEdge = SEGMENT_EDGE;
segment.children.push(newSegment);
- // Reset lastPushedText for current Segment since the new Segment "consumed" it
- segment.lastPushedText = false;
const newTask = createTask(
request,
task.node,
@@ -1592,15 +1650,8 @@ 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;
+ task.abortSet.delete(task);
finishedTask(request, task.blockedBoundary, segment);
} catch (x) {
resetHooksState();
@@ -1679,8 +1730,9 @@ function flushSubtree(
// 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;
+ // The currentEdge of a Pending Segment is the Edge just before the Segment begins because we have not
+ // retried the task for this segment yet.
+ segment.currentEdge = NODE_EDGE;
return writePlaceholder(destination, request.responseState, segmentID);
}
case COMPLETED: {
@@ -1695,7 +1747,7 @@ function flushSubtree(
for (; chunkIdx < nextChild.index; chunkIdx++) {
writeChunk(destination, chunks[chunkIdx]);
}
- r = flushSegment(request, destination, nextChild);
+ r = flushSegmentInline(request, destination, nextChild);
}
// Finally just write all the remaining chunks
for (; chunkIdx < chunks.length - 1; chunkIdx++) {
@@ -1714,16 +1766,61 @@ function flushSubtree(
}
}
+let lastText = false;
+
function flushSegment(
request: Request,
destination,
segment: Segment,
+): boolean {
+ lastText = false;
+ return flushSegmentInline(request, destination, segment);
+}
+
+function flushSegmentInline(
+ request: Request,
+ destination,
+ segment: Segment,
): boolean {
const boundary = segment.boundary;
if (boundary === null) {
// Not a suspense boundary.
- return flushSubtree(request, destination, segment);
+
+ // If parent not flushed we are flushing inside another segment and need to consider text separator
+ // insertion. If parent is flushed we are a top-level Segment that will be emitted inside non-text
+ // nodes and can avoid separators regardless of what edges the segment derived when it was rendering
+ const parentFlushed = segment.parentFlushed;
+
+ // We might have emitted Text or Nodes since the last segment to flush. set the value accordingly or
+ // retain the existing value if no Edges were emitted
+ const precedingEdge = segment.preEdge;
+ lastText =
+ precedingEdge === TEXT_EDGE
+ ? true
+ : precedingEdge === NODE_EDGE
+ ? false
+ : lastText;
+ if (parentFlushed === false && lastText && segment.leadEdge === TEXT_EDGE) {
+ // This Segment has a Text embedding at the leading edge and we need a separator
+ writeTextSeparator(destination, request.responseState);
+ }
+ let r = flushSubtree(request, destination, segment);
+
+ // We might have emitted Text or Nodes following this segment. set the value accordingly or
+ // retain teh existing valeu if no Edges were emitted
+ const lastEdge = segment.currentEdge;
+ lastText =
+ lastEdge === TEXT_EDGE ? true : lastEdge === NODE_EDGE ? false : lastText;
+ if (parentFlushed === false && lastText && segment.postEdge === TEXT_EDGE) {
+ // This Segment has a Text embedding at the trailing edge and we need a separator
+ r = writeTextSeparator(destination, request.responseState);
+ }
+
+ return r;
}
+ // Boundaries will always write non-Text last
+ lastText = false;
+
boundary.parentFlushed = true;
// This segment is a Suspense boundary. We need to decide whether to
// emit the content or the fallback now.
@@ -1797,7 +1894,7 @@ function flushSegment(
}
const contentSegment = completedSegments[0];
- flushSegment(request, destination, contentSegment);
+ flushSegmentInline(request, destination, contentSegment);
return writeEndCompletedSuspenseBoundary(
destination,
diff --git a/packages/react-server/src/forks/ReactServerFormatConfig.custom.js b/packages/react-server/src/forks/ReactServerFormatConfig.custom.js
index ecb3218ea1dad..dcfa63b13377a 100644
--- a/packages/react-server/src/forks/ReactServerFormatConfig.custom.js
+++ b/packages/react-server/src/forks/ReactServerFormatConfig.custom.js
@@ -43,7 +43,6 @@ 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 =
@@ -60,6 +59,7 @@ export const writeEndClientRenderedSuspenseBoundary =
$$$hostConfig.writeEndClientRenderedSuspenseBoundary;
export const writeStartSegment = $$$hostConfig.writeStartSegment;
export const writeEndSegment = $$$hostConfig.writeEndSegment;
+export const writeTextSeparator = $$$hostConfig.writeTextSeparator;
export const writeCompletedSegmentInstruction =
$$$hostConfig.writeCompletedSegmentInstruction;
export const writeCompletedBoundaryInstruction =