[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':