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

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

Hendrik Liebau committed May 29, 2026 at 14:27 UTC f0dfee38f8a4b8d04867362a9f7b633426aec3c6
6 files changed +354 -1
fixtures/flight/src/App.js
+6
@@ -27,6 +27,7 @@ import {like, greet, increment} from './actions.js';
27
28 import {getServerState} from './ServerState.js';
29 import {sdkMethod} from './library.js';
30 +import FileReader from './FileReader.js';
31
32 const promisedText = new Promise(resolve =>
33 setTimeout(() => resolve('deferred text'), 50)
@@ -243,6 +244,11 @@ export default async function App({prerender, noCache}) {
244 {prerender ? null : ( // TODO: prerender is broken for large content for some reason.
245 <React.Suspense fallback={null}>
246 <LargeContent />
247 + {/*
248 + This text prop is above the threshold, so in the debug info for
249 + the element we'll see a placeholder instead of the actual value.
250 + */}
251 + <FileReader largeText={'a'.repeat(1000001)} />
252 </React.Suspense>
253 )}
254 </Container>
fixtures/flight/src/FileReader.js new
+16
@@ -0,0 +1,16 @@
1 +export default async function FileReader() {
2 + // This debug string is below the threshold for debug string length, so its
3 + // value is sent to the client as the awaited value.
4 + await new Promise(resolve => {
5 + setTimeout(() => resolve('o'.repeat(1000000)), 1);
6 + });
7 +
8 + // This debug string is above the threshold for debug string length, so the
9 + // client receives a placeholder as the awaited value instead of the actual
10 + // string.
11 + await new Promise(resolve => {
12 + setTimeout(() => resolve('x'.repeat(1000001)), 1);
13 + });
14 +
15 + return <p>FileReader</p>;
16 +}
packages/react-client/src/__tests__/ReactFlight-test.js
+53
@@ -3743,6 +3743,59 @@ describe('ReactFlight', () => {
3743 expect(cyclic2.cycle).toBe(cyclic2);
3744 });
3745
3746 + // @gate __DEV__
3747 + it('replays logs with large strings replaced by a placeholder', async () => {
3748 + // This string exceeds the threshold for debug string length. Reconstructing
3749 + // a multi-megabyte string on the client when replaying the log would block
3750 + // the main thread for too long, so we omit it and send a placeholder
3751 + // instead.
3752 + const largeString = 'x'.repeat(1000001);
3753 +
3754 + function ServerComponent() {
3755 + console.log('large string:', largeString);
3756 + return null;
3757 + }
3758 +
3759 + function App() {
3760 + return ReactServer.createElement(ServerComponent);
3761 + }
3762 +
3763 + // These tests are specifically testing console.log.
3764 + // Assign to `mockConsoleLog` so we can still inspect it when `console.log`
3765 + // is overridden by the test modules. The original function will be restored
3766 + // after this test finishes by `jest.restoreAllMocks()`.
3767 + const mockConsoleLog = spyOnDevAndProd(console, 'log').mockImplementation(
3768 + () => {},
3769 + );
3770 +
3771 + // Reset the modules so that we get a new overridden console on top of the
3772 + // one installed by expect. This ensures that we still emit console.error
3773 + // calls.
3774 + jest.resetModules();
3775 + jest.mock('react', () => require('react/react.react-server'));
3776 + ReactServer = require('react');
3777 + ReactNoopFlightServer = require('react-noop-renderer/flight-server');
3778 + const transport = ReactNoopFlightServer.render({
3779 + root: ReactServer.createElement(App),
3780 + });
3781 +
3782 + // The server logged the actual string synchronously while rendering.
3783 + expect(mockConsoleLog).toHaveBeenCalledTimes(1);
3784 + expect(mockConsoleLog.mock.calls[0][1]).toBe(largeString);
3785 + mockConsoleLog.mockClear();
3786 + mockConsoleLog.mockImplementation(() => {});
3787 +
3788 + await ReactNoopFlightClient.read(transport);
3789 +
3790 + // The replayed log received a placeholder instead of the actual string.
3791 + expect(mockConsoleLog).toHaveBeenCalledTimes(1);
3792 + expect(mockConsoleLog.mock.calls[0][0]).toBe('large string:');
3793 + expect(mockConsoleLog.mock.calls[0][1]).toBe(
3794 + 'This string of length 1000001 has been omitted by React to avoid ' +
3795 + 'sending too much data from the server.',
3796 + );
3797 + });
3798 +
3799 // @gate !__DEV__ || enableComponentPerformanceTrack
3800 it('uses the server component debug info as the element owner in DEV', async () => {
3801 function Container({children}) {
packages/react-server/src/ReactFlightServer.js
+11
@@ -5160,6 +5160,17 @@ function renderDebugModel(
5160 }
5161
5162 if (typeof value === 'string') {
5163 + if (value.length > 1000000) {
5164 + // Reconstructing a multi-megabyte string on the client blocks the main
5165 + // thread for too long. We omit the actual value and send a placeholder
5166 + // instead.
5167 + return (
5168 + 'This string of length ' +
5169 + value.length +
5170 + ' has been omitted by React to avoid sending too much data from the ' +
5171 + 'server.'
5172 + );
5173 + }
5174 if (value.length >= 1024) {
5175 // Large strings are counted towards the object limit.
5176 if (counter.objectLimit <= 0) {
packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js
+263
@@ -3669,4 +3669,267 @@ describe('ReactFlightAsyncDebugInfo', () => {
3669
3670 await finishLoadingStream(readable);
3671 });
3672 +
3673 + it('omits large debug strings to avoid blocking the main thread when parsing', async () => {
3674 + async function Component() {
3675 + // This promise's value is expected to show up in the debug info below.
3676 + const small = await new Promise(resolve => {
3677 + setTimeout(() => resolve('hello'), 1);
3678 + });
3679 +
3680 + // This promise's value exceeds the threshold for debug string length and
3681 + // is expected to show up as a placeholder in the debug info below.
3682 + // Reconstructing a multi-megabyte string on the client would block the
3683 + // main thread for too long.
3684 + const large = await new Promise(resolve => {
3685 + setTimeout(() => resolve('x'.repeat(1000001)), 1);
3686 + });
3687 +
3688 + return small + ' ' + large.length;
3689 + }
3690 +
3691 + const stream = ReactServerDOMServer.renderToPipeableStream(
3692 + ReactServer.createElement(Component),
3693 + {},
3694 + {filterStackFrame},
3695 + );
3696 +
3697 + const readable = new Stream.PassThrough(streamOptions);
3698 +
3699 + const result = ReactServerDOMClient.createFromNodeStream(readable, {
3700 + moduleMap: {},
3701 + moduleLoading: {},
3702 + });
3703 + stream.pipe(readable);
3704 +
3705 + expect(await result).toBe('hello 1000001');
3706 +
3707 + await finishLoadingStream(readable);
3708 + if (
3709 + __DEV__ &&
3710 + gate(
3711 + flags =>
3712 + flags.enableComponentPerformanceTrack && flags.enableAsyncDebugInfo,
3713 + )
3714 + ) {
3715 + expect(getDebugInfo(result)).toMatchInlineSnapshot(`
3716 + [
3717 + {
3718 + "time": 0,
3719 + },
3720 + {
3721 + "env": "Server",
3722 + "key": null,
3723 + "name": "Component",
3724 + "props": {},
3725 + "stack": [
3726 + [
3727 + "Object.<anonymous>",
3728 + "/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js",
3729 + 3692,
3730 + 19,
3731 + 3673,
3732 + 82,
3733 + ],
3734 + [
3735 + "new Promise",
3736 + "",
3737 + 0,
3738 + 0,
3739 + 0,
3740 + 0,
3741 + ],
3742 + ],
3743 + },
3744 + {
3745 + "time": 0,
3746 + },
3747 + {
3748 + "awaited": {
3749 + "end": 0,
3750 + "env": "Server",
3751 + "name": "Component",
3752 + "owner": {
3753 + "env": "Server",
3754 + "key": null,
3755 + "name": "Component",
3756 + "props": {},
3757 + "stack": [
3758 + [
3759 + "Object.<anonymous>",
3760 + "/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js",
3761 + 3692,
3762 + 19,
3763 + 3673,
3764 + 82,
3765 + ],
3766 + [
3767 + "new Promise",
3768 + "",
3769 + 0,
3770 + 0,
3771 + 0,
3772 + 0,
3773 + ],
3774 + ],
3775 + },
3776 + "stack": [
3777 + [
3778 + "Component",
3779 + "/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js",
3780 + 3676,
3781 + 25,
3782 + 3674,
3783 + 5,
3784 + ],
3785 + ],
3786 + "start": 0,
3787 + "value": {
3788 + "value": "hello",
3789 + },
3790 + },
3791 + "env": "Server",
3792 + "owner": {
3793 + "env": "Server",
3794 + "key": null,
3795 + "name": "Component",
3796 + "props": {},
3797 + "stack": [
3798 + [
3799 + "Object.<anonymous>",
3800 + "/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js",
3801 + 3692,
3802 + 19,
3803 + 3673,
3804 + 82,
3805 + ],
3806 + [
3807 + "new Promise",
3808 + "",
3809 + 0,
3810 + 0,
3811 + 0,
3812 + 0,
3813 + ],
3814 + ],
3815 + },
3816 + "stack": [
3817 + [
3818 + "Component",
3819 + "/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js",
3820 + 3676,
3821 + 25,
3822 + 3674,
3823 + 5,
3824 + ],
3825 + ],
3826 + },
3827 + {
3828 + "time": 0,
3829 + },
3830 + {
3831 + "time": 0,
3832 + },
3833 + {
3834 + "awaited": {
3835 + "end": 0,
3836 + "env": "Server",
3837 + "name": "Component",
3838 + "owner": {
3839 + "env": "Server",
3840 + "key": null,
3841 + "name": "Component",
3842 + "props": {},
3843 + "stack": [
3844 + [
3845 + "Object.<anonymous>",
3846 + "/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js",
3847 + 3692,
3848 + 19,
3849 + 3673,
3850 + 82,
3851 + ],
3852 + [
3853 + "new Promise",
3854 + "",
3855 + 0,
3856 + 0,
3857 + 0,
3858 + 0,
3859 + ],
3860 + ],
3861 + },
3862 + "stack": [
3863 + [
3864 + "Component",
3865 + "/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js",
3866 + 3684,
3867 + 25,
3868 + 3674,
3869 + 5,
3870 + ],
3871 + ],
3872 + "start": 0,
3873 + "value": {
3874 + "value": "This string of length 1000001 has been omitted by React to avoid sending too much data from the server.",
3875 + },
3876 + },
3877 + "env": "Server",
3878 + "owner": {
3879 + "env": "Server",
3880 + "key": null,
3881 + "name": "Component",
3882 + "props": {},
3883 + "stack": [
3884 + [
3885 + "Object.<anonymous>",
3886 + "/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js",
3887 + 3692,
3888 + 19,
3889 + 3673,
3890 + 82,
3891 + ],
3892 + [
3893 + "new Promise",
3894 + "",
3895 + 0,
3896 + 0,
3897 + 0,
3898 + 0,
3899 + ],
3900 + ],
3901 + },
3902 + "stack": [
3903 + [
3904 + "Component",
3905 + "/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js",
3906 + 3684,
3907 + 25,
3908 + 3674,
3909 + 5,
3910 + ],
3911 + ],
3912 + },
3913 + {
3914 + "time": 0,
3915 + },
3916 + {
3917 + "time": 0,
3918 + },
3919 + {
3920 + "awaited": {
3921 + "byteSize": 0,
3922 + "end": 0,
3923 + "name": "rsc stream",
3924 + "owner": null,
3925 + "start": 0,
3926 + "value": {
3927 + "value": "stream",
3928 + },
3929 + },
3930 + },
3931 + ]
3932 + `);
3933 + }
3934 + });
3935 });
packages/shared/ReactPerformanceTrackProperties.js
+5 -1
@@ -275,7 +275,11 @@ export function addValueToProperties(
275 if (value === OMITTED_PROP_ERROR) {
276 desc = '\u2026'; // ellipsis
277 } else {
278 - desc = JSON.stringify(value);
278 + desc = JSON.stringify(
279 + value.length >= 1024
280 + ? value.slice(0, 1023) + '\u2026' // ellipsis
281 + : value,
282 + );
283 }
284 break;
285 case 'undefined':