Skip to content

Commit a276638

Browse files
authored
[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
1 parent f786053 commit a276638

11 files changed

+579
-59
lines changed

packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js

+422
Large diffs are not rendered by default.

packages/react-dom/src/__tests__/ReactDOMServerIntegrationElements-test.js

+25-32
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,7 @@ describe('ReactDOMServerIntegration', () => {
101101
) {
102102
// For plain server markup result we have comments between.
103103
// If we're able to hydrate, they remain.
104-
expect(e.childNodes.length).toBe(render === streamRender ? 6 : 5);
104+
expect(e.childNodes.length).toBe(5);
105105
expectTextNode(e.childNodes[0], ' ');
106106
expectTextNode(e.childNodes[2], ' ');
107107
expectTextNode(e.childNodes[4], ' ');
@@ -119,8 +119,8 @@ describe('ReactDOMServerIntegration', () => {
119119
Text<span>More Text</span>
120120
</div>,
121121
);
122-
expect(e.childNodes.length).toBe(render === streamRender ? 3 : 2);
123-
const spanNode = e.childNodes[render === streamRender ? 2 : 1];
122+
expect(e.childNodes.length).toBe(2);
123+
const spanNode = e.childNodes[1];
124124
expectTextNode(e.childNodes[0], 'Text');
125125
expect(spanNode.tagName).toBe('SPAN');
126126
expect(spanNode.childNodes.length).toBe(1);
@@ -147,19 +147,19 @@ describe('ReactDOMServerIntegration', () => {
147147
itRenders('a custom element with text', async render => {
148148
const e = await render(<custom-element>Text</custom-element>);
149149
expect(e.tagName).toBe('CUSTOM-ELEMENT');
150-
expect(e.childNodes.length).toBe(render === streamRender ? 2 : 1);
150+
expect(e.childNodes.length).toBe(1);
151151
expectNode(e.firstChild, TEXT_NODE_TYPE, 'Text');
152152
});
153153

154154
itRenders('a leading blank child with a text sibling', async render => {
155155
const e = await render(<div>{''}foo</div>);
156-
expect(e.childNodes.length).toBe(render === streamRender ? 2 : 1);
156+
expect(e.childNodes.length).toBe(1);
157157
expectTextNode(e.childNodes[0], 'foo');
158158
});
159159

160160
itRenders('a trailing blank child with a text sibling', async render => {
161161
const e = await render(<div>foo{''}</div>);
162-
expect(e.childNodes.length).toBe(render === streamRender ? 2 : 1);
162+
expect(e.childNodes.length).toBe(1);
163163
expectTextNode(e.childNodes[0], 'foo');
164164
});
165165

@@ -176,7 +176,7 @@ describe('ReactDOMServerIntegration', () => {
176176
render === streamRender
177177
) {
178178
// In the server render output there's a comment between them.
179-
expect(e.childNodes.length).toBe(render === streamRender ? 4 : 3);
179+
expect(e.childNodes.length).toBe(3);
180180
expectTextNode(e.childNodes[0], 'foo');
181181
expectTextNode(e.childNodes[2], 'bar');
182182
} else {
@@ -203,7 +203,7 @@ describe('ReactDOMServerIntegration', () => {
203203
render === streamRender
204204
) {
205205
// In the server render output there's a comment between them.
206-
expect(e.childNodes.length).toBe(render === streamRender ? 6 : 5);
206+
expect(e.childNodes.length).toBe(5);
207207
expectTextNode(e.childNodes[0], 'a');
208208
expectTextNode(e.childNodes[2], 'b');
209209
expectTextNode(e.childNodes[4], 'c');
@@ -240,7 +240,11 @@ describe('ReactDOMServerIntegration', () => {
240240
e
241241
</div>,
242242
);
243-
if (render === serverRender || render === clientRenderOnServerString) {
243+
if (
244+
render === serverRender ||
245+
render === streamRender ||
246+
render === clientRenderOnServerString
247+
) {
244248
// In the server render output there's comments between text nodes.
245249
expect(e.childNodes.length).toBe(5);
246250
expectTextNode(e.childNodes[0], 'a');
@@ -249,15 +253,6 @@ describe('ReactDOMServerIntegration', () => {
249253
expectTextNode(e.childNodes[3].childNodes[0], 'c');
250254
expectTextNode(e.childNodes[3].childNodes[2], 'd');
251255
expectTextNode(e.childNodes[4], 'e');
252-
} else if (render === streamRender) {
253-
// In the server render output there's comments after each text node.
254-
expect(e.childNodes.length).toBe(7);
255-
expectTextNode(e.childNodes[0], 'a');
256-
expectTextNode(e.childNodes[2], 'b');
257-
expect(e.childNodes[4].childNodes.length).toBe(4);
258-
expectTextNode(e.childNodes[4].childNodes[0], 'c');
259-
expectTextNode(e.childNodes[4].childNodes[2], 'd');
260-
expectTextNode(e.childNodes[5], 'e');
261256
} else {
262257
expect(e.childNodes.length).toBe(4);
263258
expectTextNode(e.childNodes[0], 'a');
@@ -296,7 +291,7 @@ describe('ReactDOMServerIntegration', () => {
296291
render === streamRender
297292
) {
298293
// In the server markup there's a comment between.
299-
expect(e.childNodes.length).toBe(render === streamRender ? 4 : 3);
294+
expect(e.childNodes.length).toBe(3);
300295
expectTextNode(e.childNodes[0], 'foo');
301296
expectTextNode(e.childNodes[2], '40');
302297
} else {
@@ -335,13 +330,13 @@ describe('ReactDOMServerIntegration', () => {
335330

336331
itRenders('null children as blank', async render => {
337332
const e = await render(<div>{null}foo</div>);
338-
expect(e.childNodes.length).toBe(render === streamRender ? 2 : 1);
333+
expect(e.childNodes.length).toBe(1);
339334
expectTextNode(e.childNodes[0], 'foo');
340335
});
341336

342337
itRenders('false children as blank', async render => {
343338
const e = await render(<div>{false}foo</div>);
344-
expect(e.childNodes.length).toBe(render === streamRender ? 2 : 1);
339+
expect(e.childNodes.length).toBe(1);
345340
expectTextNode(e.childNodes[0], 'foo');
346341
});
347342

@@ -353,7 +348,7 @@ describe('ReactDOMServerIntegration', () => {
353348
{false}
354349
</div>,
355350
);
356-
expect(e.childNodes.length).toBe(render === streamRender ? 2 : 1);
351+
expect(e.childNodes.length).toBe(1);
357352
expectTextNode(e.childNodes[0], 'foo');
358353
});
359354

@@ -740,10 +735,10 @@ describe('ReactDOMServerIntegration', () => {
740735
</div>,
741736
);
742737
expect(e.id).toBe('parent');
743-
expect(e.childNodes.length).toBe(render === streamRender ? 4 : 3);
738+
expect(e.childNodes.length).toBe(3);
744739
const child1 = e.childNodes[0];
745740
const textNode = e.childNodes[1];
746-
const child2 = e.childNodes[render === streamRender ? 3 : 2];
741+
const child2 = e.childNodes[2];
747742
expect(child1.id).toBe('child1');
748743
expect(child1.childNodes.length).toBe(0);
749744
expectTextNode(textNode, ' ');
@@ -757,10 +752,10 @@ describe('ReactDOMServerIntegration', () => {
757752
async render => {
758753
// prettier-ignore
759754
const e = await render(<div id="parent"> <div id="child" /> </div>); // eslint-disable-line no-multi-spaces
760-
expect(e.childNodes.length).toBe(render === streamRender ? 5 : 3);
755+
expect(e.childNodes.length).toBe(3);
761756
const textNode1 = e.childNodes[0];
762-
const child = e.childNodes[render === streamRender ? 2 : 1];
763-
const textNode2 = e.childNodes[render === streamRender ? 3 : 2];
757+
const child = e.childNodes[1];
758+
const textNode2 = e.childNodes[2];
764759
expect(e.id).toBe('parent');
765760
expectTextNode(textNode1, ' ');
766761
expect(child.id).toBe('child');
@@ -783,9 +778,7 @@ describe('ReactDOMServerIntegration', () => {
783778
) {
784779
// For plain server markup result we have comments between.
785780
// If we're able to hydrate, they remain.
786-
expect(parent.childNodes.length).toBe(
787-
render === streamRender ? 6 : 5,
788-
);
781+
expect(parent.childNodes.length).toBe(5);
789782
expectTextNode(parent.childNodes[0], 'a');
790783
expectTextNode(parent.childNodes[2], 'b');
791784
expectTextNode(parent.childNodes[4], 'c');
@@ -817,7 +810,7 @@ describe('ReactDOMServerIntegration', () => {
817810
render === clientRenderOnServerString ||
818811
render === streamRender
819812
) {
820-
expect(e.childNodes.length).toBe(render === streamRender ? 4 : 3);
813+
expect(e.childNodes.length).toBe(3);
821814
expectTextNode(e.childNodes[0], '<span>Text1&quot;</span>');
822815
expectTextNode(e.childNodes[2], '<span>Text2&quot;</span>');
823816
} else {
@@ -868,7 +861,7 @@ describe('ReactDOMServerIntegration', () => {
868861
);
869862
if (render === serverRender || render === streamRender) {
870863
// We have three nodes because there is a comment between them.
871-
expect(e.childNodes.length).toBe(render === streamRender ? 4 : 3);
864+
expect(e.childNodes.length).toBe(3);
872865
// Everything becomes LF when parsed from server HTML.
873866
// Null character is ignored.
874867
expectNode(e.childNodes[0], TEXT_NODE_TYPE, 'foo\nbar');

packages/react-dom/src/__tests__/ReactDOMUseId-test.js

-2
Original file line numberDiff line numberDiff line change
@@ -343,7 +343,6 @@ describe('useId', () => {
343343
id="container"
344344
>
345345
:R0:, :R0H1:, :R0H2:
346-
<!-- -->
347346
</div>
348347
`);
349348
});
@@ -369,7 +368,6 @@ describe('useId', () => {
369368
id="container"
370369
>
371370
:R0:
372-
<!-- -->
373371
</div>
374372
`);
375373
});

packages/react-dom/src/server/ReactDOMLegacyServerStreamConfig.js

-11
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,6 @@ export function flushBuffered(destination: Destination) {}
2424

2525
export function beginWriting(destination: Destination) {}
2626

27-
let prevWasCommentSegmenter = false;
2827
export function writeChunk(
2928
destination: Destination,
3029
chunk: Chunk | PrecomputedChunk,
@@ -36,16 +35,6 @@ export function writeChunkAndReturn(
3635
destination: Destination,
3736
chunk: Chunk | PrecomputedChunk,
3837
): boolean {
39-
if (prevWasCommentSegmenter) {
40-
prevWasCommentSegmenter = false;
41-
if (chunk[0] !== '<') {
42-
destination.push('<!-- -->');
43-
}
44-
}
45-
if (chunk === '<!-- -->') {
46-
prevWasCommentSegmenter = true;
47-
return true;
48-
}
4938
return destination.push(chunk);
5039
}
5140

packages/react-dom/src/server/ReactDOMServerFormatConfig.js

+21-4
Original file line numberDiff line numberDiff line change
@@ -284,13 +284,30 @@ export function pushTextInstance(
284284
target: Array<Chunk | PrecomputedChunk>,
285285
text: string,
286286
responseState: ResponseState,
287-
): void {
287+
textEmbedded: boolean,
288+
): boolean {
288289
if (text === '') {
289290
// Empty text doesn't have a DOM node representation and the hydration is aware of this.
290-
return;
291+
return textEmbedded;
292+
}
293+
if (textEmbedded) {
294+
target.push(textSeparator);
295+
}
296+
target.push(stringToChunk(encodeHTMLTextNode(text)));
297+
return true;
298+
}
299+
300+
// Called when Fizz is done with a Segment. Currently the only purpose is to conditionally
301+
// emit a text separator when we don't know for sure it is safe to omit
302+
export function pushSegmentFinale(
303+
target: Array<Chunk | PrecomputedChunk>,
304+
responseState: ResponseState,
305+
lastPushedText: boolean,
306+
textEmbedded: boolean,
307+
): void {
308+
if (lastPushedText && textEmbedded) {
309+
target.push(textSeparator);
291310
}
292-
// TODO: Avoid adding a text separator in common cases.
293-
target.push(stringToChunk(encodeHTMLTextNode(text)), textSeparator);
294311
}
295312

296313
const styleNameCache: Map<string, PrecomputedChunk> = new Map();

packages/react-dom/src/server/ReactDOMServerLegacyFormatConfig.js

+23-2
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import type {FormatContext} from './ReactDOMServerFormatConfig';
1212
import {
1313
createResponseState as createResponseStateImpl,
1414
pushTextInstance as pushTextInstanceImpl,
15+
pushSegmentFinale as pushSegmentFinaleImpl,
1516
writeStartCompletedSuspenseBoundary as writeStartCompletedSuspenseBoundaryImpl,
1617
writeStartClientRenderedSuspenseBoundary as writeStartClientRenderedSuspenseBoundaryImpl,
1718
writeEndCompletedSuspenseBoundary as writeEndCompletedSuspenseBoundaryImpl,
@@ -105,11 +106,31 @@ export function pushTextInstance(
105106
target: Array<Chunk | PrecomputedChunk>,
106107
text: string,
107108
responseState: ResponseState,
108-
): void {
109+
textEmbedded: boolean,
110+
): boolean {
109111
if (responseState.generateStaticMarkup) {
110112
target.push(stringToChunk(escapeTextForBrowser(text)));
113+
return false;
114+
} else {
115+
return pushTextInstanceImpl(target, text, responseState, textEmbedded);
116+
}
117+
}
118+
119+
export function pushSegmentFinale(
120+
target: Array<Chunk | PrecomputedChunk>,
121+
responseState: ResponseState,
122+
lastPushedText: boolean,
123+
textEmbedded: boolean,
124+
): void {
125+
if (responseState.generateStaticMarkup) {
126+
return;
111127
} else {
112-
pushTextInstanceImpl(target, text, responseState);
128+
return pushSegmentFinaleImpl(
129+
target,
130+
responseState,
131+
lastPushedText,
132+
textEmbedded,
133+
);
113134
}
114135
}
115136

packages/react-native-renderer/src/server/ReactNativeServerFormatConfig.js

+12-1
Original file line numberDiff line numberDiff line change
@@ -122,14 +122,17 @@ export function pushTextInstance(
122122
target: Array<Chunk | PrecomputedChunk>,
123123
text: string,
124124
responseState: ResponseState,
125-
): void {
125+
// This Renderer does not use this argument
126+
textEmbedded: boolean,
127+
): boolean {
126128
target.push(
127129
INSTANCE,
128130
RAW_TEXT, // Type
129131
END, // Null terminated type string
130132
// TODO: props { text: text }
131133
END, // End of children
132134
);
135+
return false;
133136
}
134137

135138
export function pushStartInstance(
@@ -156,6 +159,14 @@ export function pushEndInstance(
156159
target.push(END);
157160
}
158161

162+
// In this Renderer this is a noop
163+
export function pushSegmentFinale(
164+
target: Array<Chunk | PrecomputedChunk>,
165+
responseState: ResponseState,
166+
lastPushedText: boolean,
167+
textEmbedded: boolean,
168+
): void {}
169+
159170
export function writeCompletedRoot(
160171
destination: Destination,
161172
responseState: ResponseState,

packages/react-noop-renderer/src/ReactNoopServer.js

+15-1
Original file line numberDiff line numberDiff line change
@@ -98,12 +98,18 @@ const ReactNoopServer = ReactFizzServer({
9898
return null;
9999
},
100100

101-
pushTextInstance(target: Array<Uint8Array>, text: string): void {
101+
pushTextInstance(
102+
target: Array<Uint8Array>,
103+
text: string,
104+
responseState: ResponseState,
105+
textEmbedded: boolean,
106+
): boolean {
102107
const textInstance: TextInstance = {
103108
text,
104109
hidden: false,
105110
};
106111
target.push(Buffer.from(JSON.stringify(textInstance), 'utf8'), POP);
112+
return false;
107113
},
108114
pushStartInstance(
109115
target: Array<Uint8Array>,
@@ -128,6 +134,14 @@ const ReactNoopServer = ReactFizzServer({
128134
target.push(POP);
129135
},
130136

137+
// This is a noop in ReactNoop
138+
pushSegmentFinale(
139+
target: Array<Uint8Array>,
140+
responseState: ResponseState,
141+
lastPushedText: boolean,
142+
textEmbedded: boolean,
143+
): void {},
144+
131145
writeCompletedRoot(
132146
destination: Destination,
133147
responseState: ResponseState,

packages/react-server-dom-relay/src/__tests__/ReactDOMServerFB-test.internal.js

+1-3
Original file line numberDiff line numberDiff line change
@@ -93,9 +93,7 @@ describe('ReactDOMServerFB', () => {
9393
await jest.runAllTimers();
9494

9595
const result = readResult(stream);
96-
expect(result).toMatchInlineSnapshot(
97-
`"<div><!--$-->Done<!-- --><!--/$--></div>"`,
98-
);
96+
expect(result).toMatchInlineSnapshot(`"<div><!--$-->Done<!--/$--></div>"`);
9997
});
10098

10199
it('should throw an error when an error is thrown at the root', () => {

0 commit comments

Comments
 (0)