Commit f0dfee3

Browse files
authored
[Flight] Avoid main-thread stalls from large debug strings (#36570)
1 parent 6b5ea12 commit f0dfee3

6 files changed

Lines changed: 354 additions & 1 deletion

File tree

‎fixtures/flight/src/App.js‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import {like, greet, increment} from './actions.js';
2727

2828
import{getServerState}from'./ServerState.js';
2929
import{sdkMethod}from'./library.js';
30+
importFileReaderfrom'./FileReader.js';
3031

3132
constpromisedText=newPromise(resolve=>
3233
setTimeout(()=>resolve('deferred text'),50)
@@ -243,6 +244,11 @@ export default async function App({prerender, noCache}) {
243244
{prerender ? null : (// TODO: prerender is broken for large content for some reason.
244245
<React.Suspensefallback={null}>
245246
<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+
<FileReaderlargeText={'a'.repeat(1000001)}/>
246252
</React.Suspense>
247253
)}
248254
</Container>

‎fixtures/flight/src/FileReader.js‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
exportdefaultasyncfunctionFileReader(){
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+
awaitnewPromise(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+
awaitnewPromise(resolve=>{
12+
setTimeout(()=>resolve('x'.repeat(1000001)),1);
13+
});
14+
15+
return<p>FileReader</p>;
16+
}

‎packages/react-client/src/__tests__/ReactFlight-test.js‎

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3743,6 +3743,59 @@ describe('ReactFlight', () => {
37433743
expect(cyclic2.cycle).toBe(cyclic2);
37443744
});
37453745

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+
constlargeString='x'.repeat(1000001);
3753+
3754+
functionServerComponent(){
3755+
console.log('large string:',largeString);
3756+
returnnull;
3757+
}
3758+
3759+
functionApp(){
3760+
returnReactServer.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+
constmockConsoleLog=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+
consttransport=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+
awaitReactNoopFlightClient.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+
37463799
// @gate !__DEV__ || enableComponentPerformanceTrack
37473800
it('uses the server component debug info as the element owner in DEV',async()=>{
37483801
functionContainer({children}){

‎packages/react-server/src/ReactFlightServer.js‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5160,6 +5160,17 @@ function renderDebugModel(
51605160
}
51615161

51625162
if(typeofvalue=== '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+
}
51635174
if (value.length >=1024){
51645175
// Large strings are counted towards the object limit.
51655176
if(counter.objectLimit<=0){

‎packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js‎

Lines changed: 263 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3669,4 +3669,267 @@ describe('ReactFlightAsyncDebugInfo', () => {
36693669

36703670
awaitfinishLoadingStream(readable);
36713671
});
3672+
3673+
it('omits large debug strings to avoid blocking the main thread when parsing',async()=>{
3674+
asyncfunctionComponent(){
3675+
// This promise's value is expected to show up in the debug info below.
3676+
constsmall=awaitnewPromise(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+
constlarge=awaitnewPromise(resolve=>{
3685+
setTimeout(()=>resolve('x'.repeat(1000001)),1);
3686+
});
3687+
3688+
returnsmall+' '+large.length;
3689+
}
3690+
3691+
conststream=ReactServerDOMServer.renderToPipeableStream(
3692+
ReactServer.createElement(Component),
3693+
{},
3694+
{filterStackFrame},
3695+
);
3696+
3697+
constreadable=newStream.PassThrough(streamOptions);
3698+
3699+
constresult=ReactServerDOMClient.createFromNodeStream(readable,{
3700+
moduleMap: {},
3701+
moduleLoading: {},
3702+
});
3703+
stream.pipe(readable);
3704+
3705+
expect(awaitresult).toBe('hello 1000001');
3706+
3707+
awaitfinishLoadingStream(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+
});
36723935
});

‎packages/shared/ReactPerformanceTrackProperties.js‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -275,7 +275,11 @@ export function addValueToProperties(
275275
if(value===OMITTED_PROP_ERROR){
276276
desc='\u2026';// ellipsis
277277
}else{
278-
desc=JSON.stringify(value);
278+
desc=JSON.stringify(
279+
value.length>=1024
280+
? value.slice(0,1023)+'\u2026'// ellipsis
281+
: value,
282+
);
279283
}
280284
break;
281285
case'undefined':

0 commit comments

Comments
 (0)
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

Commit f0dfee3

Browse files
authored
[Flight] Avoid main-thread stalls from large debug strings (#36570)
1 parent 6b5ea12 commit f0dfee3

6 files changed

Lines changed: 354 additions & 1 deletion

File tree

‎fixtures/flight/src/App.js‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import {like, greet, increment} from './actions.js';
2727

2828
import{getServerState}from'./ServerState.js';
2929
import{sdkMethod}from'./library.js';
30+
importFileReaderfrom'./FileReader.js';
3031

3132
constpromisedText=newPromise(resolve=>
3233
setTimeout(()=>resolve('deferred text'),50)
@@ -243,6 +244,11 @@ export default async function App({prerender, noCache}) {
243244
{prerender ? null : (// TODO: prerender is broken for large content for some reason.
244245
<React.Suspensefallback={null}>
245246
<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+
<FileReaderlargeText={'a'.repeat(1000001)}/>
246252
</React.Suspense>
247253
)}
248254
</Container>

‎fixtures/flight/src/FileReader.js‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
exportdefaultasyncfunctionFileReader(){
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+
awaitnewPromise(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+
awaitnewPromise(resolve=>{
12+
setTimeout(()=>resolve('x'.repeat(1000001)),1);
13+
});
14+
15+
return<p>FileReader</p>;
16+
}

‎packages/react-client/src/__tests__/ReactFlight-test.js‎

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3743,6 +3743,59 @@ describe('ReactFlight', () => {
37433743
expect(cyclic2.cycle).toBe(cyclic2);
37443744
});
37453745

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+
constlargeString='x'.repeat(1000001);
3753+
3754+
functionServerComponent(){
3755+
console.log('large string:',largeString);
3756+
returnnull;
3757+
}
3758+
3759+
functionApp(){
3760+
returnReactServer.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+
constmockConsoleLog=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+
consttransport=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+
awaitReactNoopFlightClient.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+
37463799
// @gate !__DEV__ || enableComponentPerformanceTrack
37473800
it('uses the server component debug info as the element owner in DEV',async()=>{
37483801
functionContainer({children}){

‎packages/react-server/src/ReactFlightServer.js‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5160,6 +5160,17 @@ function renderDebugModel(
51605160
}
51615161

51625162
if(typeofvalue=== '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+
}
51635174
if (value.length >=1024){
51645175
// Large strings are counted towards the object limit.
51655176
if(counter.objectLimit<=0){

‎packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js‎

Lines changed: 263 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3669,4 +3669,267 @@ describe('ReactFlightAsyncDebugInfo', () => {
36693669

36703670
awaitfinishLoadingStream(readable);
36713671
});
3672+
3673+
it('omits large debug strings to avoid blocking the main thread when parsing',async()=>{
3674+
asyncfunctionComponent(){
3675+
// This promise's value is expected to show up in the debug info below.
3676+
constsmall=awaitnewPromise(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+
constlarge=awaitnewPromise(resolve=>{
3685+
setTimeout(()=>resolve('x'.repeat(1000001)),1);
3686+
});
3687+
3688+
returnsmall+' '+large.length;
3689+
}
3690+
3691+
conststream=ReactServerDOMServer.renderToPipeableStream(
3692+
ReactServer.createElement(Component),
3693+
{},
3694+
{filterStackFrame},
3695+
);
3696+
3697+
constreadable=newStream.PassThrough(streamOptions);
3698+
3699+
constresult=ReactServerDOMClient.createFromNodeStream(readable,{
3700+
moduleMap: {},
3701+
moduleLoading: {},
3702+
});
3703+
stream.pipe(readable);
3704+
3705+
expect(awaitresult).toBe('hello 1000001');
3706+
3707+
awaitfinishLoadingStream(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+
});
36723935
});

‎packages/shared/ReactPerformanceTrackProperties.js‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -275,7 +275,11 @@ export function addValueToProperties(
275275
if(value===OMITTED_PROP_ERROR){
276276
desc='\u2026';// ellipsis
277277
}else{
278-
desc=JSON.stringify(value);
278+
desc=JSON.stringify(
279+
value.length>=1024
280+
? value.slice(0,1023)+'\u2026'// ellipsis
281+
: value,
282+
);
279283
}
280284
break;
281285
case'undefined':

0 commit comments

Comments
 (0)
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Commit f0dfee3

Browse files
authored
[Flight] Avoid main-thread stalls from large debug strings (#36570)
1 parent 6b5ea12 commit f0dfee3

6 files changed

Lines changed: 354 additions & 1 deletion

File tree

‎fixtures/flight/src/App.js‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import {like, greet, increment} from './actions.js';
2727

2828
import{getServerState}from'./ServerState.js';
2929
import{sdkMethod}from'./library.js';
30+
importFileReaderfrom'./FileReader.js';
3031

3132
constpromisedText=newPromise(resolve=>
3233
setTimeout(()=>resolve('deferred text'),50)
@@ -243,6 +244,11 @@ export default async function App({prerender, noCache}) {
243244
{prerender ? null : (// TODO: prerender is broken for large content for some reason.
244245
<React.Suspensefallback={null}>
245246
<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+
<FileReaderlargeText={'a'.repeat(1000001)}/>
246252
</React.Suspense>
247253
)}
248254
</Container>

‎fixtures/flight/src/FileReader.js‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
exportdefaultasyncfunctionFileReader(){
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+
awaitnewPromise(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+
awaitnewPromise(resolve=>{
12+
setTimeout(()=>resolve('x'.repeat(1000001)),1);
13+
});
14+
15+
return<p>FileReader</p>;
16+
}

‎packages/react-client/src/__tests__/ReactFlight-test.js‎

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3743,6 +3743,59 @@ describe('ReactFlight', () => {
37433743
expect(cyclic2.cycle).toBe(cyclic2);
37443744
});
37453745

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+
constlargeString='x'.repeat(1000001);
3753+
3754+
functionServerComponent(){
3755+
console.log('large string:',largeString);
3756+
returnnull;
3757+
}
3758+
3759+
functionApp(){
3760+
returnReactServer.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+
constmockConsoleLog=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+
consttransport=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+
awaitReactNoopFlightClient.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+
37463799
// @gate !__DEV__ || enableComponentPerformanceTrack
37473800
it('uses the server component debug info as the element owner in DEV',async()=>{
37483801
functionContainer({children}){

‎packages/react-server/src/ReactFlightServer.js‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5160,6 +5160,17 @@ function renderDebugModel(
51605160
}
51615161

51625162
if(typeofvalue=== '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+
}
51635174
if (value.length >=1024){
51645175
// Large strings are counted towards the object limit.
51655176
if(counter.objectLimit<=0){

‎packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js‎

Lines changed: 263 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3669,4 +3669,267 @@ describe('ReactFlightAsyncDebugInfo', () => {
36693669

36703670
awaitfinishLoadingStream(readable);
36713671
});
3672+
3673+
it('omits large debug strings to avoid blocking the main thread when parsing',async()=>{
3674+
asyncfunctionComponent(){
3675+
// This promise's value is expected to show up in the debug info below.
3676+
constsmall=awaitnewPromise(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+
constlarge=awaitnewPromise(resolve=>{
3685+
setTimeout(()=>resolve('x'.repeat(1000001)),1);
3686+
});
3687+
3688+
returnsmall+' '+large.length;
3689+
}
3690+
3691+
conststream=ReactServerDOMServer.renderToPipeableStream(
3692+
ReactServer.createElement(Component),
3693+
{},
3694+
{filterStackFrame},
3695+
);
3696+
3697+
constreadable=newStream.PassThrough(streamOptions);
3698+
3699+
constresult=ReactServerDOMClient.createFromNodeStream(readable,{
3700+
moduleMap: {},
3701+
moduleLoading: {},
3702+
});
3703+
stream.pipe(readable);
3704+
3705+
expect(awaitresult).toBe('hello 1000001');
3706+
3707+
awaitfinishLoadingStream(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+
});
36723935
});

‎packages/shared/ReactPerformanceTrackProperties.js‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -275,7 +275,11 @@ export function addValueToProperties(
275275
if(value===OMITTED_PROP_ERROR){
276276
desc='\u2026';// ellipsis
277277
}else{
278-
desc=JSON.stringify(value);
278+
desc=JSON.stringify(
279+
value.length>=1024
280+
? value.slice(0,1023)+'\u2026'// ellipsis
281+
: value,
282+
);
279283
}
280284
break;
281285
case'undefined':

0 commit comments

Comments
 (0)
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Commit f0dfee3

Browse files
authored
[Flight] Avoid main-thread stalls from large debug strings (#36570)
1 parent 6b5ea12 commit f0dfee3

6 files changed

Lines changed: 354 additions & 1 deletion

File tree

‎fixtures/flight/src/App.js‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import {like, greet, increment} from './actions.js';
2727

2828
import{getServerState}from'./ServerState.js';
2929
import{sdkMethod}from'./library.js';
30+
importFileReaderfrom'./FileReader.js';
3031

3132
constpromisedText=newPromise(resolve=>
3233
setTimeout(()=>resolve('deferred text'),50)
@@ -243,6 +244,11 @@ export default async function App({prerender, noCache}) {
243244
{prerender ? null : (// TODO: prerender is broken for large content for some reason.
244245
<React.Suspensefallback={null}>
245246
<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+
<FileReaderlargeText={'a'.repeat(1000001)}/>
246252
</React.Suspense>
247253
)}
248254
</Container>

‎fixtures/flight/src/FileReader.js‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
exportdefaultasyncfunctionFileReader(){
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+
awaitnewPromise(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+
awaitnewPromise(resolve=>{
12+
setTimeout(()=>resolve('x'.repeat(1000001)),1);
13+
});
14+
15+
return<p>FileReader</p>;
16+
}

‎packages/react-client/src/__tests__/ReactFlight-test.js‎

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3743,6 +3743,59 @@ describe('ReactFlight', () => {
37433743
expect(cyclic2.cycle).toBe(cyclic2);
37443744
});
37453745

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+
constlargeString='x'.repeat(1000001);
3753+
3754+
functionServerComponent(){
3755+
console.log('large string:',largeString);
3756+
returnnull;
3757+
}
3758+
3759+
functionApp(){
3760+
returnReactServer.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+
constmockConsoleLog=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+
consttransport=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+
awaitReactNoopFlightClient.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+
37463799
// @gate !__DEV__ || enableComponentPerformanceTrack
37473800
it('uses the server component debug info as the element owner in DEV',async()=>{
37483801
functionContainer({children}){

‎packages/react-server/src/ReactFlightServer.js‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5160,6 +5160,17 @@ function renderDebugModel(
51605160
}
51615161

51625162
if(typeofvalue=== '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+
}
51635174
if (value.length >=1024){
51645175
// Large strings are counted towards the object limit.
51655176
if(counter.objectLimit<=0){

‎packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js‎

Lines changed: 263 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3669,4 +3669,267 @@ describe('ReactFlightAsyncDebugInfo', () => {
36693669

36703670
awaitfinishLoadingStream(readable);
36713671
});
3672+
3673+
it('omits large debug strings to avoid blocking the main thread when parsing',async()=>{
3674+
asyncfunctionComponent(){
3675+
// This promise's value is expected to show up in the debug info below.
3676+
constsmall=awaitnewPromise(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+
constlarge=awaitnewPromise(resolve=>{
3685+
setTimeout(()=>resolve('x'.repeat(1000001)),1);
3686+
});
3687+
3688+
returnsmall+' '+large.length;
3689+
}
3690+
3691+
conststream=ReactServerDOMServer.renderToPipeableStream(
3692+
ReactServer.createElement(Component),
3693+
{},
3694+
{filterStackFrame},
3695+
);
3696+
3697+
constreadable=newStream.PassThrough(streamOptions);
3698+
3699+
constresult=ReactServerDOMClient.createFromNodeStream(readable,{
3700+
moduleMap: {},
3701+
moduleLoading: {},
3702+
});
3703+
stream.pipe(readable);
3704+
3705+
expect(awaitresult).toBe('hello 1000001');
3706+
3707+
awaitfinishLoadingStream(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+
});
36723935
});

‎packages/shared/ReactPerformanceTrackProperties.js‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -275,7 +275,11 @@ export function addValueToProperties(
275275
if(value===OMITTED_PROP_ERROR){
276276
desc='\u2026';// ellipsis
277277
}else{
278-
desc=JSON.stringify(value);
278+
desc=JSON.stringify(
279+
value.length>=1024
280+
? value.slice(0,1023)+'\u2026'// ellipsis
281+
: value,
282+
);
279283
}
280284
break;
281285
case'undefined':

0 commit comments

Comments
 (0)
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

Commit f0dfee3

Browse files
authored
[Flight] Avoid main-thread stalls from large debug strings (#36570)
1 parent 6b5ea12 commit f0dfee3

6 files changed

Lines changed: 354 additions & 1 deletion

File tree

‎fixtures/flight/src/App.js‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import {like, greet, increment} from './actions.js';
2727

2828
import{getServerState}from'./ServerState.js';
2929
import{sdkMethod}from'./library.js';
30+
importFileReaderfrom'./FileReader.js';
3031

3132
constpromisedText=newPromise(resolve=>
3233
setTimeout(()=>resolve('deferred text'),50)
@@ -243,6 +244,11 @@ export default async function App({prerender, noCache}) {
243244
{prerender ? null : (// TODO: prerender is broken for large content for some reason.
244245
<React.Suspensefallback={null}>
245246
<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+
<FileReaderlargeText={'a'.repeat(1000001)}/>
246252
</React.Suspense>
247253
)}
248254
</Container>

‎fixtures/flight/src/FileReader.js‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
exportdefaultasyncfunctionFileReader(){
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+
awaitnewPromise(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+
awaitnewPromise(resolve=>{
12+
setTimeout(()=>resolve('x'.repeat(1000001)),1);
13+
});
14+
15+
return<p>FileReader</p>;
16+
}

‎packages/react-client/src/__tests__/ReactFlight-test.js‎

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3743,6 +3743,59 @@ describe('ReactFlight', () => {
37433743
expect(cyclic2.cycle).toBe(cyclic2);
37443744
});
37453745

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+
constlargeString='x'.repeat(1000001);
3753+
3754+
functionServerComponent(){
3755+
console.log('large string:',largeString);
3756+
returnnull;
3757+
}
3758+
3759+
functionApp(){
3760+
returnReactServer.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+
constmockConsoleLog=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+
consttransport=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+
awaitReactNoopFlightClient.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+
37463799
// @gate !__DEV__ || enableComponentPerformanceTrack
37473800
it('uses the server component debug info as the element owner in DEV',async()=>{
37483801
functionContainer({children}){

‎packages/react-server/src/ReactFlightServer.js‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5160,6 +5160,17 @@ function renderDebugModel(
51605160
}
51615161

51625162
if(typeofvalue=== '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+
}
51635174
if (value.length >=1024){
51645175
// Large strings are counted towards the object limit.
51655176
if(counter.objectLimit<=0){

‎packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js‎

Lines changed: 263 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3669,4 +3669,267 @@ describe('ReactFlightAsyncDebugInfo', () => {
36693669

36703670
awaitfinishLoadingStream(readable);
36713671
});
3672+
3673+
it('omits large debug strings to avoid blocking the main thread when parsing',async()=>{
3674+
asyncfunctionComponent(){
3675+
// This promise's value is expected to show up in the debug info below.
3676+
constsmall=awaitnewPromise(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+
constlarge=awaitnewPromise(resolve=>{
3685+
setTimeout(()=>resolve('x'.repeat(1000001)),1);
3686+
});
3687+
3688+
returnsmall+' '+large.length;
3689+
}
3690+
3691+
conststream=ReactServerDOMServer.renderToPipeableStream(
3692+
ReactServer.createElement(Component),
3693+
{},
3694+
{filterStackFrame},
3695+
);
3696+
3697+
constreadable=newStream.PassThrough(streamOptions);
3698+
3699+
constresult=ReactServerDOMClient.createFromNodeStream(readable,{
3700+
moduleMap: {},
3701+
moduleLoading: {},
3702+
});
3703+
stream.pipe(readable);
3704+
3705+
expect(awaitresult).toBe('hello 1000001');
3706+
3707+
awaitfinishLoadingStream(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+
});
36723935
});

‎packages/shared/ReactPerformanceTrackProperties.js‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -275,7 +275,11 @@ export function addValueToProperties(
275275
if(value===OMITTED_PROP_ERROR){
276276
desc='\u2026';// ellipsis
277277
}else{
278-
desc=JSON.stringify(value);
278+
desc=JSON.stringify(
279+
value.length>=1024
280+
? value.slice(0,1023)+'\u2026'// ellipsis
281+
: value,
282+
);
279283
}
280284
break;
281285
case'undefined':

0 commit comments

Comments
 (0)
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Commit f0dfee3

Browse files
authored
[Flight] Avoid main-thread stalls from large debug strings (#36570)
1 parent 6b5ea12 commit f0dfee3

6 files changed

Lines changed: 354 additions & 1 deletion

File tree

‎fixtures/flight/src/App.js‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import {like, greet, increment} from './actions.js';
2727

2828
import{getServerState}from'./ServerState.js';
2929
import{sdkMethod}from'./library.js';
30+
importFileReaderfrom'./FileReader.js';
3031

3132
constpromisedText=newPromise(resolve=>
3233
setTimeout(()=>resolve('deferred text'),50)
@@ -243,6 +244,11 @@ export default async function App({prerender, noCache}) {
243244
{prerender ? null : (// TODO: prerender is broken for large content for some reason.
244245
<React.Suspensefallback={null}>
245246
<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+
<FileReaderlargeText={'a'.repeat(1000001)}/>
246252
</React.Suspense>
247253
)}
248254
</Container>

‎fixtures/flight/src/FileReader.js‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
exportdefaultasyncfunctionFileReader(){
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+
awaitnewPromise(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+
awaitnewPromise(resolve=>{
12+
setTimeout(()=>resolve('x'.repeat(1000001)),1);
13+
});
14+
15+
return<p>FileReader</p>;
16+
}

‎packages/react-client/src/__tests__/ReactFlight-test.js‎

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3743,6 +3743,59 @@ describe('ReactFlight', () => {
37433743
expect(cyclic2.cycle).toBe(cyclic2);
37443744
});
37453745

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+
constlargeString='x'.repeat(1000001);
3753+
3754+
functionServerComponent(){
3755+
console.log('large string:',largeString);
3756+
returnnull;
3757+
}
3758+
3759+
functionApp(){
3760+
returnReactServer.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+
constmockConsoleLog=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+
consttransport=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+
awaitReactNoopFlightClient.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+
37463799
// @gate !__DEV__ || enableComponentPerformanceTrack
37473800
it('uses the server component debug info as the element owner in DEV',async()=>{
37483801
functionContainer({children}){

‎packages/react-server/src/ReactFlightServer.js‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5160,6 +5160,17 @@ function renderDebugModel(
51605160
}
51615161

51625162
if(typeofvalue=== '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+
}
51635174
if (value.length >=1024){
51645175
// Large strings are counted towards the object limit.
51655176
if(counter.objectLimit<=0){

‎packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js‎

Lines changed: 263 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3669,4 +3669,267 @@ describe('ReactFlightAsyncDebugInfo', () => {
36693669

36703670
awaitfinishLoadingStream(readable);
36713671
});
3672+
3673+
it('omits large debug strings to avoid blocking the main thread when parsing',async()=>{
3674+
asyncfunctionComponent(){
3675+
// This promise's value is expected to show up in the debug info below.
3676+
constsmall=awaitnewPromise(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+
constlarge=awaitnewPromise(resolve=>{
3685+
setTimeout(()=>resolve('x'.repeat(1000001)),1);
3686+
});
3687+
3688+
returnsmall+' '+large.length;
3689+
}
3690+
3691+
conststream=ReactServerDOMServer.renderToPipeableStream(
3692+
ReactServer.createElement(Component),
3693+
{},
3694+
{filterStackFrame},
3695+
);
3696+
3697+
constreadable=newStream.PassThrough(streamOptions);
3698+
3699+
constresult=ReactServerDOMClient.createFromNodeStream(readable,{
3700+
moduleMap: {},
3701+
moduleLoading: {},
3702+
});
3703+
stream.pipe(readable);
3704+
3705+
expect(awaitresult).toBe('hello 1000001');
3706+
3707+
awaitfinishLoadingStream(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+
});
36723935
});

‎packages/shared/ReactPerformanceTrackProperties.js‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -275,7 +275,11 @@ export function addValueToProperties(
275275
if(value===OMITTED_PROP_ERROR){
276276
desc='\u2026';// ellipsis
277277
}else{
278-
desc=JSON.stringify(value);
278+
desc=JSON.stringify(
279+
value.length>=1024
280+
? value.slice(0,1023)+'\u2026'// ellipsis
281+
: value,
282+
);
279283
}
280284
break;
281285
case'undefined':

0 commit comments

Comments
 (0)
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Commit f0dfee3

Browse files
authored
[Flight] Avoid main-thread stalls from large debug strings (#36570)
1 parent 6b5ea12 commit f0dfee3

6 files changed

Lines changed: 354 additions & 1 deletion

File tree

‎fixtures/flight/src/App.js‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import {like, greet, increment} from './actions.js';
2727

2828
import{getServerState}from'./ServerState.js';
2929
import{sdkMethod}from'./library.js';
30+
importFileReaderfrom'./FileReader.js';
3031

3132
constpromisedText=newPromise(resolve=>
3233
setTimeout(()=>resolve('deferred text'),50)
@@ -243,6 +244,11 @@ export default async function App({prerender, noCache}) {
243244
{prerender ? null : (// TODO: prerender is broken for large content for some reason.
244245
<React.Suspensefallback={null}>
245246
<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+
<FileReaderlargeText={'a'.repeat(1000001)}/>
246252
</React.Suspense>
247253
)}
248254
</Container>

‎fixtures/flight/src/FileReader.js‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
exportdefaultasyncfunctionFileReader(){
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+
awaitnewPromise(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+
awaitnewPromise(resolve=>{
12+
setTimeout(()=>resolve('x'.repeat(1000001)),1);
13+
});
14+
15+
return<p>FileReader</p>;
16+
}

‎packages/react-client/src/__tests__/ReactFlight-test.js‎

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3743,6 +3743,59 @@ describe('ReactFlight', () => {
37433743
expect(cyclic2.cycle).toBe(cyclic2);
37443744
});
37453745

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+
constlargeString='x'.repeat(1000001);
3753+
3754+
functionServerComponent(){
3755+
console.log('large string:',largeString);
3756+
returnnull;
3757+
}
3758+
3759+
functionApp(){
3760+
returnReactServer.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+
constmockConsoleLog=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+
consttransport=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+
awaitReactNoopFlightClient.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+
37463799
// @gate !__DEV__ || enableComponentPerformanceTrack
37473800
it('uses the server component debug info as the element owner in DEV',async()=>{
37483801
functionContainer({children}){

‎packages/react-server/src/ReactFlightServer.js‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5160,6 +5160,17 @@ function renderDebugModel(
51605160
}
51615161

51625162
if(typeofvalue=== '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+
}
51635174
if (value.length >=1024){
51645175
// Large strings are counted towards the object limit.
51655176
if(counter.objectLimit<=0){

‎packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js‎

Lines changed: 263 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3669,4 +3669,267 @@ describe('ReactFlightAsyncDebugInfo', () => {
36693669

36703670
awaitfinishLoadingStream(readable);
36713671
});
3672+
3673+
it('omits large debug strings to avoid blocking the main thread when parsing',async()=>{
3674+
asyncfunctionComponent(){
3675+
// This promise's value is expected to show up in the debug info below.
3676+
constsmall=awaitnewPromise(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+
constlarge=awaitnewPromise(resolve=>{
3685+
setTimeout(()=>resolve('x'.repeat(1000001)),1);
3686+
});
3687+
3688+
returnsmall+' '+large.length;
3689+
}
3690+
3691+
conststream=ReactServerDOMServer.renderToPipeableStream(
3692+
ReactServer.createElement(Component),
3693+
{},
3694+
{filterStackFrame},
3695+
);
3696+
3697+
constreadable=newStream.PassThrough(streamOptions);
3698+
3699+
constresult=ReactServerDOMClient.createFromNodeStream(readable,{
3700+
moduleMap: {},
3701+
moduleLoading: {},
3702+
});
3703+
stream.pipe(readable);
3704+
3705+
expect(awaitresult).toBe('hello 1000001');
3706+
3707+
awaitfinishLoadingStream(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+
});
36723935
});

‎packages/shared/ReactPerformanceTrackProperties.js‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -275,7 +275,11 @@ export function addValueToProperties(
275275
if(value===OMITTED_PROP_ERROR){
276276
desc='\u2026';// ellipsis
277277
}else{
278-
desc=JSON.stringify(value);
278+
desc=JSON.stringify(
279+
value.length>=1024
280+
? value.slice(0,1023)+'\u2026'// ellipsis
281+
: value,
282+
);
279283
}
280284
break;
281285
case'undefined':

0 commit comments

Comments
 (0)
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

Commit f0dfee3

Browse files
authored
[Flight] Avoid main-thread stalls from large debug strings (#36570)
1 parent 6b5ea12 commit f0dfee3

6 files changed

Lines changed: 354 additions & 1 deletion

File tree

‎fixtures/flight/src/App.js‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import {like, greet, increment} from './actions.js';
2727

2828
import{getServerState}from'./ServerState.js';
2929
import{sdkMethod}from'./library.js';
30+
importFileReaderfrom'./FileReader.js';
3031

3132
constpromisedText=newPromise(resolve=>
3233
setTimeout(()=>resolve('deferred text'),50)
@@ -243,6 +244,11 @@ export default async function App({prerender, noCache}) {
243244
{prerender ? null : (// TODO: prerender is broken for large content for some reason.
244245
<React.Suspensefallback={null}>
245246
<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+
<FileReaderlargeText={'a'.repeat(1000001)}/>
246252
</React.Suspense>
247253
)}
248254
</Container>

‎fixtures/flight/src/FileReader.js‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
exportdefaultasyncfunctionFileReader(){
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+
awaitnewPromise(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+
awaitnewPromise(resolve=>{
12+
setTimeout(()=>resolve('x'.repeat(1000001)),1);
13+
});
14+
15+
return<p>FileReader</p>;
16+
}

‎packages/react-client/src/__tests__/ReactFlight-test.js‎

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3743,6 +3743,59 @@ describe('ReactFlight', () => {
37433743
expect(cyclic2.cycle).toBe(cyclic2);
37443744
});
37453745

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+
constlargeString='x'.repeat(1000001);
3753+
3754+
functionServerComponent(){
3755+
console.log('large string:',largeString);
3756+
returnnull;
3757+
}
3758+
3759+
functionApp(){
3760+
returnReactServer.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+
constmockConsoleLog=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+
consttransport=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+
awaitReactNoopFlightClient.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+
37463799
// @gate !__DEV__ || enableComponentPerformanceTrack
37473800
it('uses the server component debug info as the element owner in DEV',async()=>{
37483801
functionContainer({children}){

‎packages/react-server/src/ReactFlightServer.js‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5160,6 +5160,17 @@ function renderDebugModel(
51605160
}
51615161

51625162
if(typeofvalue=== '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+
}
51635174
if (value.length >=1024){
51645175
// Large strings are counted towards the object limit.
51655176
if(counter.objectLimit<=0){

‎packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js‎

Lines changed: 263 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3669,4 +3669,267 @@ describe('ReactFlightAsyncDebugInfo', () => {
36693669

36703670
awaitfinishLoadingStream(readable);
36713671
});
3672+
3673+
it('omits large debug strings to avoid blocking the main thread when parsing',async()=>{
3674+
asyncfunctionComponent(){
3675+
// This promise's value is expected to show up in the debug info below.
3676+
constsmall=awaitnewPromise(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+
constlarge=awaitnewPromise(resolve=>{
3685+
setTimeout(()=>resolve('x'.repeat(1000001)),1);
3686+
});
3687+
3688+
returnsmall+' '+large.length;
3689+
}
3690+
3691+
conststream=ReactServerDOMServer.renderToPipeableStream(
3692+
ReactServer.createElement(Component),
3693+
{},
3694+
{filterStackFrame},
3695+
);
3696+
3697+
constreadable=newStream.PassThrough(streamOptions);
3698+
3699+
constresult=ReactServerDOMClient.createFromNodeStream(readable,{
3700+
moduleMap: {},
3701+
moduleLoading: {},
3702+
});
3703+
stream.pipe(readable);
3704+
3705+
expect(awaitresult).toBe('hello 1000001');
3706+
3707+
awaitfinishLoadingStream(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+
});
36723935
});

‎packages/shared/ReactPerformanceTrackProperties.js‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -275,7 +275,11 @@ export function addValueToProperties(
275275
if(value===OMITTED_PROP_ERROR){
276276
desc='\u2026';// ellipsis
277277
}else{
278-
desc=JSON.stringify(value);
278+
desc=JSON.stringify(
279+
value.length>=1024
280+
? value.slice(0,1023)+'\u2026'// ellipsis
281+
: value,
282+
);
279283
}
280284
break;
281285
case'undefined':

0 commit comments

Comments
 (0)