@samitouri / QOS-React-2 / commits / 0ba2f01f74

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.

Sebastian Markbåge committed Nov 5, 2025 at 14:12 UTC 0ba2f01f7470f2f78a2698adf2644b0801ef3c98
5 files changed +132 -32
packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js
+30
@@ -9433,4 +9433,34 @@ Unfortunately that previous paragraph wasn't quite long enough so I'll continue
9433 </html>,
9434 );
9435 });
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 + });
9466 });
packages/react-reconciler/src/ReactFiberBeginWork.js
+14 -1
@@ -325,6 +325,7 @@ export let didWarnAboutReassigningProps: boolean;
325 let didWarnAboutRevealOrder;
326 let didWarnAboutTailOptions;
327 let didWarnAboutClassNameOnViewTransition;
328 +let didWarnAboutExpectedLoadTime = false;
329
330 if (__DEV__) {
331 didWarnAboutBadClass = ({}: {[string]: boolean});
@@ -2458,8 +2459,20 @@ function updateSuspenseComponent(
2459 return bailoutOffscreenComponent(null, primaryChildFragment);
2460 } else if (
2461 enableCPUSuspense &&
2461 - typeof nextProps.unstable_expectedLoadTime === 'number'
2462 + (typeof nextProps.unstable_expectedLoadTime === 'number' ||
2463 + nextProps.defer === true)
2464 ) {
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 + }
2476 // This is a CPU-bound tree. Skip this tree and show a placeholder to
2477 // unblock the surrounding content. Then immediately retry after the
2478 // initial commit.
packages/react-reconciler/src/__tests__/ReactCPUSuspense-test.js
+53 -14
@@ -1,3 +1,5 @@
1 +/* eslint-disable react/jsx-boolean-value */
2 +
3 let React;
4 let ReactNoop;
5 let Scheduler;
@@ -11,6 +13,7 @@ let resolveText;
13 // let rejectText;
14
15 let assertLog;
16 +let assertConsoleErrorDev;
17 let waitForPaint;
18
19 describe('ReactSuspenseWithNoopRenderer', () => {
@@ -26,6 +29,7 @@ describe('ReactSuspenseWithNoopRenderer', () => {
29
30 const InternalTestUtils = require('internal-test-utils');
31 assertLog = InternalTestUtils.assertLog;
32 + assertConsoleErrorDev = InternalTestUtils.assertConsoleErrorDev;
33 waitForPaint = InternalTestUtils.waitForPaint;
34
35 textCache = new Map();
@@ -116,14 +120,14 @@ describe('ReactSuspenseWithNoopRenderer', () => {
120 }
121
122 // @gate enableCPUSuspense
119 - it('skips CPU-bound trees on initial mount', async () => {
123 + it('warns for the old name is used', async () => {
124 function App() {
125 return (
126 <>
127 <Text text="Outer" />
128 <div>
129 <Suspense
126 - unstable_expectedLoadTime={2000}
130 + unstable_expectedLoadTime={1000}
131 fallback={<Text text="Loading..." />}>
132 <Text text="Inner" />
133 </Suspense>
@@ -132,6 +136,49 @@ describe('ReactSuspenseWithNoopRenderer', () => {
136 );
137 }
138
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 +
182 const root = ReactNoop.createRoot();
183 await act(async () => {
184 root.render(<App />);
@@ -164,9 +211,7 @@ describe('ReactSuspenseWithNoopRenderer', () => {
211 <>
212 <Text text="Outer" />
213 <div>
167 - <Suspense
168 - unstable_expectedLoadTime={2000}
169 - fallback={<Text text="Loading..." />}>
214 + <Suspense defer fallback={<Text text="Loading..." />}>
215 <Text text={`Inner [${count}]`} />
216 </Suspense>
217 </div>
@@ -209,9 +254,7 @@ describe('ReactSuspenseWithNoopRenderer', () => {
254 <>
255 <Text text="Outer" />
256 <div>
212 - <Suspense
213 - unstable_expectedLoadTime={2000}
214 - fallback={<Text text="Loading..." />}>
257 + <Suspense defer fallback={<Text text="Loading..." />}>
258 <AsyncText text="Inner" />
259 </Suspense>
260 </div>
@@ -263,14 +306,10 @@ describe('ReactSuspenseWithNoopRenderer', () => {
306 <>
307 <Text text="A" />
308 <div>
266 - <Suspense
267 - unstable_expectedLoadTime={2000}
268 - fallback={<Text text="Loading B..." />}>
309 + <Suspense defer fallback={<Text text="Loading B..." />}>
310 <Text text="B" />
311 <div>
271 - <Suspense
272 - unstable_expectedLoadTime={2000}
273 - fallback={<Text text="Loading C..." />}>
312 + <Suspense defer fallback={<Text text="Loading C..." />}>
313 <Text text="C" />
314 </Suspense>
315 </div>
packages/react-server/src/ReactFizzServer.js
+34 -17
@@ -181,6 +181,7 @@ import {
181 enableViewTransition,
182 enableFizzBlockingRender,
183 enableAsyncDebugInfo,
184 + enableCPUSuspense,
185 } from 'shared/ReactFeatureFlags';
186
187 import assign from 'shared/assign';
@@ -250,6 +251,7 @@ type SuspenseBoundary = {
251 row: null | SuspenseListRow, // the row that this boundary blocks from completing.
252 completedSegments: Array<Segment>, // completed but not yet flushed segments.
253 byteSize: number, // used to determine whether to inline children boundaries.
254 + defer: boolean, // never inline deferred boundaries
255 fallbackAbortableTasks: Set<Task>, // used to cancel task on the fallback if the boundary completes or gets canceled.
256 contentState: HoistableState,
257 fallbackState: HoistableState,
@@ -456,7 +458,9 @@ function isEligibleForOutlining(
458 // The larger this limit is, the more we can save on preparing fallbacks in case we end up
459 // outlining.
460 return (
459 - (boundary.byteSize > 500 || hasSuspenseyContent(boundary.contentState)) &&
461 + (boundary.byteSize > 500 ||
462 + hasSuspenseyContent(boundary.contentState) ||
463 + boundary.defer) &&
464 // For boundaries that can possibly contribute to the preamble we don't want to outline
465 // them regardless of their size since the fallbacks should only be emitted if we've
466 // errored the boundary.
@@ -782,6 +786,7 @@ function createSuspenseBoundary(
786 fallbackAbortableTasks: Set<Task>,
787 contentPreamble: null | Preamble,
788 fallbackPreamble: null | Preamble,
789 + defer: boolean,
790 ): SuspenseBoundary {
791 const boundary: SuspenseBoundary = {
792 status: PENDING,
@@ -791,6 +796,7 @@ function createSuspenseBoundary(
796 row: row,
797 completedSegments: [],
798 byteSize: 0,
799 + defer: defer,
800 fallbackAbortableTasks,
801 errorDigest: null,
802 contentState: createHoistableState(),
@@ -1274,6 +1280,7 @@ function renderSuspenseBoundary(
1280 // in case it ends up generating a large subtree of content.
1281 const fallback: ReactNodeList = props.fallback;
1282 const content: ReactNodeList = props.children;
1283 + const defer: boolean = enableCPUSuspense && props.defer === true;
1284
1285 const fallbackAbortSet: Set<Task> = new Set();
1286 let newBoundary: SuspenseBoundary;
@@ -1284,6 +1291,7 @@ function renderSuspenseBoundary(
1291 fallbackAbortSet,
1292 createPreambleState(),
1293 createPreambleState(),
1294 + defer,
1295 );
1296 } else {
1297 newBoundary = createSuspenseBoundary(
@@ -1292,6 +1300,7 @@ function renderSuspenseBoundary(
1300 fallbackAbortSet,
1301 null,
1302 null,
1303 + defer,
1304 );
1305 }
1306 if (request.trackedPostpones !== null) {
@@ -1327,29 +1336,32 @@ function renderSuspenseBoundary(
1336 // no parent segment so there's nothing to wait on.
1337 contentRootSegment.parentFlushed = true;
1338
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 +
1345 // Stash the original stack frame.
1346 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;
1347
1348 const fallbackKeyPath: KeyNode = [
1349 keyPath[0],
1350 'Suspense Fallback',
1351 keyPath[2],
1352 ];
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 + }
1365
1366 task.blockedSegment = boundarySegment;
1367 task.blockedPreamble = newBoundary.fallbackPreamble;
@@ -1580,6 +1592,7 @@ function replaySuspenseBoundary(
1592
1593 const content: ReactNodeList = props.children;
1594 const fallback: ReactNodeList = props.fallback;
1595 + const defer: boolean = enableCPUSuspense && props.defer === true;
1596
1597 const fallbackAbortSet: Set<Task> = new Set();
1598 let resumedBoundary: SuspenseBoundary;
@@ -1590,6 +1603,7 @@ function replaySuspenseBoundary(
1603 fallbackAbortSet,
1604 createPreambleState(),
1605 createPreambleState(),
1606 + defer,
1607 );
1608 } else {
1609 resumedBoundary = createSuspenseBoundary(
@@ -1598,6 +1612,7 @@ function replaySuspenseBoundary(
1612 fallbackAbortSet,
1613 null,
1614 null,
1615 + defer,
1616 );
1617 }
1618 resumedBoundary.parentFlushed = true;
@@ -4384,6 +4399,7 @@ function abortRemainingSuspenseBoundary(
4399 new Set(),
4400 null,
4401 null,
4402 + false,
4403 );
4404 resumedBoundary.parentFlushed = true;
4405 // We restore the same id of this boundary as was used during prerender.
@@ -5493,7 +5509,8 @@ function flushSegment(
5509 !flushingPartialBoundaries &&
5510 isEligibleForOutlining(request, boundary) &&
5511 (flushedByteSize + boundary.byteSize > request.progressiveChunkSize ||
5496 - hasSuspenseyContent(boundary.contentState))
5512 + hasSuspenseyContent(boundary.contentState) ||
5513 + boundary.defer)
5514 ) {
5515 // Inlining this boundary would make the current sequence being written too large
5516 // and block the parent for too long. Instead, it will be emitted separately so that we
packages/shared/ReactTypes.js
+1
@@ -313,6 +313,7 @@ export type SuspenseProps = {
313
314 unstable_avoidThisFallback?: boolean,
315 unstable_expectedLoadTime?: number,
316 + defer?: boolean,
317 name?: string,
318 };
319