diff --git a/fixtures/flight/src/App.js b/fixtures/flight/src/App.js
index 9f8deed495d3..1ecfbbcd9cac 100644
--- a/fixtures/flight/src/App.js
+++ b/fixtures/flight/src/App.js
@@ -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.
FileReader
; +} diff --git a/packages/react-client/src/__tests__/ReactFlight-test.js b/packages/react-client/src/__tests__/ReactFlight-test.js index 8736b0585f1b..95633283b2c7 100644 --- a/packages/react-client/src/__tests__/ReactFlight-test.js +++ b/packages/react-client/src/__tests__/ReactFlight-test.js @@ -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}) { diff --git a/packages/react-server/src/ReactFlightServer.js b/packages/react-server/src/ReactFlightServer.js index 6330e86dde60..ef6e9caac4b2 100644 --- a/packages/react-server/src/ReactFlightServer.js +++ b/packages/react-server/src/ReactFlightServer.js @@ -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) { diff --git a/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js b/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js index 5107fc0bfaea..f659c2bba839 100644 --- a/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js +++ b/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js @@ -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.