Commit 0ba2f01

Browse files
authored
Rename <Suspense unstable_expectedLoadTime> to <Suspense defer> and implement in SSR (#35022)
We've long had the CPU suspense feature behind a flag under the terrible API `unstable_expectedLoadTime={arbitraryNumber}`. We've known for a long time we want it to just be `defer={true}` (or just `<Suspense defer>` in the short hand syntax). So this adds the new name and warns for the old name. For only the new name, I also implemented SSR semantics in Fizz. It has two effects here. 1) It renders the fallback before the content (similar to prerender) allowing siblings to complete quicker. 2) It always outlines the result. When streaming this should really happen naturally but if you defer a prerendered content it also implies that it's expensive and should be outlined. It gives you a opt-in to outlining similar to suspensey images and css but let you control it manually.
1 parent dd048c3 commit 0ba2f01

5 files changed

Lines changed: 132 additions & 32 deletions

File tree

‎packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js‎

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9433,4 +9433,34 @@ Unfortunately that previous paragraph wasn't quite long enough so I'll continue
94339433
</html>,
94349434
);
94359435
});
9436+
9437+
// @gate enableCPUSuspense
9438+
it('outlines deferred Suspense boundaries',async()=>{
9439+
functionLog({text}){
9440+
Scheduler.log(text);
9441+
returntext;
9442+
}
9443+
9444+
awaitact(async()=>{
9445+
renderToPipeableStream(
9446+
<div>
9447+
<Suspensedefer={true}fallback={<Logtext="Waiting"/>}>
9448+
<span>{<Logtext="hello"/>}</span>
9449+
</Suspense>
9450+
</div>,
9451+
).pipe(writable);
9452+
awaitjest.runAllTimers();
9453+
consttemp=document.createElement('body');
9454+
temp.innerHTML=buffer;
9455+
expect(getVisibleChildren(temp)).toEqual(<div>Waiting</div>);
9456+
});
9457+
9458+
assertLog(['Waiting','hello']);
9459+
9460+
expect(getVisibleChildren(container)).toEqual(
9461+
<div>
9462+
<span>hello</span>
9463+
</div>,
9464+
);
9465+
});
94369466
});

‎packages/react-reconciler/src/ReactFiberBeginWork.js‎

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -325,6 +325,7 @@ export let didWarnAboutReassigningProps: boolean;
325325
letdidWarnAboutRevealOrder;
326326
letdidWarnAboutTailOptions;
327327
letdidWarnAboutClassNameOnViewTransition;
328+
letdidWarnAboutExpectedLoadTime=false;
328329

329330
if(__DEV__){
330331
didWarnAboutBadClass=({}: {[string]: boolean});
@@ -2458,8 +2459,20 @@ function updateSuspenseComponent(
24582459
returnbailoutOffscreenComponent(null,primaryChildFragment);
24592460
}elseif(
24602461
enableCPUSuspense&&
2461-
typeofnextProps.unstable_expectedLoadTime==='number'
2462+
(typeofnextProps.unstable_expectedLoadTime==='number'||
2463+
nextProps.defer===true)
24622464
){
2465+
if(__DEV__){
2466+
if(typeofnextProps.unstable_expectedLoadTime==='number'){
2467+
if(!didWarnAboutExpectedLoadTime){
2468+
didWarnAboutExpectedLoadTime=true;
2469+
console.error(
2470+
'<Suspense unstable_expectedLoadTime={...}> is deprecated. '+
2471+
'Use <Suspense defer={true}> instead.',
2472+
);
2473+
}
2474+
}
2475+
}
24632476
// This is a CPU-bound tree. Skip this tree and show a placeholder to
24642477
// unblock the surrounding content. Then immediately retry after the
24652478
// initial commit.

‎packages/react-reconciler/src/__tests__/ReactCPUSuspense-test.js‎

Lines changed: 53 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
/* eslint-disable react/jsx-boolean-value */
2+
13
letReact;
24
letReactNoop;
35
letScheduler;
@@ -11,6 +13,7 @@ let resolveText;
1113
// let rejectText;
1214

1315
letassertLog;
16+
letassertConsoleErrorDev;
1417
letwaitForPaint;
1518

1619
describe('ReactSuspenseWithNoopRenderer',()=>{
@@ -26,6 +29,7 @@ describe('ReactSuspenseWithNoopRenderer', () => {
2629

2730
constInternalTestUtils=require('internal-test-utils');
2831
assertLog=InternalTestUtils.assertLog;
32+
assertConsoleErrorDev=InternalTestUtils.assertConsoleErrorDev;
2933
waitForPaint=InternalTestUtils.waitForPaint;
3034

3135
textCache=newMap();
@@ -116,14 +120,14 @@ describe('ReactSuspenseWithNoopRenderer', () => {
116120
}
117121

118122
// @gate enableCPUSuspense
119-
it('skips CPU-bound trees on initial mount',async()=>{
123+
it('warns for the old name is used',async()=>{
120124
functionApp(){
121125
return(
122126
<>
123127
<Texttext="Outer"/>
124128
<div>
125129
<Suspense
126-
unstable_expectedLoadTime={2000}
130+
unstable_expectedLoadTime={1000}
127131
fallback={<Texttext="Loading..."/>}>
128132
<Texttext="Inner"/>
129133
</Suspense>
@@ -132,6 +136,49 @@ describe('ReactSuspenseWithNoopRenderer', () => {
132136
);
133137
}
134138

139+
constroot=ReactNoop.createRoot();
140+
awaitact(async()=>{
141+
root.render(<App/>);
142+
awaitwaitForPaint(['Outer','Loading...']);
143+
assertConsoleErrorDev([
144+
'<Suspense unstable_expectedLoadTime={...}> is deprecated. '+
145+
'Use <Suspense defer={true}> instead.'+
146+
'\n in Suspense (at **)'+
147+
'\n in App (at **)',
148+
]);
149+
expect(root).toMatchRenderedOutput(
150+
<>
151+
Outer
152+
<div>Loading...</div>
153+
</>,
154+
);
155+
});
156+
157+
// Inner contents finish in separate commit from outer
158+
assertLog(['Inner']);
159+
expect(root).toMatchRenderedOutput(
160+
<>
161+
Outer
162+
<div>Inner</div>
163+
</>,
164+
);
165+
});
166+
167+
// @gate enableCPUSuspense
168+
it('skips CPU-bound trees on initial mount',async()=>{
169+
functionApp(){
170+
return(
171+
<>
172+
<Texttext="Outer"/>
173+
<div>
174+
<Suspensedeferfallback={<Texttext="Loading..."/>}>
175+
<Texttext="Inner"/>
176+
</Suspense>
177+
</div>
178+
</>
179+
);
180+
}
181+
135182
constroot=ReactNoop.createRoot();
136183
awaitact(async()=>{
137184
root.render(<App/>);
@@ -164,9 +211,7 @@ describe('ReactSuspenseWithNoopRenderer', () => {
164211
<>
165212
<Texttext="Outer"/>
166213
<div>
167-
<Suspense
168-
unstable_expectedLoadTime={2000}
169-
fallback={<Texttext="Loading..."/>}>
214+
<Suspensedeferfallback={<Texttext="Loading..."/>}>
170215
<Texttext={`Inner [${count}]`}/>
171216
</Suspense>
172217
</div>
@@ -209,9 +254,7 @@ describe('ReactSuspenseWithNoopRenderer', () => {
209254
<>
210255
<Texttext="Outer"/>
211256
<div>
212-
<Suspense
213-
unstable_expectedLoadTime={2000}
214-
fallback={<Texttext="Loading..."/>}>
257+
<Suspensedeferfallback={<Texttext="Loading..."/>}>
215258
<AsyncTexttext="Inner"/>
216259
</Suspense>
217260
</div>
@@ -263,14 +306,10 @@ describe('ReactSuspenseWithNoopRenderer', () => {
263306
<>
264307
<Texttext="A"/>
265308
<div>
266-
<Suspense
267-
unstable_expectedLoadTime={2000}
268-
fallback={<Texttext="Loading B..."/>}>
309+
<Suspensedeferfallback={<Texttext="Loading B..."/>}>
269310
<Texttext="B"/>
270311
<div>
271-
<Suspense
272-
unstable_expectedLoadTime={2000}
273-
fallback={<Texttext="Loading C..."/>}>
312+
<Suspensedeferfallback={<Texttext="Loading C..."/>}>
274313
<Texttext="C"/>
275314
</Suspense>
276315
</div>

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

Lines changed: 34 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,7 @@ import {
181181
enableViewTransition,
182182
enableFizzBlockingRender,
183183
enableAsyncDebugInfo,
184+
enableCPUSuspense,
184185
}from'shared/ReactFeatureFlags';
185186

186187
importassignfrom'shared/assign';
@@ -250,6 +251,7 @@ type SuspenseBoundary = {
250251
row: null|SuspenseListRow,// the row that this boundary blocks from completing.
251252
completedSegments: Array<Segment>,// completed but not yet flushed segments.
252253
byteSize: number,// used to determine whether to inline children boundaries.
254+
defer: boolean,// never inline deferred boundaries
253255
fallbackAbortableTasks: Set<Task>,// used to cancel task on the fallback if the boundary completes or gets canceled.
254256
contentState: HoistableState,
255257
fallbackState: HoistableState,
@@ -456,7 +458,9 @@ function isEligibleForOutlining(
456458
// The larger this limit is, the more we can save on preparing fallbacks in case we end up
457459
// outlining.
458460
return(
459-
(boundary.byteSize>500||hasSuspenseyContent(boundary.contentState))&&
461+
(boundary.byteSize>500||
462+
hasSuspenseyContent(boundary.contentState)||
463+
boundary.defer)&&
460464
// For boundaries that can possibly contribute to the preamble we don't want to outline
461465
// them regardless of their size since the fallbacks should only be emitted if we've
462466
// errored the boundary.
@@ -782,6 +786,7 @@ function createSuspenseBoundary(
782786
fallbackAbortableTasks: Set<Task>,
783787
contentPreamble: null | Preamble,
784788
fallbackPreamble: null | Preamble,
789+
defer: boolean,
785790
): SuspenseBoundary {
786791
constboundary: SuspenseBoundary={
787792
status: PENDING,
@@ -791,6 +796,7 @@ function createSuspenseBoundary(
791796
row: row,
792797
completedSegments: [],
793798
byteSize: 0,
799+
defer: defer,
794800
fallbackAbortableTasks,
795801
errorDigest: null,
796802
contentState: createHoistableState(),
@@ -1274,6 +1280,7 @@ function renderSuspenseBoundary(
12741280
// in case it ends up generating a large subtree of content.
12751281
constfallback: ReactNodeList=props.fallback;
12761282
constcontent: ReactNodeList=props.children;
1283+
constdefer: boolean=enableCPUSuspense&&props.defer===true;
12771284

12781285
constfallbackAbortSet: Set<Task>=newSet();
12791286
letnewBoundary: SuspenseBoundary;
@@ -1284,6 +1291,7 @@ function renderSuspenseBoundary(
12841291
fallbackAbortSet,
12851292
createPreambleState(),
12861293
createPreambleState(),
1294+
defer,
12871295
);
12881296
}else{
12891297
newBoundary=createSuspenseBoundary(
@@ -1292,6 +1300,7 @@ function renderSuspenseBoundary(
12921300
fallbackAbortSet,
12931301
null,
12941302
null,
1303+
defer,
12951304
);
12961305
}
12971306
if(request.trackedPostpones!==null){
@@ -1327,29 +1336,32 @@ function renderSuspenseBoundary(
13271336
// no parent segment so there's nothing to wait on.
13281337
contentRootSegment.parentFlushed=true;
13291338

1330-
if(request.trackedPostpones!==null){
1339+
consttrackedPostpones=request.trackedPostpones;
1340+
if(trackedPostpones!==null||defer){
1341+
// This is a prerender or deferred boundary. In this mode we want to render the fallback synchronously
1342+
// and schedule the content to render later. This is the opposite of what we do during a normal render
1343+
// where we try to skip rendering the fallback if the content itself can render synchronously
1344+
13311345
// Stash the original stack frame.
13321346
constsuspenseComponentStack=task.componentStack;
1333-
// This is a prerender. In this mode we want to render the fallback synchronously and schedule
1334-
// the content to render later. This is the opposite of what we do during a normal render
1335-
// where we try to skip rendering the fallback if the content itself can render synchronously
1336-
consttrackedPostpones=request.trackedPostpones;
13371347

13381348
constfallbackKeyPath: KeyNode=[
13391349
keyPath[0],
13401350
'Suspense Fallback',
13411351
keyPath[2],
13421352
];
1343-
constfallbackReplayNode: ReplayNode=[
1344-
fallbackKeyPath[1],
1345-
fallbackKeyPath[2],
1346-
([]: Array<ReplayNode>),
1347-
null,
1348-
];
1349-
trackedPostpones.workingMap.set(fallbackKeyPath,fallbackReplayNode);
1350-
// We are rendering the fallback before the boundary content so we keep track of
1351-
// the fallback replay node until we determine if the primary content suspends
1352-
newBoundary.trackedFallbackNode=fallbackReplayNode;
1353+
if(trackedPostpones!==null){
1354+
constfallbackReplayNode: ReplayNode=[
1355+
fallbackKeyPath[1],
1356+
fallbackKeyPath[2],
1357+
([]: Array<ReplayNode>),
1358+
null,
1359+
];
1360+
trackedPostpones.workingMap.set(fallbackKeyPath,fallbackReplayNode);
1361+
// We are rendering the fallback before the boundary content so we keep track of
1362+
// the fallback replay node until we determine if the primary content suspends
1363+
newBoundary.trackedFallbackNode=fallbackReplayNode;
1364+
}
13531365

13541366
task.blockedSegment=boundarySegment;
13551367
task.blockedPreamble=newBoundary.fallbackPreamble;
@@ -1580,6 +1592,7 @@ function replaySuspenseBoundary(
15801592

15811593
constcontent: ReactNodeList=props.children;
15821594
constfallback: ReactNodeList=props.fallback;
1595+
constdefer: boolean=enableCPUSuspense&&props.defer===true;
15831596

15841597
constfallbackAbortSet: Set<Task>=newSet();
15851598
letresumedBoundary: SuspenseBoundary;
@@ -1590,6 +1603,7 @@ function replaySuspenseBoundary(
15901603
fallbackAbortSet,
15911604
createPreambleState(),
15921605
createPreambleState(),
1606+
defer,
15931607
);
15941608
}else{
15951609
resumedBoundary =createSuspenseBoundary(
@@ -1598,6 +1612,7 @@ function replaySuspenseBoundary(
15981612
fallbackAbortSet,
15991613
null,
16001614
null,
1615+
defer,
16011616
);
16021617
}
16031618
resumedBoundary.parentFlushed=true;
@@ -4384,6 +4399,7 @@ function abortRemainingSuspenseBoundary(
43844399
newSet(),
43854400
null,
43864401
null,
4402+
false,
43874403
);
43884404
resumedBoundary.parentFlushed=true;
43894405
// We restore the same id of this boundary as was used during prerender.
@@ -5493,7 +5509,8 @@ function flushSegment(
54935509
!flushingPartialBoundaries&&
54945510
isEligibleForOutlining(request,boundary)&&
54955511
(flushedByteSize+boundary.byteSize>request.progressiveChunkSize||
5496-
hasSuspenseyContent(boundary.contentState))
5512+
hasSuspenseyContent(boundary.contentState)||
5513+
boundary.defer)
54975514
){
54985515
// Inlining this boundary would make the current sequence being written too large
54995516
// and block the parent for too long. Instead, it will be emitted separately so that we

‎packages/shared/ReactTypes.js‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -313,6 +313,7 @@ export type SuspenseProps = {
313313

314314
unstable_avoidThisFallback?: boolean,
315315
unstable_expectedLoadTime?: number,
316+
defer?: boolean,
316317
name?: string,
317318
};
318319

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 0ba2f01

Browse files
authored
Rename <Suspense unstable_expectedLoadTime> to <Suspense defer> and implement in SSR (#35022)
We've long had the CPU suspense feature behind a flag under the terrible API `unstable_expectedLoadTime={arbitraryNumber}`. We've known for a long time we want it to just be `defer={true}` (or just `<Suspense defer>` in the short hand syntax). So this adds the new name and warns for the old name. For only the new name, I also implemented SSR semantics in Fizz. It has two effects here. 1) It renders the fallback before the content (similar to prerender) allowing siblings to complete quicker. 2) It always outlines the result. When streaming this should really happen naturally but if you defer a prerendered content it also implies that it's expensive and should be outlined. It gives you a opt-in to outlining similar to suspensey images and css but let you control it manually.
1 parent dd048c3 commit 0ba2f01

5 files changed

Lines changed: 132 additions & 32 deletions

File tree

‎packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js‎

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9433,4 +9433,34 @@ Unfortunately that previous paragraph wasn't quite long enough so I'll continue
94339433
</html>,
94349434
);
94359435
});
9436+
9437+
// @gate enableCPUSuspense
9438+
it('outlines deferred Suspense boundaries',async()=>{
9439+
functionLog({text}){
9440+
Scheduler.log(text);
9441+
returntext;
9442+
}
9443+
9444+
awaitact(async()=>{
9445+
renderToPipeableStream(
9446+
<div>
9447+
<Suspensedefer={true}fallback={<Logtext="Waiting"/>}>
9448+
<span>{<Logtext="hello"/>}</span>
9449+
</Suspense>
9450+
</div>,
9451+
).pipe(writable);
9452+
awaitjest.runAllTimers();
9453+
consttemp=document.createElement('body');
9454+
temp.innerHTML=buffer;
9455+
expect(getVisibleChildren(temp)).toEqual(<div>Waiting</div>);
9456+
});
9457+
9458+
assertLog(['Waiting','hello']);
9459+
9460+
expect(getVisibleChildren(container)).toEqual(
9461+
<div>
9462+
<span>hello</span>
9463+
</div>,
9464+
);
9465+
});
94369466
});

‎packages/react-reconciler/src/ReactFiberBeginWork.js‎

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -325,6 +325,7 @@ export let didWarnAboutReassigningProps: boolean;
325325
letdidWarnAboutRevealOrder;
326326
letdidWarnAboutTailOptions;
327327
letdidWarnAboutClassNameOnViewTransition;
328+
letdidWarnAboutExpectedLoadTime=false;
328329

329330
if(__DEV__){
330331
didWarnAboutBadClass=({}: {[string]: boolean});
@@ -2458,8 +2459,20 @@ function updateSuspenseComponent(
24582459
returnbailoutOffscreenComponent(null,primaryChildFragment);
24592460
}elseif(
24602461
enableCPUSuspense&&
2461-
typeofnextProps.unstable_expectedLoadTime==='number'
2462+
(typeofnextProps.unstable_expectedLoadTime==='number'||
2463+
nextProps.defer===true)
24622464
){
2465+
if(__DEV__){
2466+
if(typeofnextProps.unstable_expectedLoadTime==='number'){
2467+
if(!didWarnAboutExpectedLoadTime){
2468+
didWarnAboutExpectedLoadTime=true;
2469+
console.error(
2470+
'<Suspense unstable_expectedLoadTime={...}> is deprecated. '+
2471+
'Use <Suspense defer={true}> instead.',
2472+
);
2473+
}
2474+
}
2475+
}
24632476
// This is a CPU-bound tree. Skip this tree and show a placeholder to
24642477
// unblock the surrounding content. Then immediately retry after the
24652478
// initial commit.

‎packages/react-reconciler/src/__tests__/ReactCPUSuspense-test.js‎

Lines changed: 53 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
/* eslint-disable react/jsx-boolean-value */
2+
13
letReact;
24
letReactNoop;
35
letScheduler;
@@ -11,6 +13,7 @@ let resolveText;
1113
// let rejectText;
1214

1315
letassertLog;
16+
letassertConsoleErrorDev;
1417
letwaitForPaint;
1518

1619
describe('ReactSuspenseWithNoopRenderer',()=>{
@@ -26,6 +29,7 @@ describe('ReactSuspenseWithNoopRenderer', () => {
2629

2730
constInternalTestUtils=require('internal-test-utils');
2831
assertLog=InternalTestUtils.assertLog;
32+
assertConsoleErrorDev=InternalTestUtils.assertConsoleErrorDev;
2933
waitForPaint=InternalTestUtils.waitForPaint;
3034

3135
textCache=newMap();
@@ -116,14 +120,14 @@ describe('ReactSuspenseWithNoopRenderer', () => {
116120
}
117121

118122
// @gate enableCPUSuspense
119-
it('skips CPU-bound trees on initial mount',async()=>{
123+
it('warns for the old name is used',async()=>{
120124
functionApp(){
121125
return(
122126
<>
123127
<Texttext="Outer"/>
124128
<div>
125129
<Suspense
126-
unstable_expectedLoadTime={2000}
130+
unstable_expectedLoadTime={1000}
127131
fallback={<Texttext="Loading..."/>}>
128132
<Texttext="Inner"/>
129133
</Suspense>
@@ -132,6 +136,49 @@ describe('ReactSuspenseWithNoopRenderer', () => {
132136
);
133137
}
134138

139+
constroot=ReactNoop.createRoot();
140+
awaitact(async()=>{
141+
root.render(<App/>);
142+
awaitwaitForPaint(['Outer','Loading...']);
143+
assertConsoleErrorDev([
144+
'<Suspense unstable_expectedLoadTime={...}> is deprecated. '+
145+
'Use <Suspense defer={true}> instead.'+
146+
'\n in Suspense (at **)'+
147+
'\n in App (at **)',
148+
]);
149+
expect(root).toMatchRenderedOutput(
150+
<>
151+
Outer
152+
<div>Loading...</div>
153+
</>,
154+
);
155+
});
156+
157+
// Inner contents finish in separate commit from outer
158+
assertLog(['Inner']);
159+
expect(root).toMatchRenderedOutput(
160+
<>
161+
Outer
162+
<div>Inner</div>
163+
</>,
164+
);
165+
});
166+
167+
// @gate enableCPUSuspense
168+
it('skips CPU-bound trees on initial mount',async()=>{
169+
functionApp(){
170+
return(
171+
<>
172+
<Texttext="Outer"/>
173+
<div>
174+
<Suspensedeferfallback={<Texttext="Loading..."/>}>
175+
<Texttext="Inner"/>
176+
</Suspense>
177+
</div>
178+
</>
179+
);
180+
}
181+
135182
constroot=ReactNoop.createRoot();
136183
awaitact(async()=>{
137184
root.render(<App/>);
@@ -164,9 +211,7 @@ describe('ReactSuspenseWithNoopRenderer', () => {
164211
<>
165212
<Texttext="Outer"/>
166213
<div>
167-
<Suspense
168-
unstable_expectedLoadTime={2000}
169-
fallback={<Texttext="Loading..."/>}>
214+
<Suspensedeferfallback={<Texttext="Loading..."/>}>
170215
<Texttext={`Inner [${count}]`}/>
171216
</Suspense>
172217
</div>
@@ -209,9 +254,7 @@ describe('ReactSuspenseWithNoopRenderer', () => {
209254
<>
210255
<Texttext="Outer"/>
211256
<div>
212-
<Suspense
213-
unstable_expectedLoadTime={2000}
214-
fallback={<Texttext="Loading..."/>}>
257+
<Suspensedeferfallback={<Texttext="Loading..."/>}>
215258
<AsyncTexttext="Inner"/>
216259
</Suspense>
217260
</div>
@@ -263,14 +306,10 @@ describe('ReactSuspenseWithNoopRenderer', () => {
263306
<>
264307
<Texttext="A"/>
265308
<div>
266-
<Suspense
267-
unstable_expectedLoadTime={2000}
268-
fallback={<Texttext="Loading B..."/>}>
309+
<Suspensedeferfallback={<Texttext="Loading B..."/>}>
269310
<Texttext="B"/>
270311
<div>
271-
<Suspense
272-
unstable_expectedLoadTime={2000}
273-
fallback={<Texttext="Loading C..."/>}>
312+
<Suspensedeferfallback={<Texttext="Loading C..."/>}>
274313
<Texttext="C"/>
275314
</Suspense>
276315
</div>

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

Lines changed: 34 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,7 @@ import {
181181
enableViewTransition,
182182
enableFizzBlockingRender,
183183
enableAsyncDebugInfo,
184+
enableCPUSuspense,
184185
}from'shared/ReactFeatureFlags';
185186

186187
importassignfrom'shared/assign';
@@ -250,6 +251,7 @@ type SuspenseBoundary = {
250251
row: null|SuspenseListRow,// the row that this boundary blocks from completing.
251252
completedSegments: Array<Segment>,// completed but not yet flushed segments.
252253
byteSize: number,// used to determine whether to inline children boundaries.
254+
defer: boolean,// never inline deferred boundaries
253255
fallbackAbortableTasks: Set<Task>,// used to cancel task on the fallback if the boundary completes or gets canceled.
254256
contentState: HoistableState,
255257
fallbackState: HoistableState,
@@ -456,7 +458,9 @@ function isEligibleForOutlining(
456458
// The larger this limit is, the more we can save on preparing fallbacks in case we end up
457459
// outlining.
458460
return(
459-
(boundary.byteSize>500||hasSuspenseyContent(boundary.contentState))&&
461+
(boundary.byteSize>500||
462+
hasSuspenseyContent(boundary.contentState)||
463+
boundary.defer)&&
460464
// For boundaries that can possibly contribute to the preamble we don't want to outline
461465
// them regardless of their size since the fallbacks should only be emitted if we've
462466
// errored the boundary.
@@ -782,6 +786,7 @@ function createSuspenseBoundary(
782786
fallbackAbortableTasks: Set<Task>,
783787
contentPreamble: null | Preamble,
784788
fallbackPreamble: null | Preamble,
789+
defer: boolean,
785790
): SuspenseBoundary {
786791
constboundary: SuspenseBoundary={
787792
status: PENDING,
@@ -791,6 +796,7 @@ function createSuspenseBoundary(
791796
row: row,
792797
completedSegments: [],
793798
byteSize: 0,
799+
defer: defer,
794800
fallbackAbortableTasks,
795801
errorDigest: null,
796802
contentState: createHoistableState(),
@@ -1274,6 +1280,7 @@ function renderSuspenseBoundary(
12741280
// in case it ends up generating a large subtree of content.
12751281
constfallback: ReactNodeList=props.fallback;
12761282
constcontent: ReactNodeList=props.children;
1283+
constdefer: boolean=enableCPUSuspense&&props.defer===true;
12771284

12781285
constfallbackAbortSet: Set<Task>=newSet();
12791286
letnewBoundary: SuspenseBoundary;
@@ -1284,6 +1291,7 @@ function renderSuspenseBoundary(
12841291
fallbackAbortSet,
12851292
createPreambleState(),
12861293
createPreambleState(),
1294+
defer,
12871295
);
12881296
}else{
12891297
newBoundary=createSuspenseBoundary(
@@ -1292,6 +1300,7 @@ function renderSuspenseBoundary(
12921300
fallbackAbortSet,
12931301
null,
12941302
null,
1303+
defer,
12951304
);
12961305
}
12971306
if(request.trackedPostpones!==null){
@@ -1327,29 +1336,32 @@ function renderSuspenseBoundary(
13271336
// no parent segment so there's nothing to wait on.
13281337
contentRootSegment.parentFlushed=true;
13291338

1330-
if(request.trackedPostpones!==null){
1339+
consttrackedPostpones=request.trackedPostpones;
1340+
if(trackedPostpones!==null||defer){
1341+
// This is a prerender or deferred boundary. In this mode we want to render the fallback synchronously
1342+
// and schedule the content to render later. This is the opposite of what we do during a normal render
1343+
// where we try to skip rendering the fallback if the content itself can render synchronously
1344+
13311345
// Stash the original stack frame.
13321346
constsuspenseComponentStack=task.componentStack;
1333-
// This is a prerender. In this mode we want to render the fallback synchronously and schedule
1334-
// the content to render later. This is the opposite of what we do during a normal render
1335-
// where we try to skip rendering the fallback if the content itself can render synchronously
1336-
consttrackedPostpones=request.trackedPostpones;
13371347

13381348
constfallbackKeyPath: KeyNode=[
13391349
keyPath[0],
13401350
'Suspense Fallback',
13411351
keyPath[2],
13421352
];
1343-
constfallbackReplayNode: ReplayNode=[
1344-
fallbackKeyPath[1],
1345-
fallbackKeyPath[2],
1346-
([]: Array<ReplayNode>),
1347-
null,
1348-
];
1349-
trackedPostpones.workingMap.set(fallbackKeyPath,fallbackReplayNode);
1350-
// We are rendering the fallback before the boundary content so we keep track of
1351-
// the fallback replay node until we determine if the primary content suspends
1352-
newBoundary.trackedFallbackNode=fallbackReplayNode;
1353+
if(trackedPostpones!==null){
1354+
constfallbackReplayNode: ReplayNode=[
1355+
fallbackKeyPath[1],
1356+
fallbackKeyPath[2],
1357+
([]: Array<ReplayNode>),
1358+
null,
1359+
];
1360+
trackedPostpones.workingMap.set(fallbackKeyPath,fallbackReplayNode);
1361+
// We are rendering the fallback before the boundary content so we keep track of
1362+
// the fallback replay node until we determine if the primary content suspends
1363+
newBoundary.trackedFallbackNode=fallbackReplayNode;
1364+
}
13531365

13541366
task.blockedSegment=boundarySegment;
13551367
task.blockedPreamble=newBoundary.fallbackPreamble;
@@ -1580,6 +1592,7 @@ function replaySuspenseBoundary(
15801592

15811593
constcontent: ReactNodeList=props.children;
15821594
constfallback: ReactNodeList=props.fallback;
1595+
constdefer: boolean=enableCPUSuspense&&props.defer===true;
15831596

15841597
constfallbackAbortSet: Set<Task>=newSet();
15851598
letresumedBoundary: SuspenseBoundary;
@@ -1590,6 +1603,7 @@ function replaySuspenseBoundary(
15901603
fallbackAbortSet,
15911604
createPreambleState(),
15921605
createPreambleState(),
1606+
defer,
15931607
);
15941608
}else{
15951609
resumedBoundary =createSuspenseBoundary(
@@ -1598,6 +1612,7 @@ function replaySuspenseBoundary(
15981612
fallbackAbortSet,
15991613
null,
16001614
null,
1615+
defer,
16011616
);
16021617
}
16031618
resumedBoundary.parentFlushed=true;
@@ -4384,6 +4399,7 @@ function abortRemainingSuspenseBoundary(
43844399
newSet(),
43854400
null,
43864401
null,
4402+
false,
43874403
);
43884404
resumedBoundary.parentFlushed=true;
43894405
// We restore the same id of this boundary as was used during prerender.
@@ -5493,7 +5509,8 @@ function flushSegment(
54935509
!flushingPartialBoundaries&&
54945510
isEligibleForOutlining(request,boundary)&&
54955511
(flushedByteSize+boundary.byteSize>request.progressiveChunkSize||
5496-
hasSuspenseyContent(boundary.contentState))
5512+
hasSuspenseyContent(boundary.contentState)||
5513+
boundary.defer)
54975514
){
54985515
// Inlining this boundary would make the current sequence being written too large
54995516
// and block the parent for too long. Instead, it will be emitted separately so that we

‎packages/shared/ReactTypes.js‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -313,6 +313,7 @@ export type SuspenseProps = {
313313

314314
unstable_avoidThisFallback?: boolean,
315315
unstable_expectedLoadTime?: number,
316+
defer?: boolean,
316317
name?: string,
317318
};
318319

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 0ba2f01

Browse files
authored
Rename <Suspense unstable_expectedLoadTime> to <Suspense defer> and implement in SSR (#35022)
We've long had the CPU suspense feature behind a flag under the terrible API `unstable_expectedLoadTime={arbitraryNumber}`. We've known for a long time we want it to just be `defer={true}` (or just `<Suspense defer>` in the short hand syntax). So this adds the new name and warns for the old name. For only the new name, I also implemented SSR semantics in Fizz. It has two effects here. 1) It renders the fallback before the content (similar to prerender) allowing siblings to complete quicker. 2) It always outlines the result. When streaming this should really happen naturally but if you defer a prerendered content it also implies that it's expensive and should be outlined. It gives you a opt-in to outlining similar to suspensey images and css but let you control it manually.
1 parent dd048c3 commit 0ba2f01

5 files changed

Lines changed: 132 additions & 32 deletions

File tree

‎packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js‎

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9433,4 +9433,34 @@ Unfortunately that previous paragraph wasn't quite long enough so I'll continue
94339433
</html>,
94349434
);
94359435
});
9436+
9437+
// @gate enableCPUSuspense
9438+
it('outlines deferred Suspense boundaries',async()=>{
9439+
functionLog({text}){
9440+
Scheduler.log(text);
9441+
returntext;
9442+
}
9443+
9444+
awaitact(async()=>{
9445+
renderToPipeableStream(
9446+
<div>
9447+
<Suspensedefer={true}fallback={<Logtext="Waiting"/>}>
9448+
<span>{<Logtext="hello"/>}</span>
9449+
</Suspense>
9450+
</div>,
9451+
).pipe(writable);
9452+
awaitjest.runAllTimers();
9453+
consttemp=document.createElement('body');
9454+
temp.innerHTML=buffer;
9455+
expect(getVisibleChildren(temp)).toEqual(<div>Waiting</div>);
9456+
});
9457+
9458+
assertLog(['Waiting','hello']);
9459+
9460+
expect(getVisibleChildren(container)).toEqual(
9461+
<div>
9462+
<span>hello</span>
9463+
</div>,
9464+
);
9465+
});
94369466
});

‎packages/react-reconciler/src/ReactFiberBeginWork.js‎

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -325,6 +325,7 @@ export let didWarnAboutReassigningProps: boolean;
325325
letdidWarnAboutRevealOrder;
326326
letdidWarnAboutTailOptions;
327327
letdidWarnAboutClassNameOnViewTransition;
328+
letdidWarnAboutExpectedLoadTime=false;
328329

329330
if(__DEV__){
330331
didWarnAboutBadClass=({}: {[string]: boolean});
@@ -2458,8 +2459,20 @@ function updateSuspenseComponent(
24582459
returnbailoutOffscreenComponent(null,primaryChildFragment);
24592460
}elseif(
24602461
enableCPUSuspense&&
2461-
typeofnextProps.unstable_expectedLoadTime==='number'
2462+
(typeofnextProps.unstable_expectedLoadTime==='number'||
2463+
nextProps.defer===true)
24622464
){
2465+
if(__DEV__){
2466+
if(typeofnextProps.unstable_expectedLoadTime==='number'){
2467+
if(!didWarnAboutExpectedLoadTime){
2468+
didWarnAboutExpectedLoadTime=true;
2469+
console.error(
2470+
'<Suspense unstable_expectedLoadTime={...}> is deprecated. '+
2471+
'Use <Suspense defer={true}> instead.',
2472+
);
2473+
}
2474+
}
2475+
}
24632476
// This is a CPU-bound tree. Skip this tree and show a placeholder to
24642477
// unblock the surrounding content. Then immediately retry after the
24652478
// initial commit.

‎packages/react-reconciler/src/__tests__/ReactCPUSuspense-test.js‎

Lines changed: 53 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
/* eslint-disable react/jsx-boolean-value */
2+
13
letReact;
24
letReactNoop;
35
letScheduler;
@@ -11,6 +13,7 @@ let resolveText;
1113
// let rejectText;
1214

1315
letassertLog;
16+
letassertConsoleErrorDev;
1417
letwaitForPaint;
1518

1619
describe('ReactSuspenseWithNoopRenderer',()=>{
@@ -26,6 +29,7 @@ describe('ReactSuspenseWithNoopRenderer', () => {
2629

2730
constInternalTestUtils=require('internal-test-utils');
2831
assertLog=InternalTestUtils.assertLog;
32+
assertConsoleErrorDev=InternalTestUtils.assertConsoleErrorDev;
2933
waitForPaint=InternalTestUtils.waitForPaint;
3034

3135
textCache=newMap();
@@ -116,14 +120,14 @@ describe('ReactSuspenseWithNoopRenderer', () => {
116120
}
117121

118122
// @gate enableCPUSuspense
119-
it('skips CPU-bound trees on initial mount',async()=>{
123+
it('warns for the old name is used',async()=>{
120124
functionApp(){
121125
return(
122126
<>
123127
<Texttext="Outer"/>
124128
<div>
125129
<Suspense
126-
unstable_expectedLoadTime={2000}
130+
unstable_expectedLoadTime={1000}
127131
fallback={<Texttext="Loading..."/>}>
128132
<Texttext="Inner"/>
129133
</Suspense>
@@ -132,6 +136,49 @@ describe('ReactSuspenseWithNoopRenderer', () => {
132136
);
133137
}
134138

139+
constroot=ReactNoop.createRoot();
140+
awaitact(async()=>{
141+
root.render(<App/>);
142+
awaitwaitForPaint(['Outer','Loading...']);
143+
assertConsoleErrorDev([
144+
'<Suspense unstable_expectedLoadTime={...}> is deprecated. '+
145+
'Use <Suspense defer={true}> instead.'+
146+
'\n in Suspense (at **)'+
147+
'\n in App (at **)',
148+
]);
149+
expect(root).toMatchRenderedOutput(
150+
<>
151+
Outer
152+
<div>Loading...</div>
153+
</>,
154+
);
155+
});
156+
157+
// Inner contents finish in separate commit from outer
158+
assertLog(['Inner']);
159+
expect(root).toMatchRenderedOutput(
160+
<>
161+
Outer
162+
<div>Inner</div>
163+
</>,
164+
);
165+
});
166+
167+
// @gate enableCPUSuspense
168+
it('skips CPU-bound trees on initial mount',async()=>{
169+
functionApp(){
170+
return(
171+
<>
172+
<Texttext="Outer"/>
173+
<div>
174+
<Suspensedeferfallback={<Texttext="Loading..."/>}>
175+
<Texttext="Inner"/>
176+
</Suspense>
177+
</div>
178+
</>
179+
);
180+
}
181+
135182
constroot=ReactNoop.createRoot();
136183
awaitact(async()=>{
137184
root.render(<App/>);
@@ -164,9 +211,7 @@ describe('ReactSuspenseWithNoopRenderer', () => {
164211
<>
165212
<Texttext="Outer"/>
166213
<div>
167-
<Suspense
168-
unstable_expectedLoadTime={2000}
169-
fallback={<Texttext="Loading..."/>}>
214+
<Suspensedeferfallback={<Texttext="Loading..."/>}>
170215
<Texttext={`Inner [${count}]`}/>
171216
</Suspense>
172217
</div>
@@ -209,9 +254,7 @@ describe('ReactSuspenseWithNoopRenderer', () => {
209254
<>
210255
<Texttext="Outer"/>
211256
<div>
212-
<Suspense
213-
unstable_expectedLoadTime={2000}
214-
fallback={<Texttext="Loading..."/>}>
257+
<Suspensedeferfallback={<Texttext="Loading..."/>}>
215258
<AsyncTexttext="Inner"/>
216259
</Suspense>
217260
</div>
@@ -263,14 +306,10 @@ describe('ReactSuspenseWithNoopRenderer', () => {
263306
<>
264307
<Texttext="A"/>
265308
<div>
266-
<Suspense
267-
unstable_expectedLoadTime={2000}
268-
fallback={<Texttext="Loading B..."/>}>
309+
<Suspensedeferfallback={<Texttext="Loading B..."/>}>
269310
<Texttext="B"/>
270311
<div>
271-
<Suspense
272-
unstable_expectedLoadTime={2000}
273-
fallback={<Texttext="Loading C..."/>}>
312+
<Suspensedeferfallback={<Texttext="Loading C..."/>}>
274313
<Texttext="C"/>
275314
</Suspense>
276315
</div>

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

Lines changed: 34 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,7 @@ import {
181181
enableViewTransition,
182182
enableFizzBlockingRender,
183183
enableAsyncDebugInfo,
184+
enableCPUSuspense,
184185
}from'shared/ReactFeatureFlags';
185186

186187
importassignfrom'shared/assign';
@@ -250,6 +251,7 @@ type SuspenseBoundary = {
250251
row: null|SuspenseListRow,// the row that this boundary blocks from completing.
251252
completedSegments: Array<Segment>,// completed but not yet flushed segments.
252253
byteSize: number,// used to determine whether to inline children boundaries.
254+
defer: boolean,// never inline deferred boundaries
253255
fallbackAbortableTasks: Set<Task>,// used to cancel task on the fallback if the boundary completes or gets canceled.
254256
contentState: HoistableState,
255257
fallbackState: HoistableState,
@@ -456,7 +458,9 @@ function isEligibleForOutlining(
456458
// The larger this limit is, the more we can save on preparing fallbacks in case we end up
457459
// outlining.
458460
return(
459-
(boundary.byteSize>500||hasSuspenseyContent(boundary.contentState))&&
461+
(boundary.byteSize>500||
462+
hasSuspenseyContent(boundary.contentState)||
463+
boundary.defer)&&
460464
// For boundaries that can possibly contribute to the preamble we don't want to outline
461465
// them regardless of their size since the fallbacks should only be emitted if we've
462466
// errored the boundary.
@@ -782,6 +786,7 @@ function createSuspenseBoundary(
782786
fallbackAbortableTasks: Set<Task>,
783787
contentPreamble: null | Preamble,
784788
fallbackPreamble: null | Preamble,
789+
defer: boolean,
785790
): SuspenseBoundary {
786791
constboundary: SuspenseBoundary={
787792
status: PENDING,
@@ -791,6 +796,7 @@ function createSuspenseBoundary(
791796
row: row,
792797
completedSegments: [],
793798
byteSize: 0,
799+
defer: defer,
794800
fallbackAbortableTasks,
795801
errorDigest: null,
796802
contentState: createHoistableState(),
@@ -1274,6 +1280,7 @@ function renderSuspenseBoundary(
12741280
// in case it ends up generating a large subtree of content.
12751281
constfallback: ReactNodeList=props.fallback;
12761282
constcontent: ReactNodeList=props.children;
1283+
constdefer: boolean=enableCPUSuspense&&props.defer===true;
12771284

12781285
constfallbackAbortSet: Set<Task>=newSet();
12791286
letnewBoundary: SuspenseBoundary;
@@ -1284,6 +1291,7 @@ function renderSuspenseBoundary(
12841291
fallbackAbortSet,
12851292
createPreambleState(),
12861293
createPreambleState(),
1294+
defer,
12871295
);
12881296
}else{
12891297
newBoundary=createSuspenseBoundary(
@@ -1292,6 +1300,7 @@ function renderSuspenseBoundary(
12921300
fallbackAbortSet,
12931301
null,
12941302
null,
1303+
defer,
12951304
);
12961305
}
12971306
if(request.trackedPostpones!==null){
@@ -1327,29 +1336,32 @@ function renderSuspenseBoundary(
13271336
// no parent segment so there's nothing to wait on.
13281337
contentRootSegment.parentFlushed=true;
13291338

1330-
if(request.trackedPostpones!==null){
1339+
consttrackedPostpones=request.trackedPostpones;
1340+
if(trackedPostpones!==null||defer){
1341+
// This is a prerender or deferred boundary. In this mode we want to render the fallback synchronously
1342+
// and schedule the content to render later. This is the opposite of what we do during a normal render
1343+
// where we try to skip rendering the fallback if the content itself can render synchronously
1344+
13311345
// Stash the original stack frame.
13321346
constsuspenseComponentStack=task.componentStack;
1333-
// This is a prerender. In this mode we want to render the fallback synchronously and schedule
1334-
// the content to render later. This is the opposite of what we do during a normal render
1335-
// where we try to skip rendering the fallback if the content itself can render synchronously
1336-
consttrackedPostpones=request.trackedPostpones;
13371347

13381348
constfallbackKeyPath: KeyNode=[
13391349
keyPath[0],
13401350
'Suspense Fallback',
13411351
keyPath[2],
13421352
];
1343-
constfallbackReplayNode: ReplayNode=[
1344-
fallbackKeyPath[1],
1345-
fallbackKeyPath[2],
1346-
([]: Array<ReplayNode>),
1347-
null,
1348-
];
1349-
trackedPostpones.workingMap.set(fallbackKeyPath,fallbackReplayNode);
1350-
// We are rendering the fallback before the boundary content so we keep track of
1351-
// the fallback replay node until we determine if the primary content suspends
1352-
newBoundary.trackedFallbackNode=fallbackReplayNode;
1353+
if(trackedPostpones!==null){
1354+
constfallbackReplayNode: ReplayNode=[
1355+
fallbackKeyPath[1],
1356+
fallbackKeyPath[2],
1357+
([]: Array<ReplayNode>),
1358+
null,
1359+
];
1360+
trackedPostpones.workingMap.set(fallbackKeyPath,fallbackReplayNode);
1361+
// We are rendering the fallback before the boundary content so we keep track of
1362+
// the fallback replay node until we determine if the primary content suspends
1363+
newBoundary.trackedFallbackNode=fallbackReplayNode;
1364+
}
13531365

13541366
task.blockedSegment=boundarySegment;
13551367
task.blockedPreamble=newBoundary.fallbackPreamble;
@@ -1580,6 +1592,7 @@ function replaySuspenseBoundary(
15801592

15811593
constcontent: ReactNodeList=props.children;
15821594
constfallback: ReactNodeList=props.fallback;
1595+
constdefer: boolean=enableCPUSuspense&&props.defer===true;
15831596

15841597
constfallbackAbortSet: Set<Task>=newSet();
15851598
letresumedBoundary: SuspenseBoundary;
@@ -1590,6 +1603,7 @@ function replaySuspenseBoundary(
15901603
fallbackAbortSet,
15911604
createPreambleState(),
15921605
createPreambleState(),
1606+
defer,
15931607
);
15941608
}else{
15951609
resumedBoundary =createSuspenseBoundary(
@@ -1598,6 +1612,7 @@ function replaySuspenseBoundary(
15981612
fallbackAbortSet,
15991613
null,
16001614
null,
1615+
defer,
16011616
);
16021617
}
16031618
resumedBoundary.parentFlushed=true;
@@ -4384,6 +4399,7 @@ function abortRemainingSuspenseBoundary(
43844399
newSet(),
43854400
null,
43864401
null,
4402+
false,
43874403
);
43884404
resumedBoundary.parentFlushed=true;
43894405
// We restore the same id of this boundary as was used during prerender.
@@ -5493,7 +5509,8 @@ function flushSegment(
54935509
!flushingPartialBoundaries&&
54945510
isEligibleForOutlining(request,boundary)&&
54955511
(flushedByteSize+boundary.byteSize>request.progressiveChunkSize||
5496-
hasSuspenseyContent(boundary.contentState))
5512+
hasSuspenseyContent(boundary.contentState)||
5513+
boundary.defer)
54975514
){
54985515
// Inlining this boundary would make the current sequence being written too large
54995516
// and block the parent for too long. Instead, it will be emitted separately so that we

‎packages/shared/ReactTypes.js‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -313,6 +313,7 @@ export type SuspenseProps = {
313313

314314
unstable_avoidThisFallback?: boolean,
315315
unstable_expectedLoadTime?: number,
316+
defer?: boolean,
316317
name?: string,
317318
};
318319

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 0ba2f01

Browse files
authored
Rename <Suspense unstable_expectedLoadTime> to <Suspense defer> and implement in SSR (#35022)
We've long had the CPU suspense feature behind a flag under the terrible API `unstable_expectedLoadTime={arbitraryNumber}`. We've known for a long time we want it to just be `defer={true}` (or just `<Suspense defer>` in the short hand syntax). So this adds the new name and warns for the old name. For only the new name, I also implemented SSR semantics in Fizz. It has two effects here. 1) It renders the fallback before the content (similar to prerender) allowing siblings to complete quicker. 2) It always outlines the result. When streaming this should really happen naturally but if you defer a prerendered content it also implies that it's expensive and should be outlined. It gives you a opt-in to outlining similar to suspensey images and css but let you control it manually.
1 parent dd048c3 commit 0ba2f01

5 files changed

Lines changed: 132 additions & 32 deletions

File tree

‎packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js‎

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9433,4 +9433,34 @@ Unfortunately that previous paragraph wasn't quite long enough so I'll continue
94339433
</html>,
94349434
);
94359435
});
9436+
9437+
// @gate enableCPUSuspense
9438+
it('outlines deferred Suspense boundaries',async()=>{
9439+
functionLog({text}){
9440+
Scheduler.log(text);
9441+
returntext;
9442+
}
9443+
9444+
awaitact(async()=>{
9445+
renderToPipeableStream(
9446+
<div>
9447+
<Suspensedefer={true}fallback={<Logtext="Waiting"/>}>
9448+
<span>{<Logtext="hello"/>}</span>
9449+
</Suspense>
9450+
</div>,
9451+
).pipe(writable);
9452+
awaitjest.runAllTimers();
9453+
consttemp=document.createElement('body');
9454+
temp.innerHTML=buffer;
9455+
expect(getVisibleChildren(temp)).toEqual(<div>Waiting</div>);
9456+
});
9457+
9458+
assertLog(['Waiting','hello']);
9459+
9460+
expect(getVisibleChildren(container)).toEqual(
9461+
<div>
9462+
<span>hello</span>
9463+
</div>,
9464+
);
9465+
});
94369466
});

‎packages/react-reconciler/src/ReactFiberBeginWork.js‎

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -325,6 +325,7 @@ export let didWarnAboutReassigningProps: boolean;
325325
letdidWarnAboutRevealOrder;
326326
letdidWarnAboutTailOptions;
327327
letdidWarnAboutClassNameOnViewTransition;
328+
letdidWarnAboutExpectedLoadTime=false;
328329

329330
if(__DEV__){
330331
didWarnAboutBadClass=({}: {[string]: boolean});
@@ -2458,8 +2459,20 @@ function updateSuspenseComponent(
24582459
returnbailoutOffscreenComponent(null,primaryChildFragment);
24592460
}elseif(
24602461
enableCPUSuspense&&
2461-
typeofnextProps.unstable_expectedLoadTime==='number'
2462+
(typeofnextProps.unstable_expectedLoadTime==='number'||
2463+
nextProps.defer===true)
24622464
){
2465+
if(__DEV__){
2466+
if(typeofnextProps.unstable_expectedLoadTime==='number'){
2467+
if(!didWarnAboutExpectedLoadTime){
2468+
didWarnAboutExpectedLoadTime=true;
2469+
console.error(
2470+
'<Suspense unstable_expectedLoadTime={...}> is deprecated. '+
2471+
'Use <Suspense defer={true}> instead.',
2472+
);
2473+
}
2474+
}
2475+
}
24632476
// This is a CPU-bound tree. Skip this tree and show a placeholder to
24642477
// unblock the surrounding content. Then immediately retry after the
24652478
// initial commit.

‎packages/react-reconciler/src/__tests__/ReactCPUSuspense-test.js‎

Lines changed: 53 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
/* eslint-disable react/jsx-boolean-value */
2+
13
letReact;
24
letReactNoop;
35
letScheduler;
@@ -11,6 +13,7 @@ let resolveText;
1113
// let rejectText;
1214

1315
letassertLog;
16+
letassertConsoleErrorDev;
1417
letwaitForPaint;
1518

1619
describe('ReactSuspenseWithNoopRenderer',()=>{
@@ -26,6 +29,7 @@ describe('ReactSuspenseWithNoopRenderer', () => {
2629

2730
constInternalTestUtils=require('internal-test-utils');
2831
assertLog=InternalTestUtils.assertLog;
32+
assertConsoleErrorDev=InternalTestUtils.assertConsoleErrorDev;
2933
waitForPaint=InternalTestUtils.waitForPaint;
3034

3135
textCache=newMap();
@@ -116,14 +120,14 @@ describe('ReactSuspenseWithNoopRenderer', () => {
116120
}
117121

118122
// @gate enableCPUSuspense
119-
it('skips CPU-bound trees on initial mount',async()=>{
123+
it('warns for the old name is used',async()=>{
120124
functionApp(){
121125
return(
122126
<>
123127
<Texttext="Outer"/>
124128
<div>
125129
<Suspense
126-
unstable_expectedLoadTime={2000}
130+
unstable_expectedLoadTime={1000}
127131
fallback={<Texttext="Loading..."/>}>
128132
<Texttext="Inner"/>
129133
</Suspense>
@@ -132,6 +136,49 @@ describe('ReactSuspenseWithNoopRenderer', () => {
132136
);
133137
}
134138

139+
constroot=ReactNoop.createRoot();
140+
awaitact(async()=>{
141+
root.render(<App/>);
142+
awaitwaitForPaint(['Outer','Loading...']);
143+
assertConsoleErrorDev([
144+
'<Suspense unstable_expectedLoadTime={...}> is deprecated. '+
145+
'Use <Suspense defer={true}> instead.'+
146+
'\n in Suspense (at **)'+
147+
'\n in App (at **)',
148+
]);
149+
expect(root).toMatchRenderedOutput(
150+
<>
151+
Outer
152+
<div>Loading...</div>
153+
</>,
154+
);
155+
});
156+
157+
// Inner contents finish in separate commit from outer
158+
assertLog(['Inner']);
159+
expect(root).toMatchRenderedOutput(
160+
<>
161+
Outer
162+
<div>Inner</div>
163+
</>,
164+
);
165+
});
166+
167+
// @gate enableCPUSuspense
168+
it('skips CPU-bound trees on initial mount',async()=>{
169+
functionApp(){
170+
return(
171+
<>
172+
<Texttext="Outer"/>
173+
<div>
174+
<Suspensedeferfallback={<Texttext="Loading..."/>}>
175+
<Texttext="Inner"/>
176+
</Suspense>
177+
</div>
178+
</>
179+
);
180+
}
181+
135182
constroot=ReactNoop.createRoot();
136183
awaitact(async()=>{
137184
root.render(<App/>);
@@ -164,9 +211,7 @@ describe('ReactSuspenseWithNoopRenderer', () => {
164211
<>
165212
<Texttext="Outer"/>
166213
<div>
167-
<Suspense
168-
unstable_expectedLoadTime={2000}
169-
fallback={<Texttext="Loading..."/>}>
214+
<Suspensedeferfallback={<Texttext="Loading..."/>}>
170215
<Texttext={`Inner [${count}]`}/>
171216
</Suspense>
172217
</div>
@@ -209,9 +254,7 @@ describe('ReactSuspenseWithNoopRenderer', () => {
209254
<>
210255
<Texttext="Outer"/>
211256
<div>
212-
<Suspense
213-
unstable_expectedLoadTime={2000}
214-
fallback={<Texttext="Loading..."/>}>
257+
<Suspensedeferfallback={<Texttext="Loading..."/>}>
215258
<AsyncTexttext="Inner"/>
216259
</Suspense>
217260
</div>
@@ -263,14 +306,10 @@ describe('ReactSuspenseWithNoopRenderer', () => {
263306
<>
264307
<Texttext="A"/>
265308
<div>
266-
<Suspense
267-
unstable_expectedLoadTime={2000}
268-
fallback={<Texttext="Loading B..."/>}>
309+
<Suspensedeferfallback={<Texttext="Loading B..."/>}>
269310
<Texttext="B"/>
270311
<div>
271-
<Suspense
272-
unstable_expectedLoadTime={2000}
273-
fallback={<Texttext="Loading C..."/>}>
312+
<Suspensedeferfallback={<Texttext="Loading C..."/>}>
274313
<Texttext="C"/>
275314
</Suspense>
276315
</div>

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

Lines changed: 34 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,7 @@ import {
181181
enableViewTransition,
182182
enableFizzBlockingRender,
183183
enableAsyncDebugInfo,
184+
enableCPUSuspense,
184185
}from'shared/ReactFeatureFlags';
185186

186187
importassignfrom'shared/assign';
@@ -250,6 +251,7 @@ type SuspenseBoundary = {
250251
row: null|SuspenseListRow,// the row that this boundary blocks from completing.
251252
completedSegments: Array<Segment>,// completed but not yet flushed segments.
252253
byteSize: number,// used to determine whether to inline children boundaries.
254+
defer: boolean,// never inline deferred boundaries
253255
fallbackAbortableTasks: Set<Task>,// used to cancel task on the fallback if the boundary completes or gets canceled.
254256
contentState: HoistableState,
255257
fallbackState: HoistableState,
@@ -456,7 +458,9 @@ function isEligibleForOutlining(
456458
// The larger this limit is, the more we can save on preparing fallbacks in case we end up
457459
// outlining.
458460
return(
459-
(boundary.byteSize>500||hasSuspenseyContent(boundary.contentState))&&
461+
(boundary.byteSize>500||
462+
hasSuspenseyContent(boundary.contentState)||
463+
boundary.defer)&&
460464
// For boundaries that can possibly contribute to the preamble we don't want to outline
461465
// them regardless of their size since the fallbacks should only be emitted if we've
462466
// errored the boundary.
@@ -782,6 +786,7 @@ function createSuspenseBoundary(
782786
fallbackAbortableTasks: Set<Task>,
783787
contentPreamble: null | Preamble,
784788
fallbackPreamble: null | Preamble,
789+
defer: boolean,
785790
): SuspenseBoundary {
786791
constboundary: SuspenseBoundary={
787792
status: PENDING,
@@ -791,6 +796,7 @@ function createSuspenseBoundary(
791796
row: row,
792797
completedSegments: [],
793798
byteSize: 0,
799+
defer: defer,
794800
fallbackAbortableTasks,
795801
errorDigest: null,
796802
contentState: createHoistableState(),
@@ -1274,6 +1280,7 @@ function renderSuspenseBoundary(
12741280
// in case it ends up generating a large subtree of content.
12751281
constfallback: ReactNodeList=props.fallback;
12761282
constcontent: ReactNodeList=props.children;
1283+
constdefer: boolean=enableCPUSuspense&&props.defer===true;
12771284

12781285
constfallbackAbortSet: Set<Task>=newSet();
12791286
letnewBoundary: SuspenseBoundary;
@@ -1284,6 +1291,7 @@ function renderSuspenseBoundary(
12841291
fallbackAbortSet,
12851292
createPreambleState(),
12861293
createPreambleState(),
1294+
defer,
12871295
);
12881296
}else{
12891297
newBoundary=createSuspenseBoundary(
@@ -1292,6 +1300,7 @@ function renderSuspenseBoundary(
12921300
fallbackAbortSet,
12931301
null,
12941302
null,
1303+
defer,
12951304
);
12961305
}
12971306
if(request.trackedPostpones!==null){
@@ -1327,29 +1336,32 @@ function renderSuspenseBoundary(
13271336
// no parent segment so there's nothing to wait on.
13281337
contentRootSegment.parentFlushed=true;
13291338

1330-
if(request.trackedPostpones!==null){
1339+
consttrackedPostpones=request.trackedPostpones;
1340+
if(trackedPostpones!==null||defer){
1341+
// This is a prerender or deferred boundary. In this mode we want to render the fallback synchronously
1342+
// and schedule the content to render later. This is the opposite of what we do during a normal render
1343+
// where we try to skip rendering the fallback if the content itself can render synchronously
1344+
13311345
// Stash the original stack frame.
13321346
constsuspenseComponentStack=task.componentStack;
1333-
// This is a prerender. In this mode we want to render the fallback synchronously and schedule
1334-
// the content to render later. This is the opposite of what we do during a normal render
1335-
// where we try to skip rendering the fallback if the content itself can render synchronously
1336-
consttrackedPostpones=request.trackedPostpones;
13371347

13381348
constfallbackKeyPath: KeyNode=[
13391349
keyPath[0],
13401350
'Suspense Fallback',
13411351
keyPath[2],
13421352
];
1343-
constfallbackReplayNode: ReplayNode=[
1344-
fallbackKeyPath[1],
1345-
fallbackKeyPath[2],
1346-
([]: Array<ReplayNode>),
1347-
null,
1348-
];
1349-
trackedPostpones.workingMap.set(fallbackKeyPath,fallbackReplayNode);
1350-
// We are rendering the fallback before the boundary content so we keep track of
1351-
// the fallback replay node until we determine if the primary content suspends
1352-
newBoundary.trackedFallbackNode=fallbackReplayNode;
1353+
if(trackedPostpones!==null){
1354+
constfallbackReplayNode: ReplayNode=[
1355+
fallbackKeyPath[1],
1356+
fallbackKeyPath[2],
1357+
([]: Array<ReplayNode>),
1358+
null,
1359+
];
1360+
trackedPostpones.workingMap.set(fallbackKeyPath,fallbackReplayNode);
1361+
// We are rendering the fallback before the boundary content so we keep track of
1362+
// the fallback replay node until we determine if the primary content suspends
1363+
newBoundary.trackedFallbackNode=fallbackReplayNode;
1364+
}
13531365

13541366
task.blockedSegment=boundarySegment;
13551367
task.blockedPreamble=newBoundary.fallbackPreamble;
@@ -1580,6 +1592,7 @@ function replaySuspenseBoundary(
15801592

15811593
constcontent: ReactNodeList=props.children;
15821594
constfallback: ReactNodeList=props.fallback;
1595+
constdefer: boolean=enableCPUSuspense&&props.defer===true;
15831596

15841597
constfallbackAbortSet: Set<Task>=newSet();
15851598
letresumedBoundary: SuspenseBoundary;
@@ -1590,6 +1603,7 @@ function replaySuspenseBoundary(
15901603
fallbackAbortSet,
15911604
createPreambleState(),
15921605
createPreambleState(),
1606+
defer,
15931607
);
15941608
}else{
15951609
resumedBoundary =createSuspenseBoundary(
@@ -1598,6 +1612,7 @@ function replaySuspenseBoundary(
15981612
fallbackAbortSet,
15991613
null,
16001614
null,
1615+
defer,
16011616
);
16021617
}
16031618
resumedBoundary.parentFlushed=true;
@@ -4384,6 +4399,7 @@ function abortRemainingSuspenseBoundary(
43844399
newSet(),
43854400
null,
43864401
null,
4402+
false,
43874403
);
43884404
resumedBoundary.parentFlushed=true;
43894405
// We restore the same id of this boundary as was used during prerender.
@@ -5493,7 +5509,8 @@ function flushSegment(
54935509
!flushingPartialBoundaries&&
54945510
isEligibleForOutlining(request,boundary)&&
54955511
(flushedByteSize+boundary.byteSize>request.progressiveChunkSize||
5496-
hasSuspenseyContent(boundary.contentState))
5512+
hasSuspenseyContent(boundary.contentState)||
5513+
boundary.defer)
54975514
){
54985515
// Inlining this boundary would make the current sequence being written too large
54995516
// and block the parent for too long. Instead, it will be emitted separately so that we

‎packages/shared/ReactTypes.js‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -313,6 +313,7 @@ export type SuspenseProps = {
313313

314314
unstable_avoidThisFallback?: boolean,
315315
unstable_expectedLoadTime?: number,
316+
defer?: boolean,
316317
name?: string,
317318
};
318319

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 0ba2f01

Browse files
authored
Rename <Suspense unstable_expectedLoadTime> to <Suspense defer> and implement in SSR (#35022)
We've long had the CPU suspense feature behind a flag under the terrible API `unstable_expectedLoadTime={arbitraryNumber}`. We've known for a long time we want it to just be `defer={true}` (or just `<Suspense defer>` in the short hand syntax). So this adds the new name and warns for the old name. For only the new name, I also implemented SSR semantics in Fizz. It has two effects here. 1) It renders the fallback before the content (similar to prerender) allowing siblings to complete quicker. 2) It always outlines the result. When streaming this should really happen naturally but if you defer a prerendered content it also implies that it's expensive and should be outlined. It gives you a opt-in to outlining similar to suspensey images and css but let you control it manually.
1 parent dd048c3 commit 0ba2f01

5 files changed

Lines changed: 132 additions & 32 deletions

File tree

‎packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js‎

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9433,4 +9433,34 @@ Unfortunately that previous paragraph wasn't quite long enough so I'll continue
94339433
</html>,
94349434
);
94359435
});
9436+
9437+
// @gate enableCPUSuspense
9438+
it('outlines deferred Suspense boundaries',async()=>{
9439+
functionLog({text}){
9440+
Scheduler.log(text);
9441+
returntext;
9442+
}
9443+
9444+
awaitact(async()=>{
9445+
renderToPipeableStream(
9446+
<div>
9447+
<Suspensedefer={true}fallback={<Logtext="Waiting"/>}>
9448+
<span>{<Logtext="hello"/>}</span>
9449+
</Suspense>
9450+
</div>,
9451+
).pipe(writable);
9452+
awaitjest.runAllTimers();
9453+
consttemp=document.createElement('body');
9454+
temp.innerHTML=buffer;
9455+
expect(getVisibleChildren(temp)).toEqual(<div>Waiting</div>);
9456+
});
9457+
9458+
assertLog(['Waiting','hello']);
9459+
9460+
expect(getVisibleChildren(container)).toEqual(
9461+
<div>
9462+
<span>hello</span>
9463+
</div>,
9464+
);
9465+
});
94369466
});

‎packages/react-reconciler/src/ReactFiberBeginWork.js‎

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -325,6 +325,7 @@ export let didWarnAboutReassigningProps: boolean;
325325
letdidWarnAboutRevealOrder;
326326
letdidWarnAboutTailOptions;
327327
letdidWarnAboutClassNameOnViewTransition;
328+
letdidWarnAboutExpectedLoadTime=false;
328329

329330
if(__DEV__){
330331
didWarnAboutBadClass=({}: {[string]: boolean});
@@ -2458,8 +2459,20 @@ function updateSuspenseComponent(
24582459
returnbailoutOffscreenComponent(null,primaryChildFragment);
24592460
}elseif(
24602461
enableCPUSuspense&&
2461-
typeofnextProps.unstable_expectedLoadTime==='number'
2462+
(typeofnextProps.unstable_expectedLoadTime==='number'||
2463+
nextProps.defer===true)
24622464
){
2465+
if(__DEV__){
2466+
if(typeofnextProps.unstable_expectedLoadTime==='number'){
2467+
if(!didWarnAboutExpectedLoadTime){
2468+
didWarnAboutExpectedLoadTime=true;
2469+
console.error(
2470+
'<Suspense unstable_expectedLoadTime={...}> is deprecated. '+
2471+
'Use <Suspense defer={true}> instead.',
2472+
);
2473+
}
2474+
}
2475+
}
24632476
// This is a CPU-bound tree. Skip this tree and show a placeholder to
24642477
// unblock the surrounding content. Then immediately retry after the
24652478
// initial commit.

‎packages/react-reconciler/src/__tests__/ReactCPUSuspense-test.js‎

Lines changed: 53 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
/* eslint-disable react/jsx-boolean-value */
2+
13
letReact;
24
letReactNoop;
35
letScheduler;
@@ -11,6 +13,7 @@ let resolveText;
1113
// let rejectText;
1214

1315
letassertLog;
16+
letassertConsoleErrorDev;
1417
letwaitForPaint;
1518

1619
describe('ReactSuspenseWithNoopRenderer',()=>{
@@ -26,6 +29,7 @@ describe('ReactSuspenseWithNoopRenderer', () => {
2629

2730
constInternalTestUtils=require('internal-test-utils');
2831
assertLog=InternalTestUtils.assertLog;
32+
assertConsoleErrorDev=InternalTestUtils.assertConsoleErrorDev;
2933
waitForPaint=InternalTestUtils.waitForPaint;
3034

3135
textCache=newMap();
@@ -116,14 +120,14 @@ describe('ReactSuspenseWithNoopRenderer', () => {
116120
}
117121

118122
// @gate enableCPUSuspense
119-
it('skips CPU-bound trees on initial mount',async()=>{
123+
it('warns for the old name is used',async()=>{
120124
functionApp(){
121125
return(
122126
<>
123127
<Texttext="Outer"/>
124128
<div>
125129
<Suspense
126-
unstable_expectedLoadTime={2000}
130+
unstable_expectedLoadTime={1000}
127131
fallback={<Texttext="Loading..."/>}>
128132
<Texttext="Inner"/>
129133
</Suspense>
@@ -132,6 +136,49 @@ describe('ReactSuspenseWithNoopRenderer', () => {
132136
);
133137
}
134138

139+
constroot=ReactNoop.createRoot();
140+
awaitact(async()=>{
141+
root.render(<App/>);
142+
awaitwaitForPaint(['Outer','Loading...']);
143+
assertConsoleErrorDev([
144+
'<Suspense unstable_expectedLoadTime={...}> is deprecated. '+
145+
'Use <Suspense defer={true}> instead.'+
146+
'\n in Suspense (at **)'+
147+
'\n in App (at **)',
148+
]);
149+
expect(root).toMatchRenderedOutput(
150+
<>
151+
Outer
152+
<div>Loading...</div>
153+
</>,
154+
);
155+
});
156+
157+
// Inner contents finish in separate commit from outer
158+
assertLog(['Inner']);
159+
expect(root).toMatchRenderedOutput(
160+
<>
161+
Outer
162+
<div>Inner</div>
163+
</>,
164+
);
165+
});
166+
167+
// @gate enableCPUSuspense
168+
it('skips CPU-bound trees on initial mount',async()=>{
169+
functionApp(){
170+
return(
171+
<>
172+
<Texttext="Outer"/>
173+
<div>
174+
<Suspensedeferfallback={<Texttext="Loading..."/>}>
175+
<Texttext="Inner"/>
176+
</Suspense>
177+
</div>
178+
</>
179+
);
180+
}
181+
135182
constroot=ReactNoop.createRoot();
136183
awaitact(async()=>{
137184
root.render(<App/>);
@@ -164,9 +211,7 @@ describe('ReactSuspenseWithNoopRenderer', () => {
164211
<>
165212
<Texttext="Outer"/>
166213
<div>
167-
<Suspense
168-
unstable_expectedLoadTime={2000}
169-
fallback={<Texttext="Loading..."/>}>
214+
<Suspensedeferfallback={<Texttext="Loading..."/>}>
170215
<Texttext={`Inner [${count}]`}/>
171216
</Suspense>
172217
</div>
@@ -209,9 +254,7 @@ describe('ReactSuspenseWithNoopRenderer', () => {
209254
<>
210255
<Texttext="Outer"/>
211256
<div>
212-
<Suspense
213-
unstable_expectedLoadTime={2000}
214-
fallback={<Texttext="Loading..."/>}>
257+
<Suspensedeferfallback={<Texttext="Loading..."/>}>
215258
<AsyncTexttext="Inner"/>
216259
</Suspense>
217260
</div>
@@ -263,14 +306,10 @@ describe('ReactSuspenseWithNoopRenderer', () => {
263306
<>
264307
<Texttext="A"/>
265308
<div>
266-
<Suspense
267-
unstable_expectedLoadTime={2000}
268-
fallback={<Texttext="Loading B..."/>}>
309+
<Suspensedeferfallback={<Texttext="Loading B..."/>}>
269310
<Texttext="B"/>
270311
<div>
271-
<Suspense
272-
unstable_expectedLoadTime={2000}
273-
fallback={<Texttext="Loading C..."/>}>
312+
<Suspensedeferfallback={<Texttext="Loading C..."/>}>
274313
<Texttext="C"/>
275314
</Suspense>
276315
</div>

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

Lines changed: 34 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,7 @@ import {
181181
enableViewTransition,
182182
enableFizzBlockingRender,
183183
enableAsyncDebugInfo,
184+
enableCPUSuspense,
184185
}from'shared/ReactFeatureFlags';
185186

186187
importassignfrom'shared/assign';
@@ -250,6 +251,7 @@ type SuspenseBoundary = {
250251
row: null|SuspenseListRow,// the row that this boundary blocks from completing.
251252
completedSegments: Array<Segment>,// completed but not yet flushed segments.
252253
byteSize: number,// used to determine whether to inline children boundaries.
254+
defer: boolean,// never inline deferred boundaries
253255
fallbackAbortableTasks: Set<Task>,// used to cancel task on the fallback if the boundary completes or gets canceled.
254256
contentState: HoistableState,
255257
fallbackState: HoistableState,
@@ -456,7 +458,9 @@ function isEligibleForOutlining(
456458
// The larger this limit is, the more we can save on preparing fallbacks in case we end up
457459
// outlining.
458460
return(
459-
(boundary.byteSize>500||hasSuspenseyContent(boundary.contentState))&&
461+
(boundary.byteSize>500||
462+
hasSuspenseyContent(boundary.contentState)||
463+
boundary.defer)&&
460464
// For boundaries that can possibly contribute to the preamble we don't want to outline
461465
// them regardless of their size since the fallbacks should only be emitted if we've
462466
// errored the boundary.
@@ -782,6 +786,7 @@ function createSuspenseBoundary(
782786
fallbackAbortableTasks: Set<Task>,
783787
contentPreamble: null | Preamble,
784788
fallbackPreamble: null | Preamble,
789+
defer: boolean,
785790
): SuspenseBoundary {
786791
constboundary: SuspenseBoundary={
787792
status: PENDING,
@@ -791,6 +796,7 @@ function createSuspenseBoundary(
791796
row: row,
792797
completedSegments: [],
793798
byteSize: 0,
799+
defer: defer,
794800
fallbackAbortableTasks,
795801
errorDigest: null,
796802
contentState: createHoistableState(),
@@ -1274,6 +1280,7 @@ function renderSuspenseBoundary(
12741280
// in case it ends up generating a large subtree of content.
12751281
constfallback: ReactNodeList=props.fallback;
12761282
constcontent: ReactNodeList=props.children;
1283+
constdefer: boolean=enableCPUSuspense&&props.defer===true;
12771284

12781285
constfallbackAbortSet: Set<Task>=newSet();
12791286
letnewBoundary: SuspenseBoundary;
@@ -1284,6 +1291,7 @@ function renderSuspenseBoundary(
12841291
fallbackAbortSet,
12851292
createPreambleState(),
12861293
createPreambleState(),
1294+
defer,
12871295
);
12881296
}else{
12891297
newBoundary=createSuspenseBoundary(
@@ -1292,6 +1300,7 @@ function renderSuspenseBoundary(
12921300
fallbackAbortSet,
12931301
null,
12941302
null,
1303+
defer,
12951304
);
12961305
}
12971306
if(request.trackedPostpones!==null){
@@ -1327,29 +1336,32 @@ function renderSuspenseBoundary(
13271336
// no parent segment so there's nothing to wait on.
13281337
contentRootSegment.parentFlushed=true;
13291338

1330-
if(request.trackedPostpones!==null){
1339+
consttrackedPostpones=request.trackedPostpones;
1340+
if(trackedPostpones!==null||defer){
1341+
// This is a prerender or deferred boundary. In this mode we want to render the fallback synchronously
1342+
// and schedule the content to render later. This is the opposite of what we do during a normal render
1343+
// where we try to skip rendering the fallback if the content itself can render synchronously
1344+
13311345
// Stash the original stack frame.
13321346
constsuspenseComponentStack=task.componentStack;
1333-
// This is a prerender. In this mode we want to render the fallback synchronously and schedule
1334-
// the content to render later. This is the opposite of what we do during a normal render
1335-
// where we try to skip rendering the fallback if the content itself can render synchronously
1336-
consttrackedPostpones=request.trackedPostpones;
13371347

13381348
constfallbackKeyPath: KeyNode=[
13391349
keyPath[0],
13401350
'Suspense Fallback',
13411351
keyPath[2],
13421352
];
1343-
constfallbackReplayNode: ReplayNode=[
1344-
fallbackKeyPath[1],
1345-
fallbackKeyPath[2],
1346-
([]: Array<ReplayNode>),
1347-
null,
1348-
];
1349-
trackedPostpones.workingMap.set(fallbackKeyPath,fallbackReplayNode);
1350-
// We are rendering the fallback before the boundary content so we keep track of
1351-
// the fallback replay node until we determine if the primary content suspends
1352-
newBoundary.trackedFallbackNode=fallbackReplayNode;
1353+
if(trackedPostpones!==null){
1354+
constfallbackReplayNode: ReplayNode=[
1355+
fallbackKeyPath[1],
1356+
fallbackKeyPath[2],
1357+
([]: Array<ReplayNode>),
1358+
null,
1359+
];
1360+
trackedPostpones.workingMap.set(fallbackKeyPath,fallbackReplayNode);
1361+
// We are rendering the fallback before the boundary content so we keep track of
1362+
// the fallback replay node until we determine if the primary content suspends
1363+
newBoundary.trackedFallbackNode=fallbackReplayNode;
1364+
}
13531365

13541366
task.blockedSegment=boundarySegment;
13551367
task.blockedPreamble=newBoundary.fallbackPreamble;
@@ -1580,6 +1592,7 @@ function replaySuspenseBoundary(
15801592

15811593
constcontent: ReactNodeList=props.children;
15821594
constfallback: ReactNodeList=props.fallback;
1595+
constdefer: boolean=enableCPUSuspense&&props.defer===true;
15831596

15841597
constfallbackAbortSet: Set<Task>=newSet();
15851598
letresumedBoundary: SuspenseBoundary;
@@ -1590,6 +1603,7 @@ function replaySuspenseBoundary(
15901603
fallbackAbortSet,
15911604
createPreambleState(),
15921605
createPreambleState(),
1606+
defer,
15931607
);
15941608
}else{
15951609
resumedBoundary =createSuspenseBoundary(
@@ -1598,6 +1612,7 @@ function replaySuspenseBoundary(
15981612
fallbackAbortSet,
15991613
null,
16001614
null,
1615+
defer,
16011616
);
16021617
}
16031618
resumedBoundary.parentFlushed=true;
@@ -4384,6 +4399,7 @@ function abortRemainingSuspenseBoundary(
43844399
newSet(),
43854400
null,
43864401
null,
4402+
false,
43874403
);
43884404
resumedBoundary.parentFlushed=true;
43894405
// We restore the same id of this boundary as was used during prerender.
@@ -5493,7 +5509,8 @@ function flushSegment(
54935509
!flushingPartialBoundaries&&
54945510
isEligibleForOutlining(request,boundary)&&
54955511
(flushedByteSize+boundary.byteSize>request.progressiveChunkSize||
5496-
hasSuspenseyContent(boundary.contentState))
5512+
hasSuspenseyContent(boundary.contentState)||
5513+
boundary.defer)
54975514
){
54985515
// Inlining this boundary would make the current sequence being written too large
54995516
// and block the parent for too long. Instead, it will be emitted separately so that we

‎packages/shared/ReactTypes.js‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -313,6 +313,7 @@ export type SuspenseProps = {
313313

314314
unstable_avoidThisFallback?: boolean,
315315
unstable_expectedLoadTime?: number,
316+
defer?: boolean,
316317
name?: string,
317318
};
318319

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 0ba2f01

Browse files
authored
Rename <Suspense unstable_expectedLoadTime> to <Suspense defer> and implement in SSR (#35022)
We've long had the CPU suspense feature behind a flag under the terrible API `unstable_expectedLoadTime={arbitraryNumber}`. We've known for a long time we want it to just be `defer={true}` (or just `<Suspense defer>` in the short hand syntax). So this adds the new name and warns for the old name. For only the new name, I also implemented SSR semantics in Fizz. It has two effects here. 1) It renders the fallback before the content (similar to prerender) allowing siblings to complete quicker. 2) It always outlines the result. When streaming this should really happen naturally but if you defer a prerendered content it also implies that it's expensive and should be outlined. It gives you a opt-in to outlining similar to suspensey images and css but let you control it manually.
1 parent dd048c3 commit 0ba2f01

5 files changed

Lines changed: 132 additions & 32 deletions

File tree

‎packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js‎

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9433,4 +9433,34 @@ Unfortunately that previous paragraph wasn't quite long enough so I'll continue
94339433
</html>,
94349434
);
94359435
});
9436+
9437+
// @gate enableCPUSuspense
9438+
it('outlines deferred Suspense boundaries',async()=>{
9439+
functionLog({text}){
9440+
Scheduler.log(text);
9441+
returntext;
9442+
}
9443+
9444+
awaitact(async()=>{
9445+
renderToPipeableStream(
9446+
<div>
9447+
<Suspensedefer={true}fallback={<Logtext="Waiting"/>}>
9448+
<span>{<Logtext="hello"/>}</span>
9449+
</Suspense>
9450+
</div>,
9451+
).pipe(writable);
9452+
awaitjest.runAllTimers();
9453+
consttemp=document.createElement('body');
9454+
temp.innerHTML=buffer;
9455+
expect(getVisibleChildren(temp)).toEqual(<div>Waiting</div>);
9456+
});
9457+
9458+
assertLog(['Waiting','hello']);
9459+
9460+
expect(getVisibleChildren(container)).toEqual(
9461+
<div>
9462+
<span>hello</span>
9463+
</div>,
9464+
);
9465+
});
94369466
});

‎packages/react-reconciler/src/ReactFiberBeginWork.js‎

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -325,6 +325,7 @@ export let didWarnAboutReassigningProps: boolean;
325325
letdidWarnAboutRevealOrder;
326326
letdidWarnAboutTailOptions;
327327
letdidWarnAboutClassNameOnViewTransition;
328+
letdidWarnAboutExpectedLoadTime=false;
328329

329330
if(__DEV__){
330331
didWarnAboutBadClass=({}: {[string]: boolean});
@@ -2458,8 +2459,20 @@ function updateSuspenseComponent(
24582459
returnbailoutOffscreenComponent(null,primaryChildFragment);
24592460
}elseif(
24602461
enableCPUSuspense&&
2461-
typeofnextProps.unstable_expectedLoadTime==='number'
2462+
(typeofnextProps.unstable_expectedLoadTime==='number'||
2463+
nextProps.defer===true)
24622464
){
2465+
if(__DEV__){
2466+
if(typeofnextProps.unstable_expectedLoadTime==='number'){
2467+
if(!didWarnAboutExpectedLoadTime){
2468+
didWarnAboutExpectedLoadTime=true;
2469+
console.error(
2470+
'<Suspense unstable_expectedLoadTime={...}> is deprecated. '+
2471+
'Use <Suspense defer={true}> instead.',
2472+
);
2473+
}
2474+
}
2475+
}
24632476
// This is a CPU-bound tree. Skip this tree and show a placeholder to
24642477
// unblock the surrounding content. Then immediately retry after the
24652478
// initial commit.

‎packages/react-reconciler/src/__tests__/ReactCPUSuspense-test.js‎

Lines changed: 53 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
/* eslint-disable react/jsx-boolean-value */
2+
13
letReact;
24
letReactNoop;
35
letScheduler;
@@ -11,6 +13,7 @@ let resolveText;
1113
// let rejectText;
1214

1315
letassertLog;
16+
letassertConsoleErrorDev;
1417
letwaitForPaint;
1518

1619
describe('ReactSuspenseWithNoopRenderer',()=>{
@@ -26,6 +29,7 @@ describe('ReactSuspenseWithNoopRenderer', () => {
2629

2730
constInternalTestUtils=require('internal-test-utils');
2831
assertLog=InternalTestUtils.assertLog;
32+
assertConsoleErrorDev=InternalTestUtils.assertConsoleErrorDev;
2933
waitForPaint=InternalTestUtils.waitForPaint;
3034

3135
textCache=newMap();
@@ -116,14 +120,14 @@ describe('ReactSuspenseWithNoopRenderer', () => {
116120
}
117121

118122
// @gate enableCPUSuspense
119-
it('skips CPU-bound trees on initial mount',async()=>{
123+
it('warns for the old name is used',async()=>{
120124
functionApp(){
121125
return(
122126
<>
123127
<Texttext="Outer"/>
124128
<div>
125129
<Suspense
126-
unstable_expectedLoadTime={2000}
130+
unstable_expectedLoadTime={1000}
127131
fallback={<Texttext="Loading..."/>}>
128132
<Texttext="Inner"/>
129133
</Suspense>
@@ -132,6 +136,49 @@ describe('ReactSuspenseWithNoopRenderer', () => {
132136
);
133137
}
134138

139+
constroot=ReactNoop.createRoot();
140+
awaitact(async()=>{
141+
root.render(<App/>);
142+
awaitwaitForPaint(['Outer','Loading...']);
143+
assertConsoleErrorDev([
144+
'<Suspense unstable_expectedLoadTime={...}> is deprecated. '+
145+
'Use <Suspense defer={true}> instead.'+
146+
'\n in Suspense (at **)'+
147+
'\n in App (at **)',
148+
]);
149+
expect(root).toMatchRenderedOutput(
150+
<>
151+
Outer
152+
<div>Loading...</div>
153+
</>,
154+
);
155+
});
156+
157+
// Inner contents finish in separate commit from outer
158+
assertLog(['Inner']);
159+
expect(root).toMatchRenderedOutput(
160+
<>
161+
Outer
162+
<div>Inner</div>
163+
</>,
164+
);
165+
});
166+
167+
// @gate enableCPUSuspense
168+
it('skips CPU-bound trees on initial mount',async()=>{
169+
functionApp(){
170+
return(
171+
<>
172+
<Texttext="Outer"/>
173+
<div>
174+
<Suspensedeferfallback={<Texttext="Loading..."/>}>
175+
<Texttext="Inner"/>
176+
</Suspense>
177+
</div>
178+
</>
179+
);
180+
}
181+
135182
constroot=ReactNoop.createRoot();
136183
awaitact(async()=>{
137184
root.render(<App/>);
@@ -164,9 +211,7 @@ describe('ReactSuspenseWithNoopRenderer', () => {
164211
<>
165212
<Texttext="Outer"/>
166213
<div>
167-
<Suspense
168-
unstable_expectedLoadTime={2000}
169-
fallback={<Texttext="Loading..."/>}>
214+
<Suspensedeferfallback={<Texttext="Loading..."/>}>
170215
<Texttext={`Inner [${count}]`}/>
171216
</Suspense>
172217
</div>
@@ -209,9 +254,7 @@ describe('ReactSuspenseWithNoopRenderer', () => {
209254
<>
210255
<Texttext="Outer"/>
211256
<div>
212-
<Suspense
213-
unstable_expectedLoadTime={2000}
214-
fallback={<Texttext="Loading..."/>}>
257+
<Suspensedeferfallback={<Texttext="Loading..."/>}>
215258
<AsyncTexttext="Inner"/>
216259
</Suspense>
217260
</div>
@@ -263,14 +306,10 @@ describe('ReactSuspenseWithNoopRenderer', () => {
263306
<>
264307
<Texttext="A"/>
265308
<div>
266-
<Suspense
267-
unstable_expectedLoadTime={2000}
268-
fallback={<Texttext="Loading B..."/>}>
309+
<Suspensedeferfallback={<Texttext="Loading B..."/>}>
269310
<Texttext="B"/>
270311
<div>
271-
<Suspense
272-
unstable_expectedLoadTime={2000}
273-
fallback={<Texttext="Loading C..."/>}>
312+
<Suspensedeferfallback={<Texttext="Loading C..."/>}>
274313
<Texttext="C"/>
275314
</Suspense>
276315
</div>

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

Lines changed: 34 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,7 @@ import {
181181
enableViewTransition,
182182
enableFizzBlockingRender,
183183
enableAsyncDebugInfo,
184+
enableCPUSuspense,
184185
}from'shared/ReactFeatureFlags';
185186

186187
importassignfrom'shared/assign';
@@ -250,6 +251,7 @@ type SuspenseBoundary = {
250251
row: null|SuspenseListRow,// the row that this boundary blocks from completing.
251252
completedSegments: Array<Segment>,// completed but not yet flushed segments.
252253
byteSize: number,// used to determine whether to inline children boundaries.
254+
defer: boolean,// never inline deferred boundaries
253255
fallbackAbortableTasks: Set<Task>,// used to cancel task on the fallback if the boundary completes or gets canceled.
254256
contentState: HoistableState,
255257
fallbackState: HoistableState,
@@ -456,7 +458,9 @@ function isEligibleForOutlining(
456458
// The larger this limit is, the more we can save on preparing fallbacks in case we end up
457459
// outlining.
458460
return(
459-
(boundary.byteSize>500||hasSuspenseyContent(boundary.contentState))&&
461+
(boundary.byteSize>500||
462+
hasSuspenseyContent(boundary.contentState)||
463+
boundary.defer)&&
460464
// For boundaries that can possibly contribute to the preamble we don't want to outline
461465
// them regardless of their size since the fallbacks should only be emitted if we've
462466
// errored the boundary.
@@ -782,6 +786,7 @@ function createSuspenseBoundary(
782786
fallbackAbortableTasks: Set<Task>,
783787
contentPreamble: null | Preamble,
784788
fallbackPreamble: null | Preamble,
789+
defer: boolean,
785790
): SuspenseBoundary {
786791
constboundary: SuspenseBoundary={
787792
status: PENDING,
@@ -791,6 +796,7 @@ function createSuspenseBoundary(
791796
row: row,
792797
completedSegments: [],
793798
byteSize: 0,
799+
defer: defer,
794800
fallbackAbortableTasks,
795801
errorDigest: null,
796802
contentState: createHoistableState(),
@@ -1274,6 +1280,7 @@ function renderSuspenseBoundary(
12741280
// in case it ends up generating a large subtree of content.
12751281
constfallback: ReactNodeList=props.fallback;
12761282
constcontent: ReactNodeList=props.children;
1283+
constdefer: boolean=enableCPUSuspense&&props.defer===true;
12771284

12781285
constfallbackAbortSet: Set<Task>=newSet();
12791286
letnewBoundary: SuspenseBoundary;
@@ -1284,6 +1291,7 @@ function renderSuspenseBoundary(
12841291
fallbackAbortSet,
12851292
createPreambleState(),
12861293
createPreambleState(),
1294+
defer,
12871295
);
12881296
}else{
12891297
newBoundary=createSuspenseBoundary(
@@ -1292,6 +1300,7 @@ function renderSuspenseBoundary(
12921300
fallbackAbortSet,
12931301
null,
12941302
null,
1303+
defer,
12951304
);
12961305
}
12971306
if(request.trackedPostpones!==null){
@@ -1327,29 +1336,32 @@ function renderSuspenseBoundary(
13271336
// no parent segment so there's nothing to wait on.
13281337
contentRootSegment.parentFlushed=true;
13291338

1330-
if(request.trackedPostpones!==null){
1339+
consttrackedPostpones=request.trackedPostpones;
1340+
if(trackedPostpones!==null||defer){
1341+
// This is a prerender or deferred boundary. In this mode we want to render the fallback synchronously
1342+
// and schedule the content to render later. This is the opposite of what we do during a normal render
1343+
// where we try to skip rendering the fallback if the content itself can render synchronously
1344+
13311345
// Stash the original stack frame.
13321346
constsuspenseComponentStack=task.componentStack;
1333-
// This is a prerender. In this mode we want to render the fallback synchronously and schedule
1334-
// the content to render later. This is the opposite of what we do during a normal render
1335-
// where we try to skip rendering the fallback if the content itself can render synchronously
1336-
consttrackedPostpones=request.trackedPostpones;
13371347

13381348
constfallbackKeyPath: KeyNode=[
13391349
keyPath[0],
13401350
'Suspense Fallback',
13411351
keyPath[2],
13421352
];
1343-
constfallbackReplayNode: ReplayNode=[
1344-
fallbackKeyPath[1],
1345-
fallbackKeyPath[2],
1346-
([]: Array<ReplayNode>),
1347-
null,
1348-
];
1349-
trackedPostpones.workingMap.set(fallbackKeyPath,fallbackReplayNode);
1350-
// We are rendering the fallback before the boundary content so we keep track of
1351-
// the fallback replay node until we determine if the primary content suspends
1352-
newBoundary.trackedFallbackNode=fallbackReplayNode;
1353+
if(trackedPostpones!==null){
1354+
constfallbackReplayNode: ReplayNode=[
1355+
fallbackKeyPath[1],
1356+
fallbackKeyPath[2],
1357+
([]: Array<ReplayNode>),
1358+
null,
1359+
];
1360+
trackedPostpones.workingMap.set(fallbackKeyPath,fallbackReplayNode);
1361+
// We are rendering the fallback before the boundary content so we keep track of
1362+
// the fallback replay node until we determine if the primary content suspends
1363+
newBoundary.trackedFallbackNode=fallbackReplayNode;
1364+
}
13531365

13541366
task.blockedSegment=boundarySegment;
13551367
task.blockedPreamble=newBoundary.fallbackPreamble;
@@ -1580,6 +1592,7 @@ function replaySuspenseBoundary(
15801592

15811593
constcontent: ReactNodeList=props.children;
15821594
constfallback: ReactNodeList=props.fallback;
1595+
constdefer: boolean=enableCPUSuspense&&props.defer===true;
15831596

15841597
constfallbackAbortSet: Set<Task>=newSet();
15851598
letresumedBoundary: SuspenseBoundary;
@@ -1590,6 +1603,7 @@ function replaySuspenseBoundary(
15901603
fallbackAbortSet,
15911604
createPreambleState(),
15921605
createPreambleState(),
1606+
defer,
15931607
);
15941608
}else{
15951609
resumedBoundary =createSuspenseBoundary(
@@ -1598,6 +1612,7 @@ function replaySuspenseBoundary(
15981612
fallbackAbortSet,
15991613
null,
16001614
null,
1615+
defer,
16011616
);
16021617
}
16031618
resumedBoundary.parentFlushed=true;
@@ -4384,6 +4399,7 @@ function abortRemainingSuspenseBoundary(
43844399
newSet(),
43854400
null,
43864401
null,
4402+
false,
43874403
);
43884404
resumedBoundary.parentFlushed=true;
43894405
// We restore the same id of this boundary as was used during prerender.
@@ -5493,7 +5509,8 @@ function flushSegment(
54935509
!flushingPartialBoundaries&&
54945510
isEligibleForOutlining(request,boundary)&&
54955511
(flushedByteSize+boundary.byteSize>request.progressiveChunkSize||
5496-
hasSuspenseyContent(boundary.contentState))
5512+
hasSuspenseyContent(boundary.contentState)||
5513+
boundary.defer)
54975514
){
54985515
// Inlining this boundary would make the current sequence being written too large
54995516
// and block the parent for too long. Instead, it will be emitted separately so that we

‎packages/shared/ReactTypes.js‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -313,6 +313,7 @@ export type SuspenseProps = {
313313

314314
unstable_avoidThisFallback?: boolean,
315315
unstable_expectedLoadTime?: number,
316+
defer?: boolean,
316317
name?: string,
317318
};
318319

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 0ba2f01

Browse files
authored
Rename <Suspense unstable_expectedLoadTime> to <Suspense defer> and implement in SSR (#35022)
We've long had the CPU suspense feature behind a flag under the terrible API `unstable_expectedLoadTime={arbitraryNumber}`. We've known for a long time we want it to just be `defer={true}` (or just `<Suspense defer>` in the short hand syntax). So this adds the new name and warns for the old name. For only the new name, I also implemented SSR semantics in Fizz. It has two effects here. 1) It renders the fallback before the content (similar to prerender) allowing siblings to complete quicker. 2) It always outlines the result. When streaming this should really happen naturally but if you defer a prerendered content it also implies that it's expensive and should be outlined. It gives you a opt-in to outlining similar to suspensey images and css but let you control it manually.
1 parent dd048c3 commit 0ba2f01

5 files changed

Lines changed: 132 additions & 32 deletions

File tree

‎packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js‎

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9433,4 +9433,34 @@ Unfortunately that previous paragraph wasn't quite long enough so I'll continue
94339433
</html>,
94349434
);
94359435
});
9436+
9437+
// @gate enableCPUSuspense
9438+
it('outlines deferred Suspense boundaries',async()=>{
9439+
functionLog({text}){
9440+
Scheduler.log(text);
9441+
returntext;
9442+
}
9443+
9444+
awaitact(async()=>{
9445+
renderToPipeableStream(
9446+
<div>
9447+
<Suspensedefer={true}fallback={<Logtext="Waiting"/>}>
9448+
<span>{<Logtext="hello"/>}</span>
9449+
</Suspense>
9450+
</div>,
9451+
).pipe(writable);
9452+
awaitjest.runAllTimers();
9453+
consttemp=document.createElement('body');
9454+
temp.innerHTML=buffer;
9455+
expect(getVisibleChildren(temp)).toEqual(<div>Waiting</div>);
9456+
});
9457+
9458+
assertLog(['Waiting','hello']);
9459+
9460+
expect(getVisibleChildren(container)).toEqual(
9461+
<div>
9462+
<span>hello</span>
9463+
</div>,
9464+
);
9465+
});
94369466
});

‎packages/react-reconciler/src/ReactFiberBeginWork.js‎

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -325,6 +325,7 @@ export let didWarnAboutReassigningProps: boolean;
325325
letdidWarnAboutRevealOrder;
326326
letdidWarnAboutTailOptions;
327327
letdidWarnAboutClassNameOnViewTransition;
328+
letdidWarnAboutExpectedLoadTime=false;
328329

329330
if(__DEV__){
330331
didWarnAboutBadClass=({}: {[string]: boolean});
@@ -2458,8 +2459,20 @@ function updateSuspenseComponent(
24582459
returnbailoutOffscreenComponent(null,primaryChildFragment);
24592460
}elseif(
24602461
enableCPUSuspense&&
2461-
typeofnextProps.unstable_expectedLoadTime==='number'
2462+
(typeofnextProps.unstable_expectedLoadTime==='number'||
2463+
nextProps.defer===true)
24622464
){
2465+
if(__DEV__){
2466+
if(typeofnextProps.unstable_expectedLoadTime==='number'){
2467+
if(!didWarnAboutExpectedLoadTime){
2468+
didWarnAboutExpectedLoadTime=true;
2469+
console.error(
2470+
'<Suspense unstable_expectedLoadTime={...}> is deprecated. '+
2471+
'Use <Suspense defer={true}> instead.',
2472+
);
2473+
}
2474+
}
2475+
}
24632476
// This is a CPU-bound tree. Skip this tree and show a placeholder to
24642477
// unblock the surrounding content. Then immediately retry after the
24652478
// initial commit.

‎packages/react-reconciler/src/__tests__/ReactCPUSuspense-test.js‎

Lines changed: 53 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
/* eslint-disable react/jsx-boolean-value */
2+
13
letReact;
24
letReactNoop;
35
letScheduler;
@@ -11,6 +13,7 @@ let resolveText;
1113
// let rejectText;
1214

1315
letassertLog;
16+
letassertConsoleErrorDev;
1417
letwaitForPaint;
1518

1619
describe('ReactSuspenseWithNoopRenderer',()=>{
@@ -26,6 +29,7 @@ describe('ReactSuspenseWithNoopRenderer', () => {
2629

2730
constInternalTestUtils=require('internal-test-utils');
2831
assertLog=InternalTestUtils.assertLog;
32+
assertConsoleErrorDev=InternalTestUtils.assertConsoleErrorDev;
2933
waitForPaint=InternalTestUtils.waitForPaint;
3034

3135
textCache=newMap();
@@ -116,14 +120,14 @@ describe('ReactSuspenseWithNoopRenderer', () => {
116120
}
117121

118122
// @gate enableCPUSuspense
119-
it('skips CPU-bound trees on initial mount',async()=>{
123+
it('warns for the old name is used',async()=>{
120124
functionApp(){
121125
return(
122126
<>
123127
<Texttext="Outer"/>
124128
<div>
125129
<Suspense
126-
unstable_expectedLoadTime={2000}
130+
unstable_expectedLoadTime={1000}
127131
fallback={<Texttext="Loading..."/>}>
128132
<Texttext="Inner"/>
129133
</Suspense>
@@ -132,6 +136,49 @@ describe('ReactSuspenseWithNoopRenderer', () => {
132136
);
133137
}
134138

139+
constroot=ReactNoop.createRoot();
140+
awaitact(async()=>{
141+
root.render(<App/>);
142+
awaitwaitForPaint(['Outer','Loading...']);
143+
assertConsoleErrorDev([
144+
'<Suspense unstable_expectedLoadTime={...}> is deprecated. '+
145+
'Use <Suspense defer={true}> instead.'+
146+
'\n in Suspense (at **)'+
147+
'\n in App (at **)',
148+
]);
149+
expect(root).toMatchRenderedOutput(
150+
<>
151+
Outer
152+
<div>Loading...</div>
153+
</>,
154+
);
155+
});
156+
157+
// Inner contents finish in separate commit from outer
158+
assertLog(['Inner']);
159+
expect(root).toMatchRenderedOutput(
160+
<>
161+
Outer
162+
<div>Inner</div>
163+
</>,
164+
);
165+
});
166+
167+
// @gate enableCPUSuspense
168+
it('skips CPU-bound trees on initial mount',async()=>{
169+
functionApp(){
170+
return(
171+
<>
172+
<Texttext="Outer"/>
173+
<div>
174+
<Suspensedeferfallback={<Texttext="Loading..."/>}>
175+
<Texttext="Inner"/>
176+
</Suspense>
177+
</div>
178+
</>
179+
);
180+
}
181+
135182
constroot=ReactNoop.createRoot();
136183
awaitact(async()=>{
137184
root.render(<App/>);
@@ -164,9 +211,7 @@ describe('ReactSuspenseWithNoopRenderer', () => {
164211
<>
165212
<Texttext="Outer"/>
166213
<div>
167-
<Suspense
168-
unstable_expectedLoadTime={2000}
169-
fallback={<Texttext="Loading..."/>}>
214+
<Suspensedeferfallback={<Texttext="Loading..."/>}>
170215
<Texttext={`Inner [${count}]`}/>
171216
</Suspense>
172217
</div>
@@ -209,9 +254,7 @@ describe('ReactSuspenseWithNoopRenderer', () => {
209254
<>
210255
<Texttext="Outer"/>
211256
<div>
212-
<Suspense
213-
unstable_expectedLoadTime={2000}
214-
fallback={<Texttext="Loading..."/>}>
257+
<Suspensedeferfallback={<Texttext="Loading..."/>}>
215258
<AsyncTexttext="Inner"/>
216259
</Suspense>
217260
</div>
@@ -263,14 +306,10 @@ describe('ReactSuspenseWithNoopRenderer', () => {
263306
<>
264307
<Texttext="A"/>
265308
<div>
266-
<Suspense
267-
unstable_expectedLoadTime={2000}
268-
fallback={<Texttext="Loading B..."/>}>
309+
<Suspensedeferfallback={<Texttext="Loading B..."/>}>
269310
<Texttext="B"/>
270311
<div>
271-
<Suspense
272-
unstable_expectedLoadTime={2000}
273-
fallback={<Texttext="Loading C..."/>}>
312+
<Suspensedeferfallback={<Texttext="Loading C..."/>}>
274313
<Texttext="C"/>
275314
</Suspense>
276315
</div>

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

Lines changed: 34 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,7 @@ import {
181181
enableViewTransition,
182182
enableFizzBlockingRender,
183183
enableAsyncDebugInfo,
184+
enableCPUSuspense,
184185
}from'shared/ReactFeatureFlags';
185186

186187
importassignfrom'shared/assign';
@@ -250,6 +251,7 @@ type SuspenseBoundary = {
250251
row: null|SuspenseListRow,// the row that this boundary blocks from completing.
251252
completedSegments: Array<Segment>,// completed but not yet flushed segments.
252253
byteSize: number,// used to determine whether to inline children boundaries.
254+
defer: boolean,// never inline deferred boundaries
253255
fallbackAbortableTasks: Set<Task>,// used to cancel task on the fallback if the boundary completes or gets canceled.
254256
contentState: HoistableState,
255257
fallbackState: HoistableState,
@@ -456,7 +458,9 @@ function isEligibleForOutlining(
456458
// The larger this limit is, the more we can save on preparing fallbacks in case we end up
457459
// outlining.
458460
return(
459-
(boundary.byteSize>500||hasSuspenseyContent(boundary.contentState))&&
461+
(boundary.byteSize>500||
462+
hasSuspenseyContent(boundary.contentState)||
463+
boundary.defer)&&
460464
// For boundaries that can possibly contribute to the preamble we don't want to outline
461465
// them regardless of their size since the fallbacks should only be emitted if we've
462466
// errored the boundary.
@@ -782,6 +786,7 @@ function createSuspenseBoundary(
782786
fallbackAbortableTasks: Set<Task>,
783787
contentPreamble: null | Preamble,
784788
fallbackPreamble: null | Preamble,
789+
defer: boolean,
785790
): SuspenseBoundary {
786791
constboundary: SuspenseBoundary={
787792
status: PENDING,
@@ -791,6 +796,7 @@ function createSuspenseBoundary(
791796
row: row,
792797
completedSegments: [],
793798
byteSize: 0,
799+
defer: defer,
794800
fallbackAbortableTasks,
795801
errorDigest: null,
796802
contentState: createHoistableState(),
@@ -1274,6 +1280,7 @@ function renderSuspenseBoundary(
12741280
// in case it ends up generating a large subtree of content.
12751281
constfallback: ReactNodeList=props.fallback;
12761282
constcontent: ReactNodeList=props.children;
1283+
constdefer: boolean=enableCPUSuspense&&props.defer===true;
12771284

12781285
constfallbackAbortSet: Set<Task>=newSet();
12791286
letnewBoundary: SuspenseBoundary;
@@ -1284,6 +1291,7 @@ function renderSuspenseBoundary(
12841291
fallbackAbortSet,
12851292
createPreambleState(),
12861293
createPreambleState(),
1294+
defer,
12871295
);
12881296
}else{
12891297
newBoundary=createSuspenseBoundary(
@@ -1292,6 +1300,7 @@ function renderSuspenseBoundary(
12921300
fallbackAbortSet,
12931301
null,
12941302
null,
1303+
defer,
12951304
);
12961305
}
12971306
if(request.trackedPostpones!==null){
@@ -1327,29 +1336,32 @@ function renderSuspenseBoundary(
13271336
// no parent segment so there's nothing to wait on.
13281337
contentRootSegment.parentFlushed=true;
13291338

1330-
if(request.trackedPostpones!==null){
1339+
consttrackedPostpones=request.trackedPostpones;
1340+
if(trackedPostpones!==null||defer){
1341+
// This is a prerender or deferred boundary. In this mode we want to render the fallback synchronously
1342+
// and schedule the content to render later. This is the opposite of what we do during a normal render
1343+
// where we try to skip rendering the fallback if the content itself can render synchronously
1344+
13311345
// Stash the original stack frame.
13321346
constsuspenseComponentStack=task.componentStack;
1333-
// This is a prerender. In this mode we want to render the fallback synchronously and schedule
1334-
// the content to render later. This is the opposite of what we do during a normal render
1335-
// where we try to skip rendering the fallback if the content itself can render synchronously
1336-
consttrackedPostpones=request.trackedPostpones;
13371347

13381348
constfallbackKeyPath: KeyNode=[
13391349
keyPath[0],
13401350
'Suspense Fallback',
13411351
keyPath[2],
13421352
];
1343-
constfallbackReplayNode: ReplayNode=[
1344-
fallbackKeyPath[1],
1345-
fallbackKeyPath[2],
1346-
([]: Array<ReplayNode>),
1347-
null,
1348-
];
1349-
trackedPostpones.workingMap.set(fallbackKeyPath,fallbackReplayNode);
1350-
// We are rendering the fallback before the boundary content so we keep track of
1351-
// the fallback replay node until we determine if the primary content suspends
1352-
newBoundary.trackedFallbackNode=fallbackReplayNode;
1353+
if(trackedPostpones!==null){
1354+
constfallbackReplayNode: ReplayNode=[
1355+
fallbackKeyPath[1],
1356+
fallbackKeyPath[2],
1357+
([]: Array<ReplayNode>),
1358+
null,
1359+
];
1360+
trackedPostpones.workingMap.set(fallbackKeyPath,fallbackReplayNode);
1361+
// We are rendering the fallback before the boundary content so we keep track of
1362+
// the fallback replay node until we determine if the primary content suspends
1363+
newBoundary.trackedFallbackNode=fallbackReplayNode;
1364+
}
13531365

13541366
task.blockedSegment=boundarySegment;
13551367
task.blockedPreamble=newBoundary.fallbackPreamble;
@@ -1580,6 +1592,7 @@ function replaySuspenseBoundary(
15801592

15811593
constcontent: ReactNodeList=props.children;
15821594
constfallback: ReactNodeList=props.fallback;
1595+
constdefer: boolean=enableCPUSuspense&&props.defer===true;
15831596

15841597
constfallbackAbortSet: Set<Task>=newSet();
15851598
letresumedBoundary: SuspenseBoundary;
@@ -1590,6 +1603,7 @@ function replaySuspenseBoundary(
15901603
fallbackAbortSet,
15911604
createPreambleState(),
15921605
createPreambleState(),
1606+
defer,
15931607
);
15941608
}else{
15951609
resumedBoundary =createSuspenseBoundary(
@@ -1598,6 +1612,7 @@ function replaySuspenseBoundary(
15981612
fallbackAbortSet,
15991613
null,
16001614
null,
1615+
defer,
16011616
);
16021617
}
16031618
resumedBoundary.parentFlushed=true;
@@ -4384,6 +4399,7 @@ function abortRemainingSuspenseBoundary(
43844399
newSet(),
43854400
null,
43864401
null,
4402+
false,
43874403
);
43884404
resumedBoundary.parentFlushed=true;
43894405
// We restore the same id of this boundary as was used during prerender.
@@ -5493,7 +5509,8 @@ function flushSegment(
54935509
!flushingPartialBoundaries&&
54945510
isEligibleForOutlining(request,boundary)&&
54955511
(flushedByteSize+boundary.byteSize>request.progressiveChunkSize||
5496-
hasSuspenseyContent(boundary.contentState))
5512+
hasSuspenseyContent(boundary.contentState)||
5513+
boundary.defer)
54975514
){
54985515
// Inlining this boundary would make the current sequence being written too large
54995516
// and block the parent for too long. Instead, it will be emitted separately so that we

‎packages/shared/ReactTypes.js‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -313,6 +313,7 @@ export type SuspenseProps = {
313313

314314
unstable_avoidThisFallback?: boolean,
315315
unstable_expectedLoadTime?: number,
316+
defer?: boolean,
316317
name?: string,
317318
};
318319

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 0ba2f01

Browse files
authored
Rename <Suspense unstable_expectedLoadTime> to <Suspense defer> and implement in SSR (#35022)
We've long had the CPU suspense feature behind a flag under the terrible API `unstable_expectedLoadTime={arbitraryNumber}`. We've known for a long time we want it to just be `defer={true}` (or just `<Suspense defer>` in the short hand syntax). So this adds the new name and warns for the old name. For only the new name, I also implemented SSR semantics in Fizz. It has two effects here. 1) It renders the fallback before the content (similar to prerender) allowing siblings to complete quicker. 2) It always outlines the result. When streaming this should really happen naturally but if you defer a prerendered content it also implies that it's expensive and should be outlined. It gives you a opt-in to outlining similar to suspensey images and css but let you control it manually.
1 parent dd048c3 commit 0ba2f01

5 files changed

Lines changed: 132 additions & 32 deletions

File tree

‎packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js‎

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9433,4 +9433,34 @@ Unfortunately that previous paragraph wasn't quite long enough so I'll continue
94339433
</html>,
94349434
);
94359435
});
9436+
9437+
// @gate enableCPUSuspense
9438+
it('outlines deferred Suspense boundaries',async()=>{
9439+
functionLog({text}){
9440+
Scheduler.log(text);
9441+
returntext;
9442+
}
9443+
9444+
awaitact(async()=>{
9445+
renderToPipeableStream(
9446+
<div>
9447+
<Suspensedefer={true}fallback={<Logtext="Waiting"/>}>
9448+
<span>{<Logtext="hello"/>}</span>
9449+
</Suspense>
9450+
</div>,
9451+
).pipe(writable);
9452+
awaitjest.runAllTimers();
9453+
consttemp=document.createElement('body');
9454+
temp.innerHTML=buffer;
9455+
expect(getVisibleChildren(temp)).toEqual(<div>Waiting</div>);
9456+
});
9457+
9458+
assertLog(['Waiting','hello']);
9459+
9460+
expect(getVisibleChildren(container)).toEqual(
9461+
<div>
9462+
<span>hello</span>
9463+
</div>,
9464+
);
9465+
});
94369466
});

‎packages/react-reconciler/src/ReactFiberBeginWork.js‎

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -325,6 +325,7 @@ export let didWarnAboutReassigningProps: boolean;
325325
letdidWarnAboutRevealOrder;
326326
letdidWarnAboutTailOptions;
327327
letdidWarnAboutClassNameOnViewTransition;
328+
letdidWarnAboutExpectedLoadTime=false;
328329

329330
if(__DEV__){
330331
didWarnAboutBadClass=({}: {[string]: boolean});
@@ -2458,8 +2459,20 @@ function updateSuspenseComponent(
24582459
returnbailoutOffscreenComponent(null,primaryChildFragment);
24592460
}elseif(
24602461
enableCPUSuspense&&
2461-
typeofnextProps.unstable_expectedLoadTime==='number'
2462+
(typeofnextProps.unstable_expectedLoadTime==='number'||
2463+
nextProps.defer===true)
24622464
){
2465+
if(__DEV__){
2466+
if(typeofnextProps.unstable_expectedLoadTime==='number'){
2467+
if(!didWarnAboutExpectedLoadTime){
2468+
didWarnAboutExpectedLoadTime=true;
2469+
console.error(
2470+
'<Suspense unstable_expectedLoadTime={...}> is deprecated. '+
2471+
'Use <Suspense defer={true}> instead.',
2472+
);
2473+
}
2474+
}
2475+
}
24632476
// This is a CPU-bound tree. Skip this tree and show a placeholder to
24642477
// unblock the surrounding content. Then immediately retry after the
24652478
// initial commit.

‎packages/react-reconciler/src/__tests__/ReactCPUSuspense-test.js‎

Lines changed: 53 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
/* eslint-disable react/jsx-boolean-value */
2+
13
letReact;
24
letReactNoop;
35
letScheduler;
@@ -11,6 +13,7 @@ let resolveText;
1113
// let rejectText;
1214

1315
letassertLog;
16+
letassertConsoleErrorDev;
1417
letwaitForPaint;
1518

1619
describe('ReactSuspenseWithNoopRenderer',()=>{
@@ -26,6 +29,7 @@ describe('ReactSuspenseWithNoopRenderer', () => {
2629

2730
constInternalTestUtils=require('internal-test-utils');
2831
assertLog=InternalTestUtils.assertLog;
32+
assertConsoleErrorDev=InternalTestUtils.assertConsoleErrorDev;
2933
waitForPaint=InternalTestUtils.waitForPaint;
3034

3135
textCache=newMap();
@@ -116,14 +120,14 @@ describe('ReactSuspenseWithNoopRenderer', () => {
116120
}
117121

118122
// @gate enableCPUSuspense
119-
it('skips CPU-bound trees on initial mount',async()=>{
123+
it('warns for the old name is used',async()=>{
120124
functionApp(){
121125
return(
122126
<>
123127
<Texttext="Outer"/>
124128
<div>
125129
<Suspense
126-
unstable_expectedLoadTime={2000}
130+
unstable_expectedLoadTime={1000}
127131
fallback={<Texttext="Loading..."/>}>
128132
<Texttext="Inner"/>
129133
</Suspense>
@@ -132,6 +136,49 @@ describe('ReactSuspenseWithNoopRenderer', () => {
132136
);
133137
}
134138

139+
constroot=ReactNoop.createRoot();
140+
awaitact(async()=>{
141+
root.render(<App/>);
142+
awaitwaitForPaint(['Outer','Loading...']);
143+
assertConsoleErrorDev([
144+
'<Suspense unstable_expectedLoadTime={...}> is deprecated. '+
145+
'Use <Suspense defer={true}> instead.'+
146+
'\n in Suspense (at **)'+
147+
'\n in App (at **)',
148+
]);
149+
expect(root).toMatchRenderedOutput(
150+
<>
151+
Outer
152+
<div>Loading...</div>
153+
</>,
154+
);
155+
});
156+
157+
// Inner contents finish in separate commit from outer
158+
assertLog(['Inner']);
159+
expect(root).toMatchRenderedOutput(
160+
<>
161+
Outer
162+
<div>Inner</div>
163+
</>,
164+
);
165+
});
166+
167+
// @gate enableCPUSuspense
168+
it('skips CPU-bound trees on initial mount',async()=>{
169+
functionApp(){
170+
return(
171+
<>
172+
<Texttext="Outer"/>
173+
<div>
174+
<Suspensedeferfallback={<Texttext="Loading..."/>}>
175+
<Texttext="Inner"/>
176+
</Suspense>
177+
</div>
178+
</>
179+
);
180+
}
181+
135182
constroot=ReactNoop.createRoot();
136183
awaitact(async()=>{
137184
root.render(<App/>);
@@ -164,9 +211,7 @@ describe('ReactSuspenseWithNoopRenderer', () => {
164211
<>
165212
<Texttext="Outer"/>
166213
<div>
167-
<Suspense
168-
unstable_expectedLoadTime={2000}
169-
fallback={<Texttext="Loading..."/>}>
214+
<Suspensedeferfallback={<Texttext="Loading..."/>}>
170215
<Texttext={`Inner [${count}]`}/>
171216
</Suspense>
172217
</div>
@@ -209,9 +254,7 @@ describe('ReactSuspenseWithNoopRenderer', () => {
209254
<>
210255
<Texttext="Outer"/>
211256
<div>
212-
<Suspense
213-
unstable_expectedLoadTime={2000}
214-
fallback={<Texttext="Loading..."/>}>
257+
<Suspensedeferfallback={<Texttext="Loading..."/>}>
215258
<AsyncTexttext="Inner"/>
216259
</Suspense>
217260
</div>
@@ -263,14 +306,10 @@ describe('ReactSuspenseWithNoopRenderer', () => {
263306
<>
264307
<Texttext="A"/>
265308
<div>
266-
<Suspense
267-
unstable_expectedLoadTime={2000}
268-
fallback={<Texttext="Loading B..."/>}>
309+
<Suspensedeferfallback={<Texttext="Loading B..."/>}>
269310
<Texttext="B"/>
270311
<div>
271-
<Suspense
272-
unstable_expectedLoadTime={2000}
273-
fallback={<Texttext="Loading C..."/>}>
312+
<Suspensedeferfallback={<Texttext="Loading C..."/>}>
274313
<Texttext="C"/>
275314
</Suspense>
276315
</div>

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

Lines changed: 34 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,7 @@ import {
181181
enableViewTransition,
182182
enableFizzBlockingRender,
183183
enableAsyncDebugInfo,
184+
enableCPUSuspense,
184185
}from'shared/ReactFeatureFlags';
185186

186187
importassignfrom'shared/assign';
@@ -250,6 +251,7 @@ type SuspenseBoundary = {
250251
row: null|SuspenseListRow,// the row that this boundary blocks from completing.
251252
completedSegments: Array<Segment>,// completed but not yet flushed segments.
252253
byteSize: number,// used to determine whether to inline children boundaries.
254+
defer: boolean,// never inline deferred boundaries
253255
fallbackAbortableTasks: Set<Task>,// used to cancel task on the fallback if the boundary completes or gets canceled.
254256
contentState: HoistableState,
255257
fallbackState: HoistableState,
@@ -456,7 +458,9 @@ function isEligibleForOutlining(
456458
// The larger this limit is, the more we can save on preparing fallbacks in case we end up
457459
// outlining.
458460
return(
459-
(boundary.byteSize>500||hasSuspenseyContent(boundary.contentState))&&
461+
(boundary.byteSize>500||
462+
hasSuspenseyContent(boundary.contentState)||
463+
boundary.defer)&&
460464
// For boundaries that can possibly contribute to the preamble we don't want to outline
461465
// them regardless of their size since the fallbacks should only be emitted if we've
462466
// errored the boundary.
@@ -782,6 +786,7 @@ function createSuspenseBoundary(
782786
fallbackAbortableTasks: Set<Task>,
783787
contentPreamble: null | Preamble,
784788
fallbackPreamble: null | Preamble,
789+
defer: boolean,
785790
): SuspenseBoundary {
786791
constboundary: SuspenseBoundary={
787792
status: PENDING,
@@ -791,6 +796,7 @@ function createSuspenseBoundary(
791796
row: row,
792797
completedSegments: [],
793798
byteSize: 0,
799+
defer: defer,
794800
fallbackAbortableTasks,
795801
errorDigest: null,
796802
contentState: createHoistableState(),
@@ -1274,6 +1280,7 @@ function renderSuspenseBoundary(
12741280
// in case it ends up generating a large subtree of content.
12751281
constfallback: ReactNodeList=props.fallback;
12761282
constcontent: ReactNodeList=props.children;
1283+
constdefer: boolean=enableCPUSuspense&&props.defer===true;
12771284

12781285
constfallbackAbortSet: Set<Task>=newSet();
12791286
letnewBoundary: SuspenseBoundary;
@@ -1284,6 +1291,7 @@ function renderSuspenseBoundary(
12841291
fallbackAbortSet,
12851292
createPreambleState(),
12861293
createPreambleState(),
1294+
defer,
12871295
);
12881296
}else{
12891297
newBoundary=createSuspenseBoundary(
@@ -1292,6 +1300,7 @@ function renderSuspenseBoundary(
12921300
fallbackAbortSet,
12931301
null,
12941302
null,
1303+
defer,
12951304
);
12961305
}
12971306
if(request.trackedPostpones!==null){
@@ -1327,29 +1336,32 @@ function renderSuspenseBoundary(
13271336
// no parent segment so there's nothing to wait on.
13281337
contentRootSegment.parentFlushed=true;
13291338

1330-
if(request.trackedPostpones!==null){
1339+
consttrackedPostpones=request.trackedPostpones;
1340+
if(trackedPostpones!==null||defer){
1341+
// This is a prerender or deferred boundary. In this mode we want to render the fallback synchronously
1342+
// and schedule the content to render later. This is the opposite of what we do during a normal render
1343+
// where we try to skip rendering the fallback if the content itself can render synchronously
1344+
13311345
// Stash the original stack frame.
13321346
constsuspenseComponentStack=task.componentStack;
1333-
// This is a prerender. In this mode we want to render the fallback synchronously and schedule
1334-
// the content to render later. This is the opposite of what we do during a normal render
1335-
// where we try to skip rendering the fallback if the content itself can render synchronously
1336-
consttrackedPostpones=request.trackedPostpones;
13371347

13381348
constfallbackKeyPath: KeyNode=[
13391349
keyPath[0],
13401350
'Suspense Fallback',
13411351
keyPath[2],
13421352
];
1343-
constfallbackReplayNode: ReplayNode=[
1344-
fallbackKeyPath[1],
1345-
fallbackKeyPath[2],
1346-
([]: Array<ReplayNode>),
1347-
null,
1348-
];
1349-
trackedPostpones.workingMap.set(fallbackKeyPath,fallbackReplayNode);
1350-
// We are rendering the fallback before the boundary content so we keep track of
1351-
// the fallback replay node until we determine if the primary content suspends
1352-
newBoundary.trackedFallbackNode=fallbackReplayNode;
1353+
if(trackedPostpones!==null){
1354+
constfallbackReplayNode: ReplayNode=[
1355+
fallbackKeyPath[1],
1356+
fallbackKeyPath[2],
1357+
([]: Array<ReplayNode>),
1358+
null,
1359+
];
1360+
trackedPostpones.workingMap.set(fallbackKeyPath,fallbackReplayNode);
1361+
// We are rendering the fallback before the boundary content so we keep track of
1362+
// the fallback replay node until we determine if the primary content suspends
1363+
newBoundary.trackedFallbackNode=fallbackReplayNode;
1364+
}
13531365

13541366
task.blockedSegment=boundarySegment;
13551367
task.blockedPreamble=newBoundary.fallbackPreamble;
@@ -1580,6 +1592,7 @@ function replaySuspenseBoundary(
15801592

15811593
constcontent: ReactNodeList=props.children;
15821594
constfallback: ReactNodeList=props.fallback;
1595+
constdefer: boolean=enableCPUSuspense&&props.defer===true;
15831596

15841597
constfallbackAbortSet: Set<Task>=newSet();
15851598
letresumedBoundary: SuspenseBoundary;
@@ -1590,6 +1603,7 @@ function replaySuspenseBoundary(
15901603
fallbackAbortSet,
15911604
createPreambleState(),
15921605
createPreambleState(),
1606+
defer,
15931607
);
15941608
}else{
15951609
resumedBoundary =createSuspenseBoundary(
@@ -1598,6 +1612,7 @@ function replaySuspenseBoundary(
15981612
fallbackAbortSet,
15991613
null,
16001614
null,
1615+
defer,
16011616
);
16021617
}
16031618
resumedBoundary.parentFlushed=true;
@@ -4384,6 +4399,7 @@ function abortRemainingSuspenseBoundary(
43844399
newSet(),
43854400
null,
43864401
null,
4402+
false,
43874403
);
43884404
resumedBoundary.parentFlushed=true;
43894405
// We restore the same id of this boundary as was used during prerender.
@@ -5493,7 +5509,8 @@ function flushSegment(
54935509
!flushingPartialBoundaries&&
54945510
isEligibleForOutlining(request,boundary)&&
54955511
(flushedByteSize+boundary.byteSize>request.progressiveChunkSize||
5496-
hasSuspenseyContent(boundary.contentState))
5512+
hasSuspenseyContent(boundary.contentState)||
5513+
boundary.defer)
54975514
){
54985515
// Inlining this boundary would make the current sequence being written too large
54995516
// and block the parent for too long. Instead, it will be emitted separately so that we

‎packages/shared/ReactTypes.js‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -313,6 +313,7 @@ export type SuspenseProps = {
313313

314314
unstable_avoidThisFallback?: boolean,
315315
unstable_expectedLoadTime?: number,
316+
defer?: boolean,
316317
name?: string,
317318
};
318319

0 commit comments

Comments
 (0)