From 4184b80f1691eef09cf2784428342d14e6833336 Mon Sep 17 00:00:00 2001 From: Ricky Date: Mon, 25 Mar 2024 20:33:31 -0400 Subject: [PATCH] Test showing mismatches after suspending force fallbacks to be shown (#28299) 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. --- ...DOMServerPartialHydration-test.internal.js | 769 ++++++++++++++++++ 1 file changed, 769 insertions(+) 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( + 'Hello
Component
', + ); + + 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( + 'Hello
Component
', + ); + + 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
Mismatch
'); + 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( + '
Component
', + ); + + 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( + '
Component
', + ); + + 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( + '
Mismatch
', + ); + 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( + '
Component
', + ); + + 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( + '
Component
', + ); + + 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( + '
Mismatch
', + ); + 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( + '
Component
', + ); + + 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( + '
Mismatch
', + ); + 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('
Mismatch
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
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( + '
Mismatch
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('calls the hydration callbacks after hydration or deletion', async () => { let suspend = false; let resolve;