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

File tree

5 files changed

+132
-32
lines changed

5 files changed

+132
-32
lines changed

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+
function Log({text}) {
9440+
Scheduler.log(text);
9441+
return text;
9442+
}
9443+
9444+
await act(async () => {
9445+
renderToPipeableStream(
9446+
<div>
9447+
<Suspense defer={true} fallback={<Log text="Waiting" />}>
9448+
<span>{<Log text="hello" />}</span>
9449+
</Suspense>
9450+
</div>,
9451+
).pipe(writable);
9452+
await jest.runAllTimers();
9453+
const temp = 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
let didWarnAboutRevealOrder;
326326
let didWarnAboutTailOptions;
327327
let didWarnAboutClassNameOnViewTransition;
328+
let didWarnAboutExpectedLoadTime = false;
328329

329330
if (__DEV__) {
330331
didWarnAboutBadClass = ({}: {[string]: boolean});
@@ -2458,8 +2459,20 @@ function updateSuspenseComponent(
24582459
return bailoutOffscreenComponent(null, primaryChildFragment);
24592460
} else if (
24602461
enableCPUSuspense &&
2461-
typeof nextProps.unstable_expectedLoadTime === 'number'
2462+
(typeof nextProps.unstable_expectedLoadTime === 'number' ||
2463+
nextProps.defer === true)
24622464
) {
2465+
if (__DEV__) {
2466+
if (typeof nextProps.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
let React;
24
let ReactNoop;
35
let Scheduler;
@@ -11,6 +13,7 @@ let resolveText;
1113
// let rejectText;
1214

1315
let assertLog;
16+
let assertConsoleErrorDev;
1417
let waitForPaint;
1518

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

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

3135
textCache = new Map();
@@ -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
function App() {
121125
return (
122126
<>
123127
<Text text="Outer" />
124128
<div>
125129
<Suspense
126-
unstable_expectedLoadTime={2000}
130+
unstable_expectedLoadTime={1000}
127131
fallback={<Text text="Loading..." />}>
128132
<Text text="Inner" />
129133
</Suspense>
@@ -132,6 +136,49 @@ describe('ReactSuspenseWithNoopRenderer', () => {
132136
);
133137
}
134138

139+
const root = ReactNoop.createRoot();
140+
await act(async () => {
141+
root.render(<App />);
142+
await waitForPaint(['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+
function App() {
170+
return (
171+
<>
172+
<Text text="Outer" />
173+
<div>
174+
<Suspense defer fallback={<Text text="Loading..." />}>
175+
<Text text="Inner" />
176+
</Suspense>
177+
</div>
178+
</>
179+
);
180+
}
181+
135182
const root = ReactNoop.createRoot();
136183
await act(async () => {
137184
root.render(<App />);
@@ -164,9 +211,7 @@ describe('ReactSuspenseWithNoopRenderer', () => {
164211
<>
165212
<Text text="Outer" />
166213
<div>
167-
<Suspense
168-
unstable_expectedLoadTime={2000}
169-
fallback={<Text text="Loading..." />}>
214+
<Suspense defer fallback={<Text text="Loading..." />}>
170215
<Text text={`Inner [${count}]`} />
171216
</Suspense>
172217
</div>
@@ -209,9 +254,7 @@ describe('ReactSuspenseWithNoopRenderer', () => {
209254
<>
210255
<Text text="Outer" />
211256
<div>
212-
<Suspense
213-
unstable_expectedLoadTime={2000}
214-
fallback={<Text text="Loading..." />}>
257+
<Suspense defer fallback={<Text text="Loading..." />}>
215258
<AsyncText text="Inner" />
216259
</Suspense>
217260
</div>
@@ -263,14 +306,10 @@ describe('ReactSuspenseWithNoopRenderer', () => {
263306
<>
264307
<Text text="A" />
265308
<div>
266-
<Suspense
267-
unstable_expectedLoadTime={2000}
268-
fallback={<Text text="Loading B..." />}>
309+
<Suspense defer fallback={<Text text="Loading B..." />}>
269310
<Text text="B" />
270311
<div>
271-
<Suspense
272-
unstable_expectedLoadTime={2000}
273-
fallback={<Text text="Loading C..." />}>
312+
<Suspense defer fallback={<Text text="Loading C..." />}>
274313
<Text text="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
import assign from '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
const boundary: 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
const fallback: ReactNodeList = props.fallback;
12761282
const content: ReactNodeList = props.children;
1283+
const defer: boolean = enableCPUSuspense && props.defer === true;
12771284

12781285
const fallbackAbortSet: Set<Task> = new Set();
12791286
let newBoundary: 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+
const trackedPostpones = 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
const suspenseComponentStack = 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-
const trackedPostpones = request.trackedPostpones;
13371347

13381348
const fallbackKeyPath: KeyNode = [
13391349
keyPath[0],
13401350
'Suspense Fallback',
13411351
keyPath[2],
13421352
];
1343-
const fallbackReplayNode: 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+
const fallbackReplayNode: 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
const content: ReactNodeList = props.children;
15821594
const fallback: ReactNodeList = props.fallback;
1595+
const defer: boolean = enableCPUSuspense && props.defer === true;
15831596

15841597
const fallbackAbortSet: Set<Task> = new Set();
15851598
let resumedBoundary: 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
new Set(),
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)