/** * Copyright (c) Meta Platforms, Inc. and affiliates. * * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. * * @emails react-core * @jest-environment ./scripts/jest/ReactDOMServerIntegrationEnvironment */ 'use strict'; let Activity; let React = require('react'); let ReactDOM; let ReactDOMClient; let ReactDOMServer; let ReactFeatureFlags; let Scheduler; let Suspense; let useSyncExternalStore; let act; let IdleEventPriority; let waitForAll; let waitFor; let assertLog; let assertConsoleErrorDev; function normalizeError(msg) { // Take the first sentence to make it easier to assert on. const idx = msg.indexOf('.'); if (idx > -1) { return msg.slice(0, idx + 1); } return msg; } function dispatchMouseEvent(to, from) { if (!to) { to = null; } if (!from) { from = null; } if (from) { const mouseOutEvent = document.createEvent('MouseEvents'); mouseOutEvent.initMouseEvent( 'mouseout', true, true, window, 0, 50, 50, 50, 50, false, false, false, false, 0, to, ); from.dispatchEvent(mouseOutEvent); } if (to) { const mouseOverEvent = document.createEvent('MouseEvents'); mouseOverEvent.initMouseEvent( 'mouseover', true, true, window, 0, 50, 50, 50, 50, false, false, false, false, 0, from, ); to.dispatchEvent(mouseOverEvent); } } describe('ReactDOMServerPartialHydrationActivity', () => { beforeEach(() => { jest.resetModules(); ReactFeatureFlags = require('shared/ReactFeatureFlags'); ReactFeatureFlags.enableSuspenseCallback = true; ReactFeatureFlags.enableCreateEventHandleAPI = true; React = require('react'); ReactDOM = require('react-dom'); ReactDOMClient = require('react-dom/client'); act = require('internal-test-utils').act; ReactDOMServer = require('react-dom/server'); Scheduler = require('scheduler'); Activity = React.Activity; Suspense = React.Suspense; useSyncExternalStore = React.useSyncExternalStore; const InternalTestUtils = require('internal-test-utils'); waitForAll = InternalTestUtils.waitForAll; assertLog = InternalTestUtils.assertLog; waitFor = InternalTestUtils.waitFor; assertConsoleErrorDev = InternalTestUtils.assertConsoleErrorDev; IdleEventPriority = require('react-reconciler/constants').IdleEventPriority; }); it('hydrates a parent even if a child Activity boundary is blocked', async () => { let suspend = false; let resolve; const promise = new Promise(resolvePromise => (resolve = resolvePromise)); const ref = React.createRef(); function Child() { if (suspend) { throw promise; } else { return 'Hello'; } } function App() { return (
); } // First we render the final HTML. With the streaming renderer // this may have suspense points on the server but here we want // to test the completed HTML. Don't suspend on the server. suspend = false; const finalHTML = ReactDOMServer.renderToString(); const container = document.createElement('div'); container.innerHTML = finalHTML; const span = container.getElementsByTagName('span')[0]; // On the client we don't have all data yet but we want to start // hydrating anyway. suspend = true; ReactDOMClient.hydrateRoot(container, ); await waitForAll([]); expect(ref.current).toBe(null); // Resolving the promise should continue hydration suspend = false; resolve(); await promise; await waitForAll([]); // We should now have hydrated with a ref on the existing span. expect(ref.current).toBe(span); }); it('can hydrate siblings of a suspended component without errors', async () => { let suspend = false; let resolve; const promise = new Promise(resolvePromise => (resolve = resolvePromise)); function Child() { if (suspend) { throw promise; } else { return 'Hello'; } } function App() { return (
Hello
); } // First we render the final HTML. With the streaming renderer // this may have suspense points on the server but here we want // to test the completed HTML. Don't suspend on the server. suspend = false; const finalHTML = ReactDOMServer.renderToString(); const container = document.createElement('div'); container.innerHTML = finalHTML; expect(container.textContent).toBe('HelloHello'); // On the client we don't have all data yet but we want to start // hydrating anyway. suspend = true; ReactDOMClient.hydrateRoot(container, , { onRecoverableError(error) { Scheduler.log('onRecoverableError: ' + normalizeError(error.message)); if (error.cause) { Scheduler.log('Cause: ' + normalizeError(error.cause.message)); } }, }); await waitForAll([]); // Expect the server-generated HTML to stay intact. expect(container.textContent).toBe('HelloHello'); // Resolving the promise should continue hydration suspend = false; resolve(); await promise; await waitForAll([]); // Hydration should not change anything. expect(container.textContent).toBe('HelloHello'); }); it('falls back to client rendering boundary on mismatch', async () => { 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 App() { return ( ); } const finalHTML = ReactDOMServer.renderToString(); const container = document.createElement('section'); container.innerHTML = finalHTML; assertLog(['Hello', 'Component', 'Component', 'Component', 'Component']); expect(container.innerHTML).toBe( 'Hello
Component
Component
Component
Component
', ); suspend = true; client = true; ReactDOMClient.hydrateRoot(container, , { onRecoverableError(error) { Scheduler.log('onRecoverableError: ' + normalizeError(error.message)); if (error.cause) { Scheduler.log('Cause: ' + normalizeError(error.cause.message)); } }, }); await waitForAll(['Suspend']); jest.runAllTimers(); // Unchanged expect(container.innerHTML).toBe( 'Hello
Component
Component
Component
Component
', ); suspend = false; resolve(); await promise; await waitForAll([ // first pass, mismatches at end 'Hello', 'Component', 'Component', 'Component', 'Component', // second pass as client render 'Hello', 'Component', 'Component', 'Component', 'Component', // Hydration mismatch is logged "onRecoverableError: Hydration failed because the server rendered HTML didn't match the client.", ]); // Client rendered - suspense comment nodes removed expect(container.innerHTML).toBe( 'Hello
Component
Component
Component
Mismatch
', ); }); it('handles if mismatch is after suspending', async () => { 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 App() { return ( ); } 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('onRecoverableError: ' + normalizeError(error.message)); if (error.cause) { Scheduler.log('Cause: ' + normalizeError(error.cause.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', "onRecoverableError: Hydration failed because the server rendered HTML didn't match the client.", ]); jest.runAllTimers(); // Client rendered - suspense comment nodes removed. expect(container.innerHTML).toBe('Hello
Mismatch
'); }); it('handles if mismatch is child of suspended component', async () => { 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 App() { return ( ); } 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('onRecoverableError: ' + normalizeError(error.message)); if (error.cause) { Scheduler.log('Cause: ' + normalizeError(error.cause.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', "onRecoverableError: Hydration failed because the server rendered HTML didn't match the client.", ]); jest.runAllTimers(); // Client rendered - suspense comment nodes removed expect(container.innerHTML).toBe('
Mismatch
'); }); it('handles if mismatch is parent and first child suspends', async () => { 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 App() { return ( ); } 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('onRecoverableError: ' + normalizeError(error.message)); if (error.cause) { Scheduler.log('Cause: ' + normalizeError(error.cause.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', "onRecoverableError: Hydration failed because the server rendered HTML didn't match the client.", ]); jest.runAllTimers(); // Client rendered - suspense comment nodes removed expect(container.innerHTML).toBe( '
Mismatch
', ); }); it('does show a parent fallback if mismatch is parent and second child suspends', async () => { 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 ( }> ); } const finalHTML = ReactDOMServer.renderToString(); const container = document.createElement('section'); container.innerHTML = finalHTML; assertLog(['Component', 'Hello']); const div = container.getElementsByTagName('div')[0]; expect(container.innerHTML).toBe( '
Component
', ); suspend = true; client = true; ReactDOMClient.hydrateRoot(container, , { onRecoverableError(error) { Scheduler.log('onRecoverableError: ' + normalizeError(error.message)); if (error.cause) { Scheduler.log('Cause: ' + normalizeError(error.cause.message)); } }, }); await waitForAll(['Component', 'Component', 'Suspend', 'Fallback']); jest.runAllTimers(); // !! Client switches to suspense fallback. The dehydrated content is still hidden because we never // committed the client rendering. expect(container.innerHTML).toBe( '
Component
' + 'Loading...', ); suspend = false; resolve(); await promise; if (gate(flags => flags.alwaysThrottleRetries)) { await waitForAll(['Component', 'Component', 'Hello']); } else { await waitForAll([ 'Component', 'Component', 'Hello', "onRecoverableError: Hydration failed because the server rendered HTML didn't match the client.", ]); } jest.runAllTimers(); // Now that we've hit the throttle timeout, we can commit the failed hydration. if (gate(flags => flags.alwaysThrottleRetries)) { assertLog([ "onRecoverableError: Hydration failed because the server rendered HTML didn't match the client.", ]); } // Client rendered - activity comment nodes removed expect(container.innerHTML).toBe( '
Mismatch
', ); }); it('does show a parent fallback if mismatch is in parent element only', async () => { 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 ( }> ); } 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('onRecoverableError: ' + normalizeError(error.message)); if (error.cause) { Scheduler.log('Cause: ' + normalizeError(error.cause.message)); } }, }); await waitForAll(['Component', 'Component', 'Suspend', 'Fallback']); jest.runAllTimers(); // !! Client switches to suspense fallback. The dehydrated content is still hidden because we never // committed the client rendering. expect(container.innerHTML).toBe( '
' + 'Loading...', ); suspend = false; resolve(); await promise; if (gate(flags => flags.alwaysThrottleRetries)) { await waitForAll(['Component', 'Component', 'Hello']); } else { await waitForAll([ 'Component', 'Component', 'Hello', "onRecoverableError: Hydration failed because the server rendered HTML didn't match the client.", ]); } jest.runAllTimers(); // Now that we've hit the throttle timeout, we can commit the failed hydration. if (gate(flags => flags.alwaysThrottleRetries)) { assertLog([ "onRecoverableError: Hydration failed because the server rendered HTML didn't match the client.", ]); } // Client rendered - activity comment nodes removed expect(container.innerHTML).toBe( '
', ); }); it('does show a parent fallback if mismatch is before suspending', async () => { 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 ( }> ); } 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('onRecoverableError: ' + normalizeError(error.message)); if (error.cause) { Scheduler.log('Cause: ' + normalizeError(error.cause.message)); } }, }); await waitForAll(['Component', 'Component', 'Suspend', 'Fallback']); jest.runAllTimers(); // !! Client switches to suspense fallback. The dehydrated content is still hidden because we never // committed the client rendering. expect(container.innerHTML).toBe( '
Component
' + 'Loading...', ); suspend = false; resolve(); await promise; if (gate(flags => flags.alwaysThrottleRetries)) { await waitForAll(['Component', 'Component', 'Hello']); } else { await waitForAll([ 'Component', 'Component', 'Hello', "onRecoverableError: Hydration failed because the server rendered HTML didn't match the client.", ]); } jest.runAllTimers(); // Now that we've hit the throttle timeout, we can commit the failed hydration. if (gate(flags => flags.alwaysThrottleRetries)) { assertLog([ "onRecoverableError: Hydration failed because the server rendered HTML didn't match the client.", ]); } // Client rendered - activity comment nodes removed expect(container.innerHTML).toBe( '
Mismatch
Hello', ); }); it('does show a parent fallback if mismatch is before suspending in a child', async () => { 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 ( }>
); } 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('onRecoverableError: ' + normalizeError(error.message)); if (error.cause) { Scheduler.log('Cause: ' + normalizeError(error.cause.message)); } }, }); await waitForAll(['Component', 'Component', 'Suspend', 'Fallback']); jest.runAllTimers(); // !! Client switches to suspense fallback. The dehydrated content is still hidden because we never // committed the client rendering. expect(container.innerHTML).toBe( '
Component
Hello
' + 'Loading...', ); suspend = false; resolve(); await promise; if (gate(flags => flags.alwaysThrottleRetries)) { await waitForAll(['Component', 'Component', 'Hello']); } else { await waitForAll([ 'Component', 'Component', 'Hello', "onRecoverableError: Hydration failed because the server rendered HTML didn't match the client.", ]); } jest.runAllTimers(); // Now that we've hit the throttle timeout, we can commit the failed hydration. if (gate(flags => flags.alwaysThrottleRetries)) { assertLog([ "onRecoverableError: Hydration failed because the server rendered HTML didn't match the client.", ]); } // Client rendered - activity comment nodes removed expect(container.innerHTML).toBe( '
Mismatch
Hello
', ); }); it('calls the hydration callbacks after hydration or deletion', async () => { let suspend = false; let resolve; const promise = new Promise(resolvePromise => (resolve = resolvePromise)); function Child() { if (suspend) { throw promise; } else { return 'Hello'; } } let suspend2 = false; const promise2 = new Promise(() => {}); function Child2({value}) { if (suspend2 && !value) { throw promise2; } else { return 'World'; } } function App({value}) { return (
); } // First we render the final HTML. With the streaming renderer // this may have suspense points on the server but here we want // to test the completed HTML. Don't suspend on the server. suspend = false; suspend2 = false; const finalHTML = ReactDOMServer.renderToString(); const container = document.createElement('div'); container.innerHTML = finalHTML; const hydrated = []; const deleted = []; // On the client we don't have all data yet but we want to start // hydrating anyway. suspend = true; suspend2 = true; const root = ReactDOMClient.hydrateRoot(container, , { onHydrated(node) { hydrated.push(node); }, onDeleted(node) { deleted.push(node); }, onRecoverableError(error) { Scheduler.log('onRecoverableError: ' + normalizeError(error.message)); if (error.cause) { Scheduler.log('Cause: ' + normalizeError(error.cause.message)); } }, }); await waitForAll([]); expect(hydrated.length).toBe(0); expect(deleted.length).toBe(0); await act(async () => { // Resolving the promise should continue hydration suspend = false; resolve(); await promise; }); expect(hydrated.length).toBe(1); expect(deleted.length).toBe(0); // Performing an update should force it to delete the boundary if // it could be unsuspended by the update. await act(() => { root.render(); }); expect(hydrated.length).toBe(1); expect(deleted.length).toBe(1); }); it('hydrates an empty activity boundary', async () => { function App() { return (
Sibling
); } const finalHTML = ReactDOMServer.renderToString(); const container = document.createElement('div'); container.innerHTML = finalHTML; ReactDOMClient.hydrateRoot(container, ); await waitForAll([]); expect(container.innerHTML).toContain('
Sibling
'); }); it('recovers with client render when server rendered additional nodes at suspense root', async () => { function CheckIfHydrating({children}) { // This is a trick to check whether we're hydrating or not, since React // doesn't expose that information currently except // via useSyncExternalStore. let serverOrClient = '(unknown)'; useSyncExternalStore( () => {}, () => { serverOrClient = 'Client rendered'; return null; }, () => { serverOrClient = 'Server rendered'; return null; }, ); Scheduler.log(serverOrClient); return null; } const ref = React.createRef(); function App({hasB}) { return (
A {hasB ? B : null}
Sibling
); } const finalHTML = ReactDOMServer.renderToString(); assertLog(['Server rendered']); const container = document.createElement('div'); container.innerHTML = finalHTML; const span = container.getElementsByTagName('span')[0]; expect(container.innerHTML).toContain('A'); expect(container.innerHTML).toContain('B'); expect(ref.current).toBe(null); await act(() => { ReactDOMClient.hydrateRoot(container, , { onRecoverableError(error) { Scheduler.log('onRecoverableError: ' + normalizeError(error.message)); if (error.cause) { Scheduler.log('Cause: ' + normalizeError(error.cause.message)); } }, }); }); expect(container.innerHTML).toContain('A'); expect(container.innerHTML).not.toContain('B'); assertLog([ 'Server rendered', 'Client rendered', "onRecoverableError: Hydration failed because the server rendered HTML didn't match the client.", ]); expect(ref.current).not.toBe(span); }); it('recovers with client render when server rendered additional nodes at suspense root after unsuspending', async () => { const ref = React.createRef(); let shouldSuspend = false; let resolve; const promise = new Promise(res => { resolve = () => { shouldSuspend = false; res(); }; }); function Suspender() { if (shouldSuspend) { throw promise; } return <>; } function App({hasB}) { return (
A {hasB ? B : null}
Sibling
); } const finalHTML = ReactDOMServer.renderToString(); const container = document.createElement('div'); container.innerHTML = finalHTML; const span = container.getElementsByTagName('span')[0]; expect(container.innerHTML).toContain('A'); expect(container.innerHTML).toContain('B'); expect(ref.current).toBe(null); shouldSuspend = true; await act(() => { ReactDOMClient.hydrateRoot(container, , { onRecoverableError(error) { Scheduler.log('onRecoverableError: ' + normalizeError(error.message)); if (error.cause) { Scheduler.log('Cause: ' + normalizeError(error.cause.message)); } }, }); }); await act(() => { resolve(); }); assertLog([ "onRecoverableError: Hydration failed because the server rendered HTML didn't match the client.", ]); expect(container.innerHTML).toContain('A'); expect(container.innerHTML).not.toContain('B'); expect(ref.current).not.toBe(span); }); it('recovers with client render when server rendered additional nodes deep inside suspense root', async () => { const ref = React.createRef(); function App({hasB}) { return (
A {hasB ? B : null}
Sibling
); } const finalHTML = ReactDOMServer.renderToString(); const container = document.createElement('div'); container.innerHTML = finalHTML; const span = container.getElementsByTagName('span')[0]; expect(container.innerHTML).toContain('A'); expect(container.innerHTML).toContain('B'); expect(ref.current).toBe(null); await act(() => { ReactDOMClient.hydrateRoot(container, , { onRecoverableError(error) { Scheduler.log('onRecoverableError: ' + normalizeError(error.message)); if (error.cause) { Scheduler.log('Cause: ' + normalizeError(error.cause.message)); } }, }); }); assertLog([ "onRecoverableError: Hydration failed because the server rendered HTML didn't match the client.", ]); expect(container.innerHTML).toContain('A'); expect(container.innerHTML).not.toContain('B'); expect(ref.current).not.toBe(span); }); it('calls the onDeleted hydration callback if the parent gets deleted', async () => { let suspend = false; const promise = new Promise(() => {}); function Child() { if (suspend) { throw promise; } else { return 'Hello'; } } function App({deleted}) { if (deleted) { return null; } return (
); } suspend = false; const finalHTML = ReactDOMServer.renderToString(); const container = document.createElement('div'); container.innerHTML = finalHTML; const deleted = []; // On the client we don't have all data yet but we want to start // hydrating anyway. suspend = true; const root = await act(() => { return ReactDOMClient.hydrateRoot(container, , { onDeleted(node) { deleted.push(node); }, }); }); expect(deleted.length).toBe(0); await act(() => { root.render(); }); // The callback should have been invoked. expect(deleted.length).toBe(1); }); it('can insert siblings before the dehydrated boundary', async () => { let suspend = false; const promise = new Promise(() => {}); let showSibling; function Child() { if (suspend) { throw promise; } else { return 'Second'; } } function Sibling() { const [visible, setVisibilty] = React.useState(false); showSibling = () => setVisibilty(true); if (visible) { return
First
; } return null; } function App() { return (
); } suspend = false; const finalHTML = ReactDOMServer.renderToString(); const container = document.createElement('div'); container.innerHTML = finalHTML; // On the client we don't have all data yet but we want to start // hydrating anyway. suspend = true; await act(() => { ReactDOMClient.hydrateRoot(container, ); }); expect(container.firstChild.firstChild.tagName).not.toBe('DIV'); // In this state, we can still update the siblings. await act(() => showSibling()); expect(container.firstChild.firstChild.tagName).toBe('DIV'); expect(container.firstChild.firstChild.textContent).toBe('First'); }); it('can delete the dehydrated boundary before it is hydrated', async () => { let suspend = false; const promise = new Promise(() => {}); let hideMiddle; function Child() { if (suspend) { throw promise; } else { return ( <>
Middle
Some text ); } } function App() { const [visible, setVisibilty] = React.useState(true); hideMiddle = () => setVisibilty(false); return (
Before
{visible ? ( ) : null}
After
); } suspend = false; const finalHTML = ReactDOMServer.renderToString(); const container = document.createElement('div'); container.innerHTML = finalHTML; // On the client we don't have all data yet but we want to start // hydrating anyway. suspend = true; await act(() => { ReactDOMClient.hydrateRoot(container, ); }); expect(container.firstChild.children[1].textContent).toBe('Middle'); // In this state, we can still delete the boundary. await act(() => hideMiddle()); expect(container.firstChild.children[1].textContent).toBe('After'); }); it('blocks updates to hydrate the content first if props have changed', async () => { let suspend = false; let resolve; const promise = new Promise(resolvePromise => (resolve = resolvePromise)); const ref = React.createRef(); function Child({text}) { if (suspend) { throw promise; } else { return text; } } function App({text, className}) { return (
); } suspend = false; const finalHTML = ReactDOMServer.renderToString( , ); const container = document.createElement('div'); container.innerHTML = finalHTML; const span = container.getElementsByTagName('span')[0]; // On the client we don't have all data yet but we want to start // hydrating anyway. suspend = true; const root = ReactDOMClient.hydrateRoot( container, , ); await waitForAll([]); expect(ref.current).toBe(null); expect(span.textContent).toBe('Hello'); // Render an update, which will be higher or the same priority as pinging the hydration. root.render(); // At the same time, resolving the promise so that rendering can complete. // This should first complete the hydration and then flush the update onto the hydrated state. await act(async () => { suspend = false; resolve(); await promise; }); // The new span should be the same since we should have successfully hydrated // before changing it. const newSpan = container.getElementsByTagName('span')[0]; expect(span).toBe(newSpan); // We should now have fully rendered with a ref on the new span. expect(ref.current).toBe(span); expect(span.textContent).toBe('Hi'); // If we ended up hydrating the existing content, we won't have properly // patched up the tree, which might mean we haven't patched the className. expect(span.className).toBe('hi'); }); // @gate www it('blocks updates to hydrate the content first if props changed at idle priority', async () => { let suspend = false; let resolve; const promise = new Promise(resolvePromise => (resolve = resolvePromise)); const ref = React.createRef(); function Child({text}) { if (suspend) { throw promise; } else { return text; } } function App({text, className}) { return (
); } suspend = false; const finalHTML = ReactDOMServer.renderToString( , ); const container = document.createElement('div'); container.innerHTML = finalHTML; const span = container.getElementsByTagName('span')[0]; // On the client we don't have all data yet but we want to start // hydrating anyway. suspend = true; const root = ReactDOMClient.hydrateRoot( container, , ); await waitForAll([]); expect(ref.current).toBe(null); expect(span.textContent).toBe('Hello'); // Schedule an update at idle priority ReactDOM.unstable_runWithPriority(IdleEventPriority, () => { root.render(); }); // At the same time, resolving the promise so that rendering can complete. suspend = false; resolve(); await promise; // This should first complete the hydration and then flush the update onto the hydrated state. await waitForAll([]); // The new span should be the same since we should have successfully hydrated // before changing it. const newSpan = container.getElementsByTagName('span')[0]; expect(span).toBe(newSpan); // We should now have fully rendered with a ref on the new span. expect(ref.current).toBe(span); expect(span.textContent).toBe('Hi'); // If we ended up hydrating the existing content, we won't have properly // patched up the tree, which might mean we haven't patched the className. expect(span.className).toBe('hi'); }); it('shows the fallback of the parent if props have changed before hydration completes and is still suspended', async () => { let suspend = false; let resolve; const promise = new Promise(resolvePromise => (resolve = resolvePromise)); const outerRef = React.createRef(); const ref = React.createRef(); function Child({text}) { if (suspend) { throw promise; } else { return text; } } function App({text, className}) { return (
); } suspend = false; const finalHTML = ReactDOMServer.renderToString( , ); const container = document.createElement('div'); container.innerHTML = finalHTML; // On the client we don't have all data yet but we want to start // hydrating anyway. suspend = true; const root = ReactDOMClient.hydrateRoot( container, , { onRecoverableError(error) { Scheduler.log('onRecoverableError: ' + normalizeError(error.message)); if (error.cause) { Scheduler.log('Cause: ' + normalizeError(error.cause.message)); } }, }, ); await waitForAll([]); expect(container.getElementsByTagName('div').length).toBe(1); // hidden const div = container.getElementsByTagName('div')[0]; expect(outerRef.current).toBe(div); expect(ref.current).toBe(null); // Render an update, but leave it still suspended. await act(() => { root.render(); }); // Flushing now should hide the existing content and show the fallback. expect(outerRef.current).toBe(null); expect(div.style.display).toBe('none'); expect(container.getElementsByTagName('span').length).toBe(1); // hidden expect(ref.current).toBe(null); expect(container.textContent).toBe('HelloLoading...'); // Unsuspending shows the content. await act(async () => { suspend = false; resolve(); await promise; }); const span = container.getElementsByTagName('span')[0]; expect(span.textContent).toBe('Hi'); expect(span.className).toBe('hi'); expect(ref.current).toBe(span); expect(container.textContent).toBe('Hi'); }); it('clears nested activity boundaries if they did not hydrate yet', async () => { let suspend = false; const promise = new Promise(() => {}); const ref = React.createRef(); function Child({text}) { if (suspend && text !== 'Hi') { throw promise; } else { return text; } } function App({text, className}) { return (
{' '}
); } suspend = false; const finalHTML = ReactDOMServer.renderToString( , ); const container = document.createElement('div'); container.innerHTML = finalHTML; // On the client we don't have all data yet but we want to start // hydrating anyway. suspend = true; const root = ReactDOMClient.hydrateRoot( container, , { onRecoverableError(error) { Scheduler.log('onRecoverableError: ' + normalizeError(error.message)); if (error.cause) { Scheduler.log('Cause: ' + normalizeError(error.cause.message)); } }, }, ); await waitForAll([]); expect(ref.current).toBe(null); // Render an update, that unblocks. // Flushing now should delete the existing content and show the update. await act(() => { root.render(); }); const span = container.getElementsByTagName('span')[0]; expect(span.textContent).toBe('Hi'); expect(span.className).toBe('hi'); expect(ref.current).toBe(span); expect(container.textContent).toBe('Hi Hi'); }); it('hydrates first if props changed but we are able to resolve within a timeout', async () => { let suspend = false; let resolve; const promise = new Promise(resolvePromise => (resolve = resolvePromise)); const ref = React.createRef(); function Child({text}) { if (suspend) { throw promise; } else { return text; } } function App({text, className}) { return (
); } suspend = false; const finalHTML = ReactDOMServer.renderToString( , ); const container = document.createElement('div'); container.innerHTML = finalHTML; const span = container.getElementsByTagName('span')[0]; // On the client we don't have all data yet but we want to start // hydrating anyway. suspend = true; const root = ReactDOMClient.hydrateRoot( container, , ); await waitForAll([]); expect(ref.current).toBe(null); expect(container.textContent).toBe('Hello'); // Render an update with a long timeout. React.startTransition(() => root.render()); // This shouldn't force the fallback yet. await waitForAll([]); expect(ref.current).toBe(null); expect(container.textContent).toBe('Hello'); // Resolving the promise so that rendering can complete. // This should first complete the hydration and then flush the update onto the hydrated state. suspend = false; await act(() => resolve()); // The new span should be the same since we should have successfully hydrated // before changing it. const newSpan = container.getElementsByTagName('span')[0]; expect(span).toBe(newSpan); // We should now have fully rendered with a ref on the new span. expect(ref.current).toBe(span); expect(container.textContent).toBe('Hi'); // If we ended up hydrating the existing content, we won't have properly // patched up the tree, which might mean we haven't patched the className. expect(span.className).toBe('hi'); }); it('warns but works if setState is called before commit in a dehydrated component', async () => { let suspend = false; let resolve; const promise = new Promise(resolvePromise => (resolve = resolvePromise)); let updateText; function Child() { const [state, setState] = React.useState('Hello'); updateText = setState; Scheduler.log('Child'); if (suspend) { throw promise; } else { return state; } } function Sibling() { Scheduler.log('Sibling'); return null; } function App() { return (
); } suspend = false; const finalHTML = ReactDOMServer.renderToString(); assertLog(['Child', 'Sibling']); const container = document.createElement('div'); container.innerHTML = finalHTML; ReactDOMClient.hydrateRoot( container, , ); await act(async () => { suspend = true; await waitFor(['Child']); // While we're part way through the hydration, we update the state. // This will schedule an update on the children of the activity boundary. updateText('Hi'); assertConsoleErrorDev([ "Can't perform a React state update on a component that hasn't mounted yet. " + 'This indicates that you have a side-effect in your render function that ' + 'asynchronously tries to update the component. Move this work to useEffect instead.\n' + ' in App (at **)', ]); // This will throw it away and rerender. await waitForAll(['Child']); expect(container.textContent).toBe('Hello'); suspend = false; resolve(); await promise; }); assertLog(['Child', 'Sibling']); expect(container.textContent).toBe('Hello'); }); it('blocks the update to hydrate first if context has changed', async () => { let suspend = false; let resolve; const promise = new Promise(resolvePromise => (resolve = resolvePromise)); const ref = React.createRef(); const Context = React.createContext(null); function Child() { const {text, className} = React.useContext(Context); if (suspend) { throw promise; } else { return ( {text} ); } } const App = React.memo(function App() { return (
); }); suspend = false; const finalHTML = ReactDOMServer.renderToString( , ); const container = document.createElement('div'); container.innerHTML = finalHTML; const span = container.getElementsByTagName('span')[0]; // On the client we don't have all data yet but we want to start // hydrating anyway. suspend = true; const root = ReactDOMClient.hydrateRoot( container, , ); await waitForAll([]); expect(ref.current).toBe(null); expect(span.textContent).toBe('Hello'); // Render an update, which will be higher or the same priority as pinging the hydration. root.render( , ); // At the same time, resolving the promise so that rendering can complete. // This should first complete the hydration and then flush the update onto the hydrated state. await act(async () => { suspend = false; resolve(); await promise; }); // Since this should have been hydrated, this should still be the same span. const newSpan = container.getElementsByTagName('span')[0]; expect(newSpan).toBe(span); // We should now have fully rendered with a ref on the new span. expect(ref.current).toBe(span); expect(span.textContent).toBe('Hi'); // If we ended up hydrating the existing content, we won't have properly // patched up the tree, which might mean we haven't patched the className. expect(span.className).toBe('hi'); }); it('shows the parent fallback if context has changed before hydration completes and is still suspended', async () => { let suspend = false; let resolve; const promise = new Promise(resolvePromise => (resolve = resolvePromise)); const ref = React.createRef(); const Context = React.createContext(null); function Child() { const {text, className} = React.useContext(Context); if (suspend) { throw promise; } else { return ( {text} ); } } const App = React.memo(function App() { return (
); }); suspend = false; const finalHTML = ReactDOMServer.renderToString( , ); const container = document.createElement('div'); container.innerHTML = finalHTML; // On the client we don't have all data yet but we want to start // hydrating anyway. suspend = true; const root = ReactDOMClient.hydrateRoot( container, , { onRecoverableError(error) { Scheduler.log('onRecoverableError: ' + normalizeError(error.message)); if (error.cause) { Scheduler.log('Cause: ' + normalizeError(error.cause.message)); } }, }, ); await waitForAll([]); expect(ref.current).toBe(null); // Render an update, but leave it still suspended. // Flushing now should delete the existing content and show the fallback. await act(() => { root.render( , ); }); expect(container.getElementsByTagName('span').length).toBe(1); // hidden expect(ref.current).toBe(null); expect(container.textContent).toBe('HelloLoading...'); // Unsuspending shows the content. await act(async () => { suspend = false; resolve(); await promise; }); const span = container.getElementsByTagName('span')[0]; expect(span.textContent).toBe('Hi'); expect(span.className).toBe('hi'); expect(ref.current).toBe(span); expect(container.textContent).toBe('Hi'); }); it('can hydrate TWO activity boundaries', async () => { const ref1 = React.createRef(); const ref2 = React.createRef(); function App() { return (
1 2
); } // First we render the final HTML. With the streaming renderer // this may have suspense points on the server but here we want // to test the completed HTML. Don't suspend on the server. const finalHTML = ReactDOMServer.renderToString(); const container = document.createElement('div'); container.innerHTML = finalHTML; const span1 = container.getElementsByTagName('span')[0]; const span2 = container.getElementsByTagName('span')[1]; // On the client we don't have all data yet but we want to start // hydrating anyway. ReactDOMClient.hydrateRoot(container, ); await waitForAll([]); expect(ref1.current).toBe(span1); expect(ref2.current).toBe(span2); }); it('regenerates if it cannot hydrate before changes to props/context expire', async () => { let suspend = false; const promise = new Promise(resolvePromise => {}); const ref = React.createRef(); const ClassName = React.createContext(null); function Child({text}) { const className = React.useContext(ClassName); if (suspend && className !== 'hi' && text !== 'Hi') { // Never suspends on the newer data. throw promise; } else { return ( {text} ); } } function App({text, className}) { return (
); } suspend = false; const finalHTML = ReactDOMServer.renderToString( , ); const container = document.createElement('div'); container.innerHTML = finalHTML; const span = container.getElementsByTagName('span')[0]; // On the client we don't have all data yet but we want to start // hydrating anyway. suspend = true; const root = ReactDOMClient.hydrateRoot( container, , { onRecoverableError(error) { Scheduler.log('onRecoverableError: ' + normalizeError(error.message)); if (error.cause) { Scheduler.log('Cause: ' + normalizeError(error.cause.message)); } }, }, ); await waitForAll([]); expect(ref.current).toBe(null); expect(span.textContent).toBe('Hello'); // Render an update, which will be higher or the same priority as pinging the hydration. // The new update doesn't suspend. // Since we're still suspended on the original data, we can't hydrate. // This will force all expiration times to flush. await act(() => { root.render( , ); }); // This will now be a new span because we weren't able to hydrate before const newSpan = container.getElementsByTagName('span')[0]; expect(newSpan).not.toBe(span); // We should now have fully rendered with a ref on the new span. expect(ref.current).toBe(newSpan); expect(newSpan.textContent).toBe('Hi'); // If we ended up hydrating the existing content, we won't have properly // patched up the tree, which might mean we haven't patched the className. expect(newSpan.className).toBe('hi'); }); it('does not invoke an event on a hydrated node until it commits', async () => { let suspend = false; let resolve; const promise = new Promise(resolvePromise => (resolve = resolvePromise)); function Sibling({text}) { if (suspend) { throw promise; } else { return 'Hello'; } } let clicks = 0; function Button() { const [clicked, setClicked] = React.useState(false); if (clicked) { return null; } return ( { setClicked(true); clicks++; }}> Click me ); } function App() { return (
); } suspend = false; const finalHTML = ReactDOMServer.renderToString(); const container = document.createElement('div'); container.innerHTML = finalHTML; // We need this to be in the document since we'll dispatch events on it. document.body.appendChild(container); const a = container.getElementsByTagName('a')[0]; // On the client we don't have all data yet but we want to start // hydrating anyway. suspend = true; ReactDOMClient.hydrateRoot(container, ); await waitForAll([]); expect(container.textContent).toBe('Click meHello'); // We're now partially hydrated. await act(() => { a.click(); }); expect(clicks).toBe(0); // Resolving the promise so that rendering can complete. await act(async () => { suspend = false; resolve(); await promise; }); expect(clicks).toBe(0); expect(container.textContent).toBe('Click meHello'); document.body.removeChild(container); }); // @gate www it('does not invoke an event on a hydrated event handle until it commits', async () => { const setClick = ReactDOM.unstable_createEventHandle('click'); let suspend = false; let isServerRendering = true; let resolve; const promise = new Promise(resolvePromise => (resolve = resolvePromise)); function Sibling({text}) { if (suspend) { throw promise; } else { return 'Hello'; } } const onEvent = jest.fn(); function Button() { const ref = React.useRef(null); if (!isServerRendering) { React.useLayoutEffect(() => { return setClick(ref.current, onEvent); }); } return Click me; } function App() { return (
); } suspend = false; const finalHTML = ReactDOMServer.renderToString(); const container = document.createElement('div'); container.innerHTML = finalHTML; // We need this to be in the document since we'll dispatch events on it. document.body.appendChild(container); const a = container.getElementsByTagName('a')[0]; // On the client we don't have all data yet but we want to start // hydrating anyway. suspend = true; isServerRendering = false; ReactDOMClient.hydrateRoot(container, ); // We'll do one click before hydrating. a.click(); // This should be delayed. expect(onEvent).toHaveBeenCalledTimes(0); await waitForAll([]); // We're now partially hydrated. await act(() => { a.click(); }); // We should not have invoked the event yet because we're not // yet hydrated. expect(onEvent).toHaveBeenCalledTimes(0); // Resolving the promise so that rendering can complete. await act(async () => { suspend = false; resolve(); await promise; }); expect(onEvent).toHaveBeenCalledTimes(0); document.body.removeChild(container); }); it('invokes discrete events on nested activity boundaries in a root (legacy system)', async () => { let suspend = false; let resolve; const promise = new Promise(resolvePromise => (resolve = resolvePromise)); let clicks = 0; function Button() { return ( { clicks++; }}> Click me ); } function Child() { if (suspend) { throw promise; } else { return (