Repository: facebook/react
Pull request: Rename <Suspense unstable_expectedLoadTime> to <Suspense defer> and implement in SSR

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.

Changed files:
filename: packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js
patch: @@ -9433,4 +9433,34 @@ Unfortunately that previous paragraph wasn't quite long enough so I'll continue
       </html>,
     );
   });
+
+  // @gate enableCPUSuspense
+  it('outlines deferred Suspense boundaries', async () => {
+    function Log({text}) {
+      Scheduler.log(text);
+      return text;
+    }
+
+    await act(async () => {
+      renderToPipeableStream(
+        <div>
+          <Suspense defer={true} fallback={<Log text="Waiting" />}>
+            <span>{<Log text="hello" />}</span>
+          </Suspense>
+        </div>,
+      ).pipe(writable);
+      await jest.runAllTimers();
+      const temp = document.createElement('body');
+      temp.innerHTML = buffer;
+      expect(getVisibleChildren(temp)).toEqual(<div>Waiting</div>);
+    });
+
+    assertLog(['Waiting', 'hello']);
+
+    expect(getVisibleChildren(container)).toEqual(
+      <div>
+        <span>hello</span>
+      </div>,
+    );
+  });
 });

filename: packages/react-reconciler/src/ReactFiberBeginWork.js
patch: @@ -325,6 +325,7 @@ export let didWarnAboutReassigningProps: boolean;
 let didWarnAboutRevealOrder;
 let didWarnAboutTailOptions;
 let didWarnAboutClassNameOnViewTransition;
+let didWarnAboutExpectedLoadTime = false;
 
 if (__DEV__) {
   didWarnAboutBadClass = ({}: {[string]: boolean});
@@ -2458,8 +2459,20 @@ function updateSuspenseComponent(
       return bailoutOffscreenComponent(null, primaryChildFragment);
     } else if (
       enableCPUSuspense &&
-      typeof nextProps.unstable_expectedLoadTime === 'number'
+      (typeof nextProps.unstable_expectedLoadTime === 'number' ||
+        nextProps.defer === true)
     ) {
+      if (__DEV__) {
+        if (typeof nextProps.unstable_expectedLoadTime === 'number') {
+          if (!didWarnAboutExpectedLoadTime) {
+            didWarnAboutExpectedLoadTime = true;
+            console.error(
+              '<Suspense unstable_expectedLoadTime={...}> is deprecated. ' +
+                'Use <Suspense defer={true}> instead.',
+            );
+          }
+        }
+      }
       // This is a CPU-bound tree. Skip this tree and show a placeholder to
       // unblock the surrounding content. Then immediately retry after the
       // initial commit.

filename: packages/react-reconciler/src/__tests__/ReactCPUSuspense-test.js
patch: @@ -1,3 +1,5 @@
+/* eslint-disable react/jsx-boolean-value */
+
 let React;
 let ReactNoop;
 let Scheduler;
@@ -11,6 +13,7 @@ let resolveText;
 // let rejectText;
 
 let assertLog;
+let assertConsoleErrorDev;
 let waitForPaint;
 
 describe('ReactSuspenseWithNoopRenderer', () => {
@@ -26,6 +29,7 @@ describe('ReactSuspenseWithNoopRenderer', () => {
 
     const InternalTestUtils = require('internal-test-utils');
     assertLog = InternalTestUtils.assertLog;
+    assertConsoleErrorDev = InternalTestUtils.assertConsoleErrorDev;
     waitForPaint = InternalTestUtils.waitForPaint;
 
     textCache = new Map();
@@ -116,14 +120,14 @@ describe('ReactSuspenseWithNoopRenderer', () => {
   }
 
   // @gate enableCPUSuspense
-  it('skips CPU-bound trees on initial mount', async () => {
+  it('warns for the old name is used', async () => {
     function App() {
       return (
         <>
           <Text text="Outer" />
           <div>
             <Suspense
-              unstable_expectedLoadTime={2000}
+              unstable_expectedLoadTime={1000}
               fallback={<Text text="Loading..." />}>
               <Text text="Inner" />
             </Suspense>
@@ -132,6 +136,49 @@ describe('ReactSuspenseWithNoopRenderer', () => {
       );
     }
 
+    const root = ReactNoop.createRoot();
+    await act(async () => {
+      root.render(<App />);
+      await waitForPaint(['Outer', 'Loading...']);
+      assertConsoleErrorDev([
+        '<Suspense unstable_expectedLoadTime={...}> is deprecated. ' +
+          'Use <Suspense defer={true}> instead.' +
+          '\n    in Suspense (at **)' +
+          '\n    in App (at **)',
+      ]);
+      expect(root).toMatchRenderedOutput(
+        <>
+          Outer
+          <div>Loading...</div>
+        </>,
+      );
+    });
+
+    // Inner contents finish in separate commit from outer
+    assertLog(['Inner']);
+    expect(root).toMatchRenderedOutput(
+      <>
+        Outer
+        <div>Inner</div>
+      </>,
+    );
+  });
+
+  // @gate enableCPUSuspense
+  it('skips CPU-bound trees on initial mount', async () => {
+    function App() {
+      return (
+        <>
+          <Text text="Outer" />
+          <div>
+            <Suspense defer fallback={<Text text="Loading..." />}>
+              <Text text="Inner" />
+            </Suspense>
+          </div>
+        </>
+      );
+    }
+
     const root = ReactNoop.createRoot();
     await act(async () => {
       root.render(<App />);
@@ -164,9 +211,7 @@ describe('ReactSuspenseWithNoopRenderer', () => {
         <>
           <Text text="Outer" />
           <div>
-            <Suspense
-              unstable_expectedLoadTime={2000}
-              fallback={<Text text="Loading..." />}>
+            <Suspense defer fallback={<Text text="Loading..." />}>
               <Text text={`Inner [${count}]`} />
             </Suspense>
           </div>
@@ -209,9 +254,7 @@ describe('ReactSuspenseWithNoopRenderer', () => {
         <>
           <Text text="Outer" />
           <div>
-            <Suspense
-              unstable_expectedLoadTime={2000}
-              fallback={<Text text="Loading..." />}>
+            <Suspense defer fallback={<Text text="Loading..." />}>
               <AsyncText text="Inner" />
             </Suspense>
           </div>
@@ -263,14 +306,10 @@ describe('ReactSuspenseWithNoopRenderer', () => {
         <>
           <Text text="A" />
           <div>
-            <Suspense
-              unstable_expectedLoadTime={2000}
-              fallback={<Text text="Loading B..." />}>
+            <Suspense defer fallback={<Text text="Loading B..." />}>
               <Text text="B" />
               <div>
-                <Suspense
-                  unstable_expectedLoadTime={2000}
-                  fallback={<Text text="Loading C..." />}>
+                <Suspense defer fallback={<Text text="Loading C..." />}>
                   <Text text="C" />
                 </Suspense>
               </div>

filename: packages/react-server/src/ReactFizzServer.js
patch: @@ -181,6 +181,7 @@ import {
   enableViewTransition,
   enableFizzBlockingRender,
   enableAsyncDebugInfo,
+  enableCPUSuspense,
 } from 'shared/ReactFeatureFlags';
 
 import assign from 'shared/assign';
@@ -250,6 +251,7 @@ type SuspenseBoundary = {
   row: null | SuspenseListRow, // the row that this boundary blocks from completing.
   completedSegments: Array<Segment>, // completed but not yet flushed segments.
   byteSize: number, // used to determine whether to inline children boundaries.
+  defer: boolean, // never inline deferred boundaries
   fallbackAbortableTasks: Set<Task>, // used to cancel task on the fallback if the boundary completes or gets canceled.
   contentState: HoistableState,
   fallbackState: HoistableState,
@@ -456,7 +458,9 @@ function isEligibleForOutlining(
   // The larger this limit is, the more we can save on preparing fallbacks in case we end up
   // outlining.
   return (
-    (boundary.byteSize > 500 || hasSuspenseyContent(boundary.contentState)) &&
+    (boundary.byteSize > 500 ||
+      hasSuspenseyContent(boundary.contentState) ||
+      boundary.defer) &&
     // For boundaries that can possibly contribute to the preamble we don't want to outline
     // them regardless of their size since the fallbacks should only be emitted if we've
     // errored the boundary.
@@ -782,6 +786,7 @@ function createSuspenseBoundary(
   fallbackAbortableTasks: Set<Task>,
   contentPreamble: null | Preamble,
   fallbackPreamble: null | Preamble,
+  defer: boolean,
 ): SuspenseBoundary {
   const boundary: SuspenseBoundary = {
     status: PENDING,
@@ -791,6 +796,7 @@ function createSuspenseBoundary(
     row: row,
     completedSegments: [],
     byteSize: 0,
+    defer: defer,
     fallbackAbortableTasks,
     errorDigest: null,
     contentState: createHoistableState(),
@@ -1274,6 +1280,7 @@ function renderSuspenseBoundary(
   // in case it ends up generating a large subtree of content.
   const fallback: ReactNodeList = props.fallback;
   const content: ReactNodeList = props.children;
+  const defer: boolean = enableCPUSuspense && props.defer === true;
 
   const fallbackAbortSet: Set<Task> = new Set();
   let newBoundary: SuspenseBoundary;
@@ -1284,6 +1291,7 @@ function renderSuspenseBoundary(
       fallbackAbortSet,
       createPreambleState(),
       createPreambleState(),
+      defer,
     );
   } else {
     newBoundary = createSuspenseBoundary(
@@ -1292,6 +1300,7 @@ function renderSuspenseBoundary(
       fallbackAbortSet,
       null,
       null,
+      defer,
     );
   }
   if (request.trackedPostpones !== null) {
@@ -1327,29 +1336,32 @@ function renderSuspenseBoundary(
   // no parent segment so there's nothing to wait on.
   contentRootSegment.parentFlushed = true;
 
-  if (request.trackedPostpones !== null) {
+  const trackedPostpones = request.trackedPostpones;
+  if (trackedPostpones !== null || defer) {
+    // This is a prerender or deferred boundary. In this mode we want to render the fallback synchronously
+    // and schedule the content to render later. This is the opposite of what we do during a normal render
+    // where we try to skip rendering the fallback if the content itself can render synchronously
+
     // Stash the original stack frame.
     const suspenseComponentStack = task.componentStack;
-    // This is a prerender. In this mode we want to render the fallback synchronously and schedule
-    // the content to render later. This is the opposite of what we do during a normal render
-    // where we try to skip rendering the fallback if the content itself can render synchronously
-    const trackedPostpones = request.trackedPostpones;
 
     const fallbackKeyPath: KeyNode = [
       keyPath[0],
       'Suspense Fallback',
       keyPath[2],
     ];
-    const fallbackReplayNode: ReplayNode = [
-      fallbackKeyPath[1],
-      fallbackKeyPath[2],
-      ([]: Array<ReplayNode>),
-      null,
-    ];
-    trackedPostpones.workingMap.set(fallbackKeyPath, fallbackReplayNode);
-    // We are rendering the fallback before the boundary content so we keep track of
-    // the fallback replay node until we determine if the primary content suspends
-    newBoundary.trackedFallbackNode = fallbackReplayNode;
+    if (trackedPostpones !== null) {
+      const fallbackReplayNode: ReplayNode = [
+        fallbackKeyPath[1],
+        fallbackKeyPath[2],
+        ([]: Array<ReplayNode>),
+        null,
+      ];
+      trackedPostpones.workingMap.set(fallbackKeyPath, fallbackReplayNode);
+      // We are rendering the fallback before the boundary content so we keep track of
+      // the fallback replay node until we determine if the primary content suspends
+      newBoundary.trackedFallbackNode = fallbackReplayNode;
+    }
 
     task.blockedSegment = boundarySegment;
     task.blockedPreamble = newBoundary.fallbackPreamble;
@@ -1580,6 +1592,7 @@ function replaySuspenseBoundary(
 
   const content: ReactNodeList = props.children;
   const fallback: ReactNodeList = props.fallback;
+  const defer: boolean = enableCPUSuspense && props.defer === true;
 
   const fallbackAbortSet: Set<Task> = new Set();
   let resumedBoundary: SuspenseBoundary;
@@ -1590,6 +1603,7 @@ function replaySuspenseBoundary(
       fallbackAbortSet,
       createPreambleState(),
       createPreambleState(),
+      defer,
     );
   } else {
     resumedBoundary = createSuspenseBoundary(
@@ -1598,6 +1612,7 @@ function replaySuspenseBoundary(
       fallbackAbortSet,
       null,
       null,
+      defer,
     );
   }
   resumedBoundary.parentFlushed = true;
@@ -4384,6 +4399,7 @@ function abortRemainingSuspenseBoundary(
     new Set(),
     null,
     null,
+    false,
   );
   resumedBoundary.parentFlushed = true;
   // We restore the same id of this boundary as was used during prerender.
@@ -5493,7 +5509,8 @@ function flushSegment(
     !flushingPartialBoundaries &&
     isEligibleForOutlining(request, boundary) &&
     (flushedByteSize + boundary.byteSize > request.progressiveChunkSize ||
-      hasSuspenseyContent(boundary.contentState))
+      hasSuspenseyContent(boundary.contentState) ||
+      boundary.defer)
   ) {
     // Inlining this boundary would make the current sequence being written too large
     // and block the parent for too long. Instead, it will be emitted separately so that we

filename: packages/shared/ReactTypes.js
patch: @@ -313,6 +313,7 @@ export type SuspenseProps = {
 
   unstable_avoidThisFallback?: boolean,
   unstable_expectedLoadTime?: number,
+  defer?: boolean,
   name?: string,
 };