diff --git a/packages/react-dom/src/__tests__/ReactDOMServerPartialHydration-test.internal.js b/packages/react-dom/src/__tests__/ReactDOMServerPartialHydration-test.internal.js
index 9ee8ee5a8130e..5f32046683190 100644
--- a/packages/react-dom/src/__tests__/ReactDOMServerPartialHydration-test.internal.js
+++ b/packages/react-dom/src/__tests__/ReactDOMServerPartialHydration-test.internal.js
@@ -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 Mismatch;
+ }
+ return
Component
;
+ }
+ function Fallback() {
+ Scheduler.log('Fallback');
+ return 'Loading...';
+ }
+ function App() {
+ return (
+ }>
+
+
+
+ );
+ }
+ try {
+ const finalHTML = ReactDOMServer.renderToString();
+ const container = document.createElement('section');
+ container.innerHTML = finalHTML;
+ assertLog(['Hello', 'Component']);
+
+ expect(container.innerHTML).toBe(
+ 'HelloComponent
',
+ );
+
+ suspend = true;
+ client = true;
+
+ ReactDOMClient.hydrateRoot(container, , {
+ onRecoverableError(error) {
+ Scheduler.log(error.message);
+ },
+ });
+ await waitForAll(['Suspend']);
+ jest.runAllTimers();
+
+ // !! Unchanged, continue showing server content while suspended.
+ expect(container.innerHTML).toBe(
+ 'HelloComponent
',
+ );
+
+ 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('HelloMismatch');
+ 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 {children}
;
+ }
+ }
+ function Component({shouldMismatch}) {
+ Scheduler.log('Component');
+ if (shouldMismatch && client) {
+ return Mismatch;
+ }
+ return Component
;
+ }
+ function Fallback() {
+ Scheduler.log('Fallback');
+ return 'Loading...';
+ }
+ function App() {
+ return (
+ }>
+
+
+
+
+ );
+ }
+ try {
+ const finalHTML = ReactDOMServer.renderToString();
+ const container = document.createElement('section');
+ container.innerHTML = finalHTML;
+ assertLog(['Hello', 'Component']);
+
+ expect(container.innerHTML).toBe(
+ '',
+ );
+
+ suspend = true;
+ client = true;
+
+ ReactDOMClient.hydrateRoot(container, , {
+ onRecoverableError(error) {
+ Scheduler.log(error.message);
+ },
+ });
+ await waitForAll(['Suspend']);
+ jest.runAllTimers();
+
+ // !! Unchanged, continue showing server content while suspended.
+ expect(container.innerHTML).toBe(
+ '',
+ );
+
+ 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(
+ '',
+ );
+ 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 {children}
;
+ }
+ }
+ function Component({shouldMismatch, children}) {
+ Scheduler.log('Component');
+ if (shouldMismatch && client) {
+ return (
+
+ {children}
+
Mismatch
+
+ );
+ }
+ return (
+
+ {children}
+
Component
+
+ );
+ }
+ function Fallback() {
+ Scheduler.log('Fallback');
+ return 'Loading...';
+ }
+ function App() {
+ return (
+ }>
+
+
+
+
+ );
+ }
+ try {
+ const finalHTML = ReactDOMServer.renderToString();
+ const container = document.createElement('section');
+ container.innerHTML = finalHTML;
+ assertLog(['Component', 'Hello']);
+
+ expect(container.innerHTML).toBe(
+ '',
+ );
+
+ suspend = true;
+ client = true;
+
+ ReactDOMClient.hydrateRoot(container, , {
+ onRecoverableError(error) {
+ Scheduler.log(error.message);
+ },
+ });
+ await waitForAll(['Component', 'Suspend']);
+ jest.runAllTimers();
+
+ // !! Unchanged, continue showing server content while suspended.
+ expect(container.innerHTML).toBe(
+ '',
+ );
+
+ 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(
+ '',
+ );
+ 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 {children}
;
+ }
+ }
+ function Component({shouldMismatch, children}) {
+ Scheduler.log('Component');
+ if (shouldMismatch && client) {
+ return (
+
+
Mismatch
+ {children}
+
+ );
+ }
+ return (
+
+
Component
+ {children}
+
+ );
+ }
+ function Fallback() {
+ Scheduler.log('Fallback');
+ return 'Loading...';
+ }
+ function App() {
+ return (
+ }>
+
+
+
+
+ );
+ }
+ try {
+ const finalHTML = ReactDOMServer.renderToString();
+ const container = document.createElement('section');
+ container.innerHTML = finalHTML;
+ assertLog(['Component', 'Hello']);
+
+ expect(container.innerHTML).toBe(
+ '',
+ );
+
+ suspend = true;
+ client = true;
+
+ ReactDOMClient.hydrateRoot(container, , {
+ 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(
+ '',
+ );
+ 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 {children}
;
+ }
+ }
+ function Component({shouldMismatch, children}) {
+ Scheduler.log('Component');
+ if (shouldMismatch && client) {
+ return {children};
+ }
+ return {children}
;
+ }
+ function Fallback() {
+ Scheduler.log('Fallback');
+ return 'Loading...';
+ }
+ function App() {
+ return (
+ }>
+
+
+
+
+ );
+ }
+ try {
+ const finalHTML = ReactDOMServer.renderToString();
+ const container = document.createElement('section');
+ container.innerHTML = finalHTML;
+ assertLog(['Component', 'Hello']);
+
+ expect(container.innerHTML).toBe(
+ '',
+ );
+
+ suspend = true;
+ client = true;
+
+ ReactDOMClient.hydrateRoot(container, , {
+ 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('');
+ 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 Mismatch;
+ }
+ return Component
;
+ }
+ function Fallback() {
+ Scheduler.log('Fallback');
+ return 'Loading...';
+ }
+ function App() {
+ return (
+ }>
+
+
+
+ );
+ }
+ try {
+ const finalHTML = ReactDOMServer.renderToString();
+ const container = document.createElement('section');
+ container.innerHTML = finalHTML;
+ assertLog(['Component', 'Hello']);
+
+ expect(container.innerHTML).toBe(
+ 'Component
Hello',
+ );
+
+ suspend = true;
+ client = true;
+
+ ReactDOMClient.hydrateRoot(container, , {
+ 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('MismatchHello');
+ 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 Mismatch;
+ }
+ return Component
;
+ }
+ function Fallback() {
+ Scheduler.log('Fallback');
+ return 'Loading...';
+ }
+ function App() {
+ return (
+ }>
+
+
+
+
+
+ );
+ }
+ try {
+ const finalHTML = ReactDOMServer.renderToString();
+ const container = document.createElement('section');
+ container.innerHTML = finalHTML;
+ assertLog(['Component', 'Hello']);
+
+ expect(container.innerHTML).toBe(
+ 'Component
Hello
',
+ );
+
+ suspend = true;
+ client = true;
+
+ ReactDOMClient.hydrateRoot(container, , {
+ 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(
+ 'MismatchHello
',
+ );
+ 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;