[Flight] Avoid main-thread stalls from large debug strings (#36570)

This commit is contained in:
Hendrik Liebau
2026-05-29 14:27:50 +02:00
committed by GitHub
parent de221cb8a5
commit c212be19de
6 changed files with 354 additions and 1 deletions
+6
View File
@@ -27,6 +27,7 @@ import {like, greet, increment} from './actions.js';
import {getServerState} from './ServerState.js';
import {sdkMethod} from './library.js';
import FileReader from './FileReader.js';
const promisedText = new Promise(resolve =>
setTimeout(() => resolve('deferred text'), 50)
@@ -243,6 +244,11 @@ export default async function App({prerender, noCache}) {
{prerender ? null : ( // TODO: prerender is broken for large content for some reason.
<React.Suspense fallback={null}>
<LargeContent />
{/*
This text prop is above the threshold, so in the debug info for
the element we'll see a placeholder instead of the actual value.
*/}
<FileReader largeText={'a'.repeat(1000001)} />
</React.Suspense>
)}
</Container>
+16
View File
@@ -0,0 +1,16 @@
export default async function FileReader() {
// This debug string is below the threshold for debug string length, so its
// value is sent to the client as the awaited value.
await new Promise(resolve => {
setTimeout(() => resolve('o'.repeat(1000000)), 1);
});
// This debug string is above the threshold for debug string length, so the
// client receives a placeholder as the awaited value instead of the actual
// string.
await new Promise(resolve => {
setTimeout(() => resolve('x'.repeat(1000001)), 1);
});
return <p>FileReader</p>;
}
+53
View File
@@ -3743,6 +3743,59 @@ describe('ReactFlight', () => {
expect(cyclic2.cycle).toBe(cyclic2);
});
// @gate __DEV__
it('replays logs with large strings replaced by a placeholder', async () => {
// This string exceeds the threshold for debug string length. Reconstructing
// a multi-megabyte string on the client when replaying the log would block
// the main thread for too long, so we omit it and send a placeholder
// instead.
const largeString = 'x'.repeat(1000001);
function ServerComponent() {
console.log('large string:', largeString);
return null;
}
function App() {
return ReactServer.createElement(ServerComponent);
}
// These tests are specifically testing console.log.
// Assign to `mockConsoleLog` so we can still inspect it when `console.log`
// is overridden by the test modules. The original function will be restored
// after this test finishes by `jest.restoreAllMocks()`.
const mockConsoleLog = spyOnDevAndProd(console, 'log').mockImplementation(
() => {},
);
// Reset the modules so that we get a new overridden console on top of the
// one installed by expect. This ensures that we still emit console.error
// calls.
jest.resetModules();
jest.mock('react', () => require('react/react.react-server'));
ReactServer = require('react');
ReactNoopFlightServer = require('react-noop-renderer/flight-server');
const transport = ReactNoopFlightServer.render({
root: ReactServer.createElement(App),
});
// The server logged the actual string synchronously while rendering.
expect(mockConsoleLog).toHaveBeenCalledTimes(1);
expect(mockConsoleLog.mock.calls[0][1]).toBe(largeString);
mockConsoleLog.mockClear();
mockConsoleLog.mockImplementation(() => {});
await ReactNoopFlightClient.read(transport);
// The replayed log received a placeholder instead of the actual string.
expect(mockConsoleLog).toHaveBeenCalledTimes(1);
expect(mockConsoleLog.mock.calls[0][0]).toBe('large string:');
expect(mockConsoleLog.mock.calls[0][1]).toBe(
'This string of length 1000001 has been omitted by React to avoid ' +
'sending too much data from the server.',
);
});
// @gate !__DEV__ || enableComponentPerformanceTrack
it('uses the server component debug info as the element owner in DEV', async () => {
function Container({children}) {
+11
View File
@@ -5160,6 +5160,17 @@ function renderDebugModel(
}
if (typeof value === 'string') {
if (value.length > 1000000) {
// Reconstructing a multi-megabyte string on the client blocks the main
// thread for too long. We omit the actual value and send a placeholder
// instead.
return (
'This string of length ' +
value.length +
' has been omitted by React to avoid sending too much data from the ' +
'server.'
);
}
if (value.length >= 1024) {
// Large strings are counted towards the object limit.
if (counter.objectLimit <= 0) {
@@ -3669,4 +3669,267 @@ describe('ReactFlightAsyncDebugInfo', () => {
await finishLoadingStream(readable);
});
it('omits large debug strings to avoid blocking the main thread when parsing', async () => {
async function Component() {
// This promise's value is expected to show up in the debug info below.
const small = await new Promise(resolve => {
setTimeout(() => resolve('hello'), 1);
});
// This promise's value exceeds the threshold for debug string length and
// is expected to show up as a placeholder in the debug info below.
// Reconstructing a multi-megabyte string on the client would block the
// main thread for too long.
const large = await new Promise(resolve => {
setTimeout(() => resolve('x'.repeat(1000001)), 1);
});
return small + ' ' + large.length;
}
const stream = ReactServerDOMServer.renderToPipeableStream(
ReactServer.createElement(Component),
{},
{filterStackFrame},
);
const readable = new Stream.PassThrough(streamOptions);
const result = ReactServerDOMClient.createFromNodeStream(readable, {
moduleMap: {},
moduleLoading: {},
});
stream.pipe(readable);
expect(await result).toBe('hello 1000001');
await finishLoadingStream(readable);
if (
__DEV__ &&
gate(
flags =>
flags.enableComponentPerformanceTrack && flags.enableAsyncDebugInfo,
)
) {
expect(getDebugInfo(result)).toMatchInlineSnapshot(`
[
{
"time": 0,
},
{
"env": "Server",
"key": null,
"name": "Component",
"props": {},
"stack": [
[
"Object.<anonymous>",
"/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js",
3692,
19,
3673,
82,
],
[
"new Promise",
"",
0,
0,
0,
0,
],
],
},
{
"time": 0,
},
{
"awaited": {
"end": 0,
"env": "Server",
"name": "Component",
"owner": {
"env": "Server",
"key": null,
"name": "Component",
"props": {},
"stack": [
[
"Object.<anonymous>",
"/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js",
3692,
19,
3673,
82,
],
[
"new Promise",
"",
0,
0,
0,
0,
],
],
},
"stack": [
[
"Component",
"/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js",
3676,
25,
3674,
5,
],
],
"start": 0,
"value": {
"value": "hello",
},
},
"env": "Server",
"owner": {
"env": "Server",
"key": null,
"name": "Component",
"props": {},
"stack": [
[
"Object.<anonymous>",
"/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js",
3692,
19,
3673,
82,
],
[
"new Promise",
"",
0,
0,
0,
0,
],
],
},
"stack": [
[
"Component",
"/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js",
3676,
25,
3674,
5,
],
],
},
{
"time": 0,
},
{
"time": 0,
},
{
"awaited": {
"end": 0,
"env": "Server",
"name": "Component",
"owner": {
"env": "Server",
"key": null,
"name": "Component",
"props": {},
"stack": [
[
"Object.<anonymous>",
"/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js",
3692,
19,
3673,
82,
],
[
"new Promise",
"",
0,
0,
0,
0,
],
],
},
"stack": [
[
"Component",
"/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js",
3684,
25,
3674,
5,
],
],
"start": 0,
"value": {
"value": "This string of length 1000001 has been omitted by React to avoid sending too much data from the server.",
},
},
"env": "Server",
"owner": {
"env": "Server",
"key": null,
"name": "Component",
"props": {},
"stack": [
[
"Object.<anonymous>",
"/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js",
3692,
19,
3673,
82,
],
[
"new Promise",
"",
0,
0,
0,
0,
],
],
},
"stack": [
[
"Component",
"/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js",
3684,
25,
3674,
5,
],
],
},
{
"time": 0,
},
{
"time": 0,
},
{
"awaited": {
"byteSize": 0,
"end": 0,
"name": "rsc stream",
"owner": null,
"start": 0,
"value": {
"value": "stream",
},
},
},
]
`);
}
});
});
@@ -275,7 +275,11 @@ export function addValueToProperties(
if (value === OMITTED_PROP_ERROR) {
desc = '\u2026'; // ellipsis
} else {
desc = JSON.stringify(value);
desc = JSON.stringify(
value.length >= 1024
? value.slice(0, 1023) + '\u2026' // ellipsis
: value,
);
}
break;
case 'undefined':