Repository: facebook/react
Pull request: [Old server renderer] Retry error on client

We're still in the process of migrating to the Fizz server renderer. In the meantime, this makes the error semantics on the old server renderer match the behavior of the new one: if an error is thrown, it triggers a Suspense fallback, just as if it suspended (this part was already implemented). Then the errored tree is retried on the client, where it may recover and finish successfully.

Changed files:
filename: packages/react-dom/src/__tests__/ReactDOMServerPartialHydration-test.internal.js
patch: @@ -2671,4 +2671,32 @@ describe('ReactDOMServerPartialHydration', () => {
     expect(ref.current).toBe(span);
     expect(ref.current.innerHTML).toBe('Hidden child');
   });
+
+  it('should retry on client if something throws', async () => {
+    let isServer = true;
+    function ThrowsOnServerOnly() {
+      if (isServer) {
+        throw new Error('Oops!');
+      }
+      return 'Yay!';
+    }
+
+    function App() {
+      return (
+        <React.Suspense fallback="Loading...">
+          <ThrowsOnServerOnly />
+        </React.Suspense>
+      );
+    }
+
+    const element = document.createElement('div');
+    element.innerHTML = ReactDOMServer.renderToString(<App />);
+    expect(element.textContent).toBe('Loading...');
+
+    isServer = false;
+    await act(async () => {
+      ReactDOM.hydrateRoot(element, <App />);
+    });
+    expect(element.textContent).toBe('Yay!');
+  });
 });

filename: packages/react-dom/src/__tests__/ReactDOMServerSuspense-test.internal.js
patch: @@ -115,6 +115,20 @@ describe('ReactDOMServerSuspense', () => {
     expect(getVisibleChildren(c)).toEqual(<div>Fallback</div>);
   });
 
+  it('should render the fallback when an error is thrown', async () => {
+    function Throws() {
+      throw new Error('Oops!');
+    }
+    const c = await serverRender(
+      <div>
+        <React.Suspense fallback={<Text text="Fallback" />}>
+          <Throws />
+        </React.Suspense>
+      </div>,
+    );
+    expect(getVisibleChildren(c)).toEqual(<div>Fallback</div>);
+  });
+
   it('should work with nested suspense components', async () => {
     const c = await serverRender(
       <div>

filename: packages/react-dom/src/server/ReactPartialRenderer.js
patch: @@ -969,22 +969,33 @@ class ReactDOMServerRenderer {
         try {
           outBuffer += this.render(child, frame.context, frame.domNamespace);
         } catch (err) {
-          if (err != null && typeof err.then === 'function') {
-            if (enableSuspenseServerRenderer) {
-              invariant(
-                this.suspenseDepth > 0,
-                // TODO: include component name. This is a bit tricky with current factoring.
-                'A React component suspended while rendering, but no fallback UI was specified.\n' +
-                  '\n' +
-                  'Add a <Suspense fallback=...> component higher in the tree to ' +
-                  'provide a loading indicator or placeholder to display.',
-              );
+          if (enableSuspenseServerRenderer) {
+            if (this.suspenseDepth > 0) {
+              // Regardless of whether this is an error or a suspense thenable,
+              // trigger the nearest Suspense boundary. We'll try to render this
+              // tree again on the client.
               suspended = true;
             } else {
-              invariant(false, 'ReactDOMServer does not yet support Suspense.');
+              // Missing a Suspense boundary. This is a fatal error.
+              if (err != null && typeof err.then === 'function') {
+                invariant(
+                  false,
+                  // TODO: include component name. This is a bit tricky with current factoring.
+                  'A React component suspended while rendering, but no fallback UI was specified.\n' +
+                    '\n' +
+                    'Add a <Suspense fallback=...> component higher in the tree to ' +
+                    'provide a loading indicator or placeholder to display.',
+                );
+              } else {
+                throw err;
+              }
             }
           } else {
-            throw err;
+            if (err != null && typeof err.then === 'function') {
+              invariant(false, 'ReactDOMServer does not yet support Suspense.');
+            } else {
+              throw err;
+            }
           }
         } finally {
           if (__DEV__) {