@samitouri / QOS-React-1 / commits / cb71f2e261

[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();