Change:
} }); <ins> 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; } }); </ins> it('calls the hydration callbacks after hydration or deletion', async () => { let suspend = false; let resolve;

Discussion:
React version: 18.2.0 a tiny app that uses + suspends during hydration + has a hydration mismatch, for example: (Full code in ) the app and observe that the suspense fallback is never mounted , wrap the element with a : () the app and observe that the suspense fallback is now mounted Code example: · The fallback is rendered inconsistently – depending on whether the element that suspends is wrapped with a ?! The fallback is rendered either never, or always, no matter if the element that suspends is wrapped with anything.
Spent a bunch of time debugging this, and I think I understand what’s causing this. Let’s say we’re trying to hydrate the following tree: Here’s why the bug happens: hydrating the tree, React enters the and starts walking its children. At some point, it walks over the node – and encounters a hydration mismatch. This causes React to find the nearest suspense boundary and . (Later, and switches the boundary to client-side rendering, .) , after setting the flag, React does not switch the to client rendering immediately. Instead, it walks the remaining siblings (in this case, two s and ) and then exits the subtree. is where the inconsistency lies. In the normal case (without ), React finishes walking all siblings, walks back up to , realizes that has set, and client-renders again from scratch. However, if one of the siblings React walks over () suspends, React . This effectively disables mismatch handling (until later) – instead, suspends, as if there was no hydration mismatch. And because it’s still hydrating, Suspense keeps the server HTML instead of rendering the fallback. This behavior disappears if lives in another part of the tree (not in direct siblings): With this tree, React won’t walk over (because it’s inside a sibling, and React won’t enter siblings after it encountered a hydration error). This means React will follow the normal code path → re-render the Suspense boundary from scratch instead of hydrating it → suspend that boundary when it finally encounters → and render the Suspense fallback. a result, if is a direct sibling to the hydration mismatch, React will keep the server HTML until the component unsuspends. If it’s not, React will render the Suspense fallback.

This is (imo) the bug: React’s behavior shouldn’t depend on where inside the boundary the hydration mismatch happens. Just from the code perspective, it feels easier/cheaper to keep the “normal” case – always resetting the boundary if there’s a hydration mismatch. This also matches that says that is rerendered from scratch on a mismatch – this is good because every edge case is an overhead that devs need to learn. But from the UX perspective, I’d maybe prefer React to keep the server HTML until the boundary unsuspends? If we keep the server HTML, the UI will go through two states: Server HTML → Client HTML. Both of these should be similar, despite a minor hydration mismatch. If we reset the boundary, the UI will jump through Server HTML → “Loading...” fallback → Client HTML, which feels like a worse UX. IDK, feels like a hard choice to make (good thing it’s not mine I guess ^_^)
Sorry for the direct ping – I see you referenced this bug in some other PR; any chance this bug can be set to “Confirmed”? (It still reproduces even in )
This issue has been automatically marked as stale. If this issue is still affecting you, please leave any comment (for example, "bump"), and we'll keep it open. We are sorry that we haven't been able to prioritize it yet. If you have any new additional information, please include it with your comment!
Closing this issue after a prolonged period of inactivity. If this issue is still present in the latest release, please create a new issue with up-to-date information. Thank you!