[test] Add coverage for multi-byte code units in stream APIs (#36900)
Adds a test documenting why a `string.length` fast path is unsound. `byteSize` feeds heuristic decisions (outlining threshold at 500 bytes, progressive chunk sizing). `string.length` undercounts multi-byte content, so a boundary that is really >500 bytes could drop below the outlining threshold. --------- Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Sebastian "Sebbie" Silbermann committed
Jun 29, 2026 at 16:04 UTC
cb71f2e2610ada0b9974a936d81c22f339167de3
2 files changed
+76
-14
packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js
+37
@@ -9840,6 +9840,43 @@ Unfortunately that previous paragraph wasn't quite long enough so I'll continue
9840
);
9841
});
9842
9843
+ it('outlines boundaries based on UTF-8 byte size, not code unit count', async () => {
9844
+ // Boundaries are outlined when byteSize > 500, which streams the fallback
9845
+ // first. Content is 200 three-byte characters: 600 UTF-8 bytes but only 200
9846
+ // code units. The fallback should be shown initially because the boundary is
9847
+ // large enough to outline. A string.length shortcut for byte size would
9848
+ // count 200, stay under the threshold, and inline the content with no
9849
+ // fallback shown — which would be incorrect.
9850
+ const multiByte = '✓'.repeat(200);
9851
+
9852
+ function App() {
9853
+ return (
9854
+ <div>
9855
+ <Suspense fallback="Waiting">
9856
+ <span>{multiByte}</span>
9857
+ </Suspense>
9858
+ </div>
9859
+ );
9860
+ }
9861
+
9862
+ await act(async () => {
9863
+ renderToPipeableStream(<App />, {progressiveChunkSize: 100}).pipe(
9864
+ writable,
9865
+ );
9866
+ await jest.runAllTimers();
9867
+ const temp = document.createElement('body');
9868
+ temp.innerHTML = buffer;
9869
+ // Fallback is shown because the boundary is outlined by its UTF-8 size.
9870
+ expect(getVisibleChildren(temp)).toEqual(<div>Waiting</div>);
9871
+ });
9872
+
9873
+ expect(getVisibleChildren(container)).toEqual(
9874
+ <div>
9875
+ <span>{multiByte}</span>
9876
+ </div>,
9877
+ );
9878
+ });
9879
+
9880
it('useId is consistent for siblings when component suspends with nested lazy', async () => {
9881
// Inner component uses useId
9882
function InnerComponent() {
packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMNode-test.js
+39
-14
@@ -338,6 +338,31 @@ describe('ReactFlightDOMNode', () => {
338
expect(result.text).toBe(testString);
339
});
340
341
+ it('round-trips long multi-byte strings using true UTF-8 byte length', async () => {
342
+ // Strings >= 1024 chars are emitted out-of-band with a binary length
343
+ // prefix (`id:T<byteLength>,`). The client reads exactly that many bytes,
344
+ // so the prefix must be the true UTF-8 byte length. These are three-byte
345
+ // characters: byte length is 3x the code unit count. A string.length
346
+ // shortcut for byteLengthOfChunk would undercount and truncate parsing.
347
+ const testString = '✓'.repeat(1100);
348
+
349
+ const stream = await serverAct(() =>
350
+ ReactServerDOMServer.renderToPipeableStream({
351
+ text: testString,
352
+ }),
353
+ );
354
+
355
+ const readable = new Stream.PassThrough(streamOptions);
356
+ const parsedResult = ReactServerDOMClient.createFromNodeStream(readable, {
357
+ moduleMap: {},
358
+ moduleLoading: webpackModuleLoading,
359
+ });
360
+ stream.pipe(readable);
361
+
362
+ const result = await parsedResult;
363
+ expect(result.text).toBe(testString);
364
+ });
365
+
366
it('should be able to serialize any kind of typed array', async () => {
367
const buffer = new Uint8Array([
368
123, 4, 10, 5, 100, 255, 244, 45, 56, 67, 43, 124, 67, 89, 100, 20,
@@ -955,10 +980,10 @@ describe('ReactFlightDOMNode', () => {
980
// The concrete location may change as this test is updated.
981
// Just make sure they still point at React.use(p2)
982
(gate(flags => flags.enableAsyncDebugInfo)
958
- ? '\n at SharedComponent (./ReactFlightDOMNode-test.js:813:7)'
983
+ ? '\n at SharedComponent (./ReactFlightDOMNode-test.js:838:7)'
984
: '') +
960
- '\n at ServerComponent (file://./ReactFlightDOMNode-test.js:835:26)' +
961
- '\n at App (file://./ReactFlightDOMNode-test.js:852:25)',
985
+ '\n at ServerComponent (file://./ReactFlightDOMNode-test.js:860:26)' +
986
+ '\n at App (file://./ReactFlightDOMNode-test.js:877:25)',
987
);
988
} else {
989
expect(ownerStack).toBeNull();
@@ -1542,12 +1567,12 @@ describe('ReactFlightDOMNode', () => {
1567
'\n' +
1568
' in Dynamic' +
1569
(gate(flags => flags.enableAsyncDebugInfo)
1545
- ? ' (file://ReactFlightDOMNode-test.js:1416:27)\n'
1570
+ ? ' (file://ReactFlightDOMNode-test.js:1441:27)\n'
1571
: '\n') +
1572
' in body\n' +
1573
' in html\n' +
1549
- ' in App (file://ReactFlightDOMNode-test.js:1429:25)\n' +
1550
- ' in ClientRoot (ReactFlightDOMNode-test.js:1504:16)',
1574
+ ' in App (file://ReactFlightDOMNode-test.js:1454:25)\n' +
1575
+ ' in ClientRoot (ReactFlightDOMNode-test.js:1529:16)',
1576
);
1577
} else {
1578
expect(
@@ -1556,7 +1581,7 @@ describe('ReactFlightDOMNode', () => {
1581
'\n' +
1582
' in body\n' +
1583
' in html\n' +
1559
- ' in ClientRoot (ReactFlightDOMNode-test.js:1504:16)',
1584
+ ' in ClientRoot (ReactFlightDOMNode-test.js:1529:16)',
1585
);
1586
}
1587
@@ -1566,8 +1591,8 @@ describe('ReactFlightDOMNode', () => {
1591
normalizeCodeLocInfo(ownerStack, {preserveLocation: true}),
1592
).toBe(
1593
'\n' +
1569
- ' in Dynamic (file://ReactFlightDOMNode-test.js:1416:27)\n' +
1570
- ' in App (file://ReactFlightDOMNode-test.js:1429:25)',
1594
+ ' in Dynamic (file://ReactFlightDOMNode-test.js:1441:27)\n' +
1595
+ ' in App (file://ReactFlightDOMNode-test.js:1454:25)',
1596
);
1597
} else {
1598
expect(
@@ -1575,7 +1600,7 @@ describe('ReactFlightDOMNode', () => {
1600
).toBe(
1601
'' +
1602
'\n' +
1578
- ' in App (file://ReactFlightDOMNode-test.js:1429:25)',
1603
+ ' in App (file://ReactFlightDOMNode-test.js:1454:25)',
1604
);
1605
}
1606
} else {
@@ -1732,7 +1757,7 @@ describe('ReactFlightDOMNode', () => {
1757
normalizeCodeLocInfo(componentStack, {preserveLocation: true}),
1758
).toBe(
1759
'\n' +
1735
- ' in ClientDynamic (ReactFlightDOMNode-test.js:1679:9)\n' +
1760
+ ' in ClientDynamic (ReactFlightDOMNode-test.js:1704:9)\n' +
1761
' in Suspense\n' +
1762
' in body\n' +
1763
' in html\n' +
@@ -1743,7 +1768,7 @@ describe('ReactFlightDOMNode', () => {
1768
normalizeCodeLocInfo(componentStack, {preserveLocation: true}),
1769
).toBe(
1770
'\n' +
1746
- ' in ClientDynamic (ReactFlightDOMNode-test.js:1679:9)\n' +
1771
+ ' in ClientDynamic (ReactFlightDOMNode-test.js:1704:9)\n' +
1772
' in Suspense\n' +
1773
' in body\n' +
1774
' in html\n' +
@@ -1756,10 +1781,10 @@ describe('ReactFlightDOMNode', () => {
1781
'\n' +
1782
gate(flags =>
1783
flags.enableAsyncDebugInfo
1759
- ? ' at ClientDynamic (./ReactFlightDOMNode-test.js:1680:9)\n'
1784
+ ? ' at ClientDynamic (./ReactFlightDOMNode-test.js:1705:9)\n'
1785
: '',
1786
) +
1762
- ' at ClientRoot (./ReactFlightDOMNode-test.js:1693:21)',
1787
+ ' at ClientRoot (./ReactFlightDOMNode-test.js:1718:21)',
1788
);
1789
} else {
1790
expect(ownerStack).toBeNull();