Repository: facebook/react
Pull request: Test showing mismatches after suspending force fallbacks to be shown

While investigating https://github.com/facebook/react/issues/28285 I found a possible bug in handling Suspense and mismatches. As the tests show, if the first sibling in a boundary suspends, and the second has a mismatch, we will NOT show a fallback. If the first sibling is a mismatch, and the second sibling suspends, we WILL show a fallback.

[Here's a stackbliz showing the behavior on Canary](https://stackblitz.com/edit/stackblitz-starters-bh3snf?file=src%2Fstyle.css,public%2Findex.html,src%2Findex.tsx).

This breakage was introduced by: https://github.com/facebook/react/pull/26380. Before this PR, we would not show a fallback in either case. That PR makes it so that we don't pre-render siblings of suspended trees, so presumably, whatever detection we had to avoid fallbacks on mismatches, requires knowing there's a mismatch in the tree when we suspend.

Changed files:
filename: packages/react-dom/src/__tests__/ReactDOMServerPartialHydration-test.internal.js
patch: @@ -366,6 +366,775 @@ describe('ReactDOMServerPartialHydration', () => {
     }
   });
 
+  it('does not show a fallback if mismatch is after suspending', async () => {
+    // We can't use the toErrorDev helper here because this is async.
+    const originalConsoleError = console.error;
+    const mockError = jest.fn();
+    console.error = (...args) => {
+      mockError(...args.map(normalizeCodeLocInfo));
+    };
+    let client = false;
+    let suspend = false;
+    let resolve;
+    const promise = new Promise(resolvePromise => {
+      resolve = () => {
+        suspend = false;
+        resolvePromise();
+      };
+    });
+    function Child() {
+      if (suspend) {
+        Scheduler.log('Suspend');
+        throw promise;
+      } else {
+        Scheduler.log('Hello');
+        return 'Hello';
+      }
+    }
+    function Component({shouldMismatch}) {
+      Scheduler.log('Component');
+      if (shouldMismatch && client) {
+        return <article>Mismatch</article>;
+      }
+      return <div>Component</div>;
+    }
+    function Fallback() {
+      Scheduler.log('Fallback');
+      return 'Loading...';
+    }
+    function App() {
+      return (
+        <Suspense fallback={<Fallback />}>
+          <Child />
+          <Component shouldMismatch={true} />
+        </Suspense>
+      );
+    }
+    try {
+      const finalHTML = ReactDOMServer.renderToString(<App />);
+      const container = document.createElement('section');
+      container.innerHTML = finalHTML;
+      assertLog(['Hello', 'Component']);
+
+      expect(container.innerHTML).toBe(
+        '<!--$-->Hello<div>Component</div><!--/$-->',
+      );
+
+      suspend = true;
+      client = true;
+
+      ReactDOMClient.hydrateRoot(container, <App />, {
+        onRecoverableError(error) {
+          Scheduler.log(error.message);
+        },
+      });
+      await waitForAll(['Suspend']);
+      jest.runAllTimers();
+
+      // !! Unchanged, continue showing server content while suspended.
+      expect(container.innerHTML).toBe(
+        '<!--$-->Hello<div>Component</div><!--/$-->',
+      );
+
+      suspend = false;
+      resolve();
+      await promise;
+      await waitForAll([
+        // first pass, mismatches at end
+        'Hello',
+        'Component',
+        'Hello',
+        'Component',
+        'Hydration failed because the initial UI does not match what was rendered on the server.',
+        'There was an error while hydrating this Suspense boundary. Switched to client rendering.',
+      ]);
+      jest.runAllTimers();
+
+      // Client rendered - suspense comment nodes removed.
+      expect(container.innerHTML).toBe('Hello<article>Mismatch</article>');
+      if (__DEV__) {
+        const secondToLastCall =
+          mockError.mock.calls[mockError.mock.calls.length - 2];
+        expect(secondToLastCall).toEqual([
+          'Warning: Expected server HTML to contain a matching <%s> in <%s>.%s',
+          'article',
+          'section',
+          '\n' +
+            '    in article (at **)\n' +
+            '    in Component (at **)\n' +
+            '    in Suspense (at **)\n' +
+            '    in App (at **)',
+        ]);
+      }
+    } finally {
+      console.error = originalConsoleError;
+    }
+  });
+
+  it('does not show a fallback if mismatch is child of suspended component', async () => {
+    // We can't use the toErrorDev helper here because this is async.
+    const originalConsoleError = console.error;
+    const mockError = jest.fn();
+    console.error = (...args) => {
+      mockError(...args.map(normalizeCodeLocInfo));
+    };
+    let client = false;
+    let suspend = false;
+    let resolve;
+    const promise = new Promise(resolvePromise => {
+      resolve = () => {
+        suspend = false;
+        resolvePromise();
+      };
+    });
+    function Child({children}) {
+      if (suspend) {
+        Scheduler.log('Suspend');
+        throw promise;
+      } else {
+        Scheduler.log('Hello');
+        return <div>{children}</div>;
+      }
+    }
+    function Component({shouldMismatch}) {
+      Scheduler.log('Component');
+      if (shouldMismatch && client) {
+        return <article>Mismatch</article>;
+      }
+      return <div>Component</div>;
+    }
+    function Fallback() {
+      Scheduler.log('Fallback');
+      return 'Loading...';
+    }
+    function App() {
+      return (
+        <Suspense fallback={<Fallback />}>
+          <Child>
+            <Component shouldMismatch={true} />
+          </Child>
+        </Suspense>
+      );
+    }
+    try {
+      const finalHTML = ReactDOMServer.renderToString(<App />);
+      const container = document.createElement('section');
+      container.innerHTML = finalHTML;
+      assertLog(['Hello', 'Component']);
+
+      expect(container.innerHTML).toBe(
+        '<!--$--><div><div>Component</div></div><!--/$-->',
+      );
+
+      suspend = true;
+      client = true;
+
+      ReactDOMClient.hydrateRoot(container, <App />, {
+        onRecoverableError(error) {
+          Scheduler.log(error.message);
+        },
+      });
+      await waitForAll(['Suspend']);
+      jest.runAllTimers();
+
+      // !! Unchanged, continue showing server content while suspended.
+      expect(container.innerHTML).toBe(
+        '<!--$--><div><div>Component</div></div><!--/$-->',
+      );
+
+      suspend = false;
+      resolve();
+      await promise;
+      await waitForAll([
+        // first pass, mismatches at end
+        'Hello',
+        'Component',
+        'Hello',
+        'Component',
+        'Hydration failed because the initial UI does not match what was rendered on the server.',
+        'There was an error while hydrating this Suspense boundary. Switched to client rendering.',
+      ]);
+      jest.runAllTimers();
+
+      // Client rendered - suspense comment nodes removed
+      expect(container.innerHTML).toBe(
+        '<div><article>Mismatch</article></div>',
+      );
+      if (__DEV__) {
+        const secondToLastCall =
+          mockError.mock.calls[mockError.mock.calls.length - 2];
+        expect(secondToLastCall).toEqual([
+          'Warning: Expected server HTML to contain a matching <%s> in <%s>.%s',
+          'article',
+          'div',
+          '\n' +
+            '    in article (at **)\n' +
+            '    in Component (at **)\n' +
+            '    in div (at **)\n' +
+            '    in Child (at **)\n' +
+            '    in Suspense (at **)\n' +
+            '    in App (at **)',
+        ]);
+      }
+    } finally {
+      console.error = originalConsoleError;
+    }
+  });
+
+  it('does not show a fallback if mismatch is parent and first child suspends', async () => {
+    // We can't use the toErrorDev helper here because this is async.
+    const originalConsoleError = console.error;
+    const mockError = jest.fn();
+    console.error = (...args) => {
+      mockError(...args.map(normalizeCodeLocInfo));
+    };
+    let client = false;
+    let suspend = false;
+    let resolve;
+    const promise = new Promise(resolvePromise => {
+      resolve = () => {
+        suspend = false;
+        resolvePromise();
+      };
+    });
+    function Child({children}) {
+      if (suspend) {
+        Scheduler.log('Suspend');
+        throw promise;
+      } else {
+        Scheduler.log('Hello');
+        return <div>{children}</div>;
+      }
+    }
+    function Component({shouldMismatch, children}) {
+      Scheduler.log('Component');
+      if (shouldMismatch && client) {
+        return (
+          <div>
+            {children}
+            <article>Mismatch</article>
+          </div>
+        );
+      }
+      return (
+        <div>
+          {children}
+          <div>Component</div>
+        </div>
+      );
+    }
+    function Fallback() {
+      Scheduler.log('Fallback');
+      return 'Loading...';
+    }
+    function App() {
+      return (
+        <Suspense fallback={<Fallback />}>
+          <Component shouldMismatch={true}>
+            <Child />
+          </Component>
+        </Suspense>
+      );
+    }
+    try {
+      const finalHTML = ReactDOMServer.renderToString(<App />);
+      const container = document.createElement('section');
+      container.innerHTML = finalHTML;
+      assertLog(['Component', 'Hello']);
+
+      expect(container.innerHTML).toBe(
+        '<!--$--><div><div></div><div>Component</div></div><!--/$-->',
+      );
+
+      suspend = true;
+      client = true;
+
+      ReactDOMClient.hydrateRoot(container, <App />, {
+        onRecoverableError(error) {
+          Scheduler.log(error.message);
+        },
+      });
+      await waitForAll(['Component', 'Suspend']);
+      jest.runAllTimers();
+
+      // !! Unchanged, continue showing server content while suspended.
+      expect(container.innerHTML).toBe(
+        '<!--$--><div><div></div><div>Component</div></div><!--/$-->',
+      );
+
+      suspend = false;
+      resolve();
+      await promise;
+      await waitForAll([
+        // first pass, mismatches at end
+        'Component',
+        'Hello',
+        'Component',
+        'Hello',
+        'Hydration failed because the initial UI does not match what was rendered on the server.',
+        'There was an error while hydrating this Suspense boundary. Switched to client rendering.',
+      ]);
+      jest.runAllTimers();
+
+      // Client rendered - suspense comment nodes removed
+      expect(container.innerHTML).toBe(
+        '<div><div></div><article>Mismatch</article></div>',
+      );
+      if (__DEV__) {
+        const secondToLastCall =
+          mockError.mock.calls[mockError.mock.calls.length - 2];
+        expect(secondToLastCall).toEqual([
+          'Warning: Expected server HTML to contain a matching <%s> in <%s>.%s',
+          'article',
+          'div',
+          '\n' +
+            '    in article (at **)\n' +
+            '    in div (at **)\n' +
+            '    in Component (at **)\n' +
+            '    in Suspense (at **)\n' +
+            '    in App (at **)',
+        ]);
+      }
+    } finally {
+      console.error = originalConsoleError;
+    }
+  });
+
+  it('does show a fallback if mismatch is parent and second child suspends', async () => {
+    // We can't use the toErrorDev helper here because this is async.
+    const originalConsoleError = console.error;
+    const mockError = jest.fn();
+    console.error = (...args) => {
+      mockError(...args.map(normalizeCodeLocInfo));
+    };
+    let client = false;
+    let suspend = false;
+    let resolve;
+    const promise = new Promise(resolvePromise => {
+      resolve = () => {
+        suspend = false;
+        resolvePromise();
+      };
+    });
+    function Child({children}) {
+      if (suspend) {
+        Scheduler.log('Suspend');
+        throw promise;
+      } else {
+        Scheduler.log('Hello');
+        return <div>{children}</div>;
+      }
+    }
+    function Component({shouldMismatch, children}) {
+      Scheduler.log('Component');
+      if (shouldMismatch && client) {
+        return (
+          <div>
+            <article>Mismatch</article>
+            {children}
+          </div>
+        );
+      }
+      return (
+        <div>
+          <div>Component</div>
+          {children}
+        </div>
+      );
+    }
+    function Fallback() {
+      Scheduler.log('Fallback');
+      return 'Loading...';
+    }
+    function App() {
+      return (
+        <Suspense fallback={<Fallback />}>
+          <Component shouldMismatch={true}>
+            <Child />
+          </Component>
+        </Suspense>
+      );
+    }
+    try {
+      const finalHTML = ReactDOMServer.renderToString(<App />);
+      const container = document.createElement('section');
+      container.innerHTML = finalHTML;
+      assertLog(['Component', 'Hello']);
+
+      expect(container.innerHTML).toBe(
+        '<!--$--><div><div>Component</div><div></div></div><!--/$-->',
+      );
+
+      suspend = true;
+      client = true;
+
+      ReactDOMClient.hydrateRoot(container, <App />, {
+        onRecoverableError(error) {
+          Scheduler.log(error.message);
+        },
+      });
+      await waitForAll([
+        'Component',
+        'Component',
+        'Suspend',
+        'Fallback',
+        'Hydration failed because the initial UI does not match what was rendered on the server.',
+        'There was an error while hydrating this Suspense boundary. Switched to client rendering.',
+      ]);
+      jest.runAllTimers();
+
+      // !! Client switches to suspense fallback.
+      expect(container.innerHTML).toBe('Loading...');
+
+      suspend = false;
+      resolve();
+      await promise;
+      await waitForAll(['Component', 'Hello']);
+      jest.runAllTimers();
+
+      // Client rendered - suspense comment nodes removed
+      expect(container.innerHTML).toBe(
+        '<div><article>Mismatch</article><div></div></div>',
+      );
+      if (__DEV__) {
+        const secondToLastCall =
+          mockError.mock.calls[mockError.mock.calls.length - 2];
+        expect(secondToLastCall).toEqual([
+          'Warning: Expected server HTML to contain a matching <%s> in <%s>.%s',
+          'article',
+          'div',
+          '\n' +
+            '    in article (at **)\n' +
+            '    in div (at **)\n' +
+            '    in Component (at **)\n' +
+            '    in Suspense (at **)\n' +
+            '    in App (at **)',
+        ]);
+      }
+    } finally {
+      console.error = originalConsoleError;
+    }
+  });
+
+  it('does show a fallback if mismatch is in parent element only', async () => {
+    // We can't use the toErrorDev helper here because this is async.
+    const originalConsoleError = console.error;
+    const mockError = jest.fn();
+    console.error = (...args) => {
+      mockError(...args.map(normalizeCodeLocInfo));
+    };
+    let client = false;
+    let suspend = false;
+    let resolve;
+    const promise = new Promise(resolvePromise => {
+      resolve = () => {
+        suspend = false;
+        resolvePromise();
+      };
+    });
+    function Child({children}) {
+      if (suspend) {
+        Scheduler.log('Suspend');
+        throw promise;
+      } else {
+        Scheduler.log('Hello');
+        return <div>{children}</div>;
+      }
+    }
+    function Component({shouldMismatch, children}) {
+      Scheduler.log('Component');
+      if (shouldMismatch && client) {
+        return <article>{children}</article>;
+      }
+      return <div>{children}</div>;
+    }
+    function Fallback() {
+      Scheduler.log('Fallback');
+      return 'Loading...';
+    }
+    function App() {
+      return (
+        <Suspense fallback={<Fallback />}>
+          <Component shouldMismatch={true}>
+            <Child />
+          </Component>
+        </Suspense>
+      );
+    }
+    try {
+      const finalHTML = ReactDOMServer.renderToString(<App />);
+      const container = document.createElement('section');
+      container.innerHTML = finalHTML;
+      assertLog(['Component', 'Hello']);
+
+      expect(container.innerHTML).toBe(
+        '<!--$--><div><div></div></div><!--/$-->',
+      );
+
+      suspend = true;
+      client = true;
+
+      ReactDOMClient.hydrateRoot(container, <App />, {
+        onRecoverableError(error) {
+          Scheduler.log(error.message);
+        },
+      });
+      await waitForAll([
+        'Component',
+        'Component',
+        'Suspend',
+        'Fallback',
+        'Hydration failed because the initial UI does not match what was rendered on the server.',
+        'There was an error while hydrating this Suspense boundary. Switched to client rendering.',
+      ]);
+      jest.runAllTimers();
+
+      // !! Client switches to suspense fallback.
+      expect(container.innerHTML).toBe('Loading...');
+
+      suspend = false;
+      resolve();
+      await promise;
+      await waitForAll(['Component', 'Hello']);
+      jest.runAllTimers();
+
+      // Client rendered - suspense comment nodes removed
+      expect(container.innerHTML).toBe('<article><div></div></article>');
+      if (__DEV__) {
+        const secondToLastCall =
+          mockError.mock.calls[mockError.mock.calls.length - 2];
+        expect(secondToLastCall).toEqual([
+          'Warning: Expected server HTML to contain a matching <%s> in <%s>.%s',
+          'article',
+          'section',
+          '\n' +
+            '    in article (at **)\n' +
+            '    in Component (at **)\n' +
+            '    in Suspense (at **)\n' +
+            '    in App (at **)',
+        ]);
+      }
+    } finally {
+      console.error = originalConsoleError;
+    }
+  });
+
+  it('does show a fallback if mismatch is before suspending', async () => {
+    // We can't use the toErrorDev helper here because this is async.
+    const originalConsoleError = console.error;
+    const mockError = jest.fn();
+    console.error = (...args) => {
+      mockError(...args.map(normalizeCodeLocInfo));
+    };
+    let client = false;
+    let suspend = false;
+    let resolve;
+    const promise = new Promise(resolvePromise => {
+      resolve = () => {
+        suspend = false;
+        resolvePromise();
+      };
+    });
+    function Child() {
+      if (suspend) {
+        Scheduler.log('Suspend');
+        throw promise;
+      } else {
+        Scheduler.log('Hello');
+        return 'Hello';
+      }
+    }
+    function Component({shouldMismatch}) {
+      Scheduler.log('Component');
+      if (shouldMismatch && client) {
+        return <article>Mismatch</article>;
+      }
+      return <div>Component</div>;
+    }
+    function Fallback() {
+      Scheduler.log('Fallback');
+      return 'Loading...';
+    }
+    function App() {
+      return (
+        <Suspense fallback={<Fallback />}>
+          <Component shouldMismatch={true} />
+          <Child />
+        </Suspense>
+      );
+    }
+    try {
+      const finalHTML = ReactDOMServer.renderToString(<App />);
+      const container = document.createElement('section');
+      container.innerHTML = finalHTML;
+      assertLog(['Component', 'Hello']);
+
+      expect(container.innerHTML).toBe(
+        '<!--$--><div>Component</div>Hello<!--/$-->',
+      );
+
+      suspend = true;
+      client = true;
+
+      ReactDOMClient.hydrateRoot(container, <App />, {
+        onRecoverableError(error) {
+          Scheduler.log(error.message);
+        },
+      });
+      await waitForAll([
+        'Component',
+        'Component',
+        'Suspend',
+        'Fallback',
+        'Hydration failed because the initial UI does not match what was rendered on the server.',
+        'There was an error while hydrating this Suspense boundary. Switched to client rendering.',
+      ]);
+      jest.runAllTimers();
+
+      // !! Client switches to suspense fallback.
+      expect(container.innerHTML).toBe('Loading...');
+
+      suspend = false;
+      resolve();
+      await promise;
+      await waitForAll([
+        // first pass, mismatches at end
+        'Component',
+        'Hello',
+      ]);
+      jest.runAllTimers();
+
+      // Client rendered - suspense comment nodes removed
+      expect(container.innerHTML).toBe('<article>Mismatch</article>Hello');
+      if (__DEV__) {
+        const secondToLastCall =
+          mockError.mock.calls[mockError.mock.calls.length - 2];
+        expect(secondToLastCall).toEqual([
+          'Warning: Expected server HTML to contain a matching <%s> in <%s>.%s',
+          'article',
+          'section',
+          '\n' +
+            '    in article (at **)\n' +
+            '    in Component (at **)\n' +
+            '    in Suspense (at **)\n' +
+            '    in App (at **)',
+        ]);
+      }
+    } finally {
+      console.error = originalConsoleError;
+    }
+  });
+
+  it('does show a fallback if mismatch is before suspending in a child', async () => {
+    // We can't use the toErrorDev helper here because this is async.
+    const originalConsoleError = console.error;
+    const mockError = jest.fn();
+    console.error = (...args) => {
+      mockError(...args.map(normalizeCodeLocInfo));
+    };
+    let client = false;
+    let suspend = false;
+    let resolve;
+    const promise = new Promise(resolvePromise => {
+      resolve = () => {
+        suspend = false;
+        resolvePromise();
+      };
+    });
+    function Child() {
+      if (suspend) {
+        Scheduler.log('Suspend');
+        throw promise;
+      } else {
+        Scheduler.log('Hello');
+        return 'Hello';
+      }
+    }
+    function Component({shouldMismatch}) {
+      Scheduler.log('Component');
+      if (shouldMismatch && client) {
+        return <article>Mismatch</article>;
+      }
+      return <div>Component</div>;
+    }
+    function Fallback() {
+      Scheduler.log('Fallback');
+      return 'Loading...';
+    }
+    function App() {
+      return (
+        <Suspense fallback={<Fallback />}>
+          <Component shouldMismatch={true} />
+          <div>
+            <Child />
+          </div>
+        </Suspense>
+      );
+    }
+    try {
+      const finalHTML = ReactDOMServer.renderToString(<App />);
+      const container = document.createElement('section');
+      container.innerHTML = finalHTML;
+      assertLog(['Component', 'Hello']);
+
+      expect(container.innerHTML).toBe(
+        '<!--$--><div>Component</div><div>Hello</div><!--/$-->',
+      );
+
+      suspend = true;
+      client = true;
+
+      ReactDOMClient.hydrateRoot(container, <App />, {
+        onRecoverableError(error) {
+          Scheduler.log(error.message);
+        },
+      });
+      await waitForAll([
+        'Component',
+        'Component',
+        'Suspend',
+        'Fallback',
+        'Hydration failed because the initial UI does not match what was rendered on the server.',
+        'There was an error while hydrating this Suspense boundary. Switched to client rendering.',
+      ]);
+      jest.runAllTimers();
+
+      // !! Client switches to suspense fallback.
+      expect(container.innerHTML).toBe('Loading...');
+
+      suspend = false;
+      resolve();
+      await promise;
+      await waitForAll([
+        // first pass, mismatches at end
+        'Component',
+        'Hello',
+      ]);
+      jest.runAllTimers();
+
+      // Client rendered - suspense comment nodes removed.
+      expect(container.innerHTML).toBe(
+        '<article>Mismatch</article><div>Hello</div>',
+      );
+      if (__DEV__) {
+        const secondToLastCall =
+          mockError.mock.calls[mockError.mock.calls.length - 2];
+        expect(secondToLastCall).toEqual([
+          'Warning: Expected server HTML to contain a matching <%s> in <%s>.%s',
+          'article',
+          'section',
+          '\n' +
+            '    in article (at **)\n' +
+            '    in Component (at **)\n' +
+            '    in Suspense (at **)\n' +
+            '    in App (at **)',
+        ]);
+      }
+    } finally {
+      console.error = originalConsoleError;
+    }
+  });
+
   it('calls the hydration callbacks after hydration or deletion', async () => {
     let suspend = false;
     let resolve;