/** * Copyright (c) Facebook, Inc. and its 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 */ 'use strict'; import {createEventTarget} from 'dom-event-testing-library'; let React; let ReactDOM; let ReactDOMServer; let ReactTestUtils; let Scheduler; let Suspense; let act; function dispatchMouseHoverEvent(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); } } function dispatchClickEvent(target) { const mouseOutEvent = document.createEvent('MouseEvents'); mouseOutEvent.initMouseEvent( 'click', true, true, window, 0, 50, 50, 50, 50, false, false, false, false, 0, target, ); return target.dispatchEvent(mouseOutEvent); } describe('ReactDOMServerSelectiveHydration', () => { beforeEach(() => { jest.resetModuleRegistry(); const ReactFeatureFlags = require('shared/ReactFeatureFlags'); ReactFeatureFlags.enableCreateEventHandleAPI = true; React = require('react'); ReactDOM = require('react-dom'); ReactDOMServer = require('react-dom/server'); ReactTestUtils = require('react-dom/test-utils'); act = ReactTestUtils.unstable_concurrentAct; Scheduler = require('scheduler'); Suspense = React.Suspense; }); // @gate experimental it('hydrates the target boundary synchronously during a click', async () => { function Child({text}) { Scheduler.unstable_yieldValue(text); return ( { e.preventDefault(); Scheduler.unstable_yieldValue('Clicked ' + text); }}> {text} ); } function App() { Scheduler.unstable_yieldValue('App'); return (
); } const finalHTML = ReactDOMServer.renderToString(); expect(Scheduler).toHaveYielded(['App', 'A', 'B']); const container = document.createElement('div'); // We need this to be in the document since we'll dispatch events on it. document.body.appendChild(container); container.innerHTML = finalHTML; const span = container.getElementsByTagName('span')[1]; const root = ReactDOM.createRoot(container, {hydrate: true}); root.render(); // Nothing has been hydrated so far. expect(Scheduler).toHaveYielded([]); // This should synchronously hydrate the root App and the second suspense // boundary. const result = dispatchClickEvent(span); // The event should have been canceled because we called preventDefault. expect(result).toBe(false); // We rendered App, B and then invoked the event without rendering A. expect(Scheduler).toHaveYielded(['App', 'B', 'Clicked B']); // After continuing the scheduler, we finally hydrate A. expect(Scheduler).toFlushAndYield(['A']); document.body.removeChild(container); }); // @gate experimental it('hydrates at higher pri if sync did not work first time', async () => { let suspend = false; let resolve; const promise = new Promise(resolvePromise => (resolve = resolvePromise)); function Child({text}) { if ((text === 'A' || text === 'D') && suspend) { throw promise; } Scheduler.unstable_yieldValue(text); return ( { e.preventDefault(); Scheduler.unstable_yieldValue('Clicked ' + text); }}> {text} ); } function App() { Scheduler.unstable_yieldValue('App'); return (
); } const finalHTML = ReactDOMServer.renderToString(); expect(Scheduler).toHaveYielded(['App', 'A', 'B', 'C', 'D']); const container = document.createElement('div'); // We need this to be in the document since we'll dispatch events on it. document.body.appendChild(container); container.innerHTML = finalHTML; const spanD = container.getElementsByTagName('span')[3]; suspend = true; // A and D will be suspended. We'll click on D which should take // priority, after we unsuspend. const root = ReactDOM.createRoot(container, {hydrate: true}); root.render(); // Nothing has been hydrated so far. expect(Scheduler).toHaveYielded([]); // This click target cannot be hydrated yet because it's suspended. const result = dispatchClickEvent(spanD); expect(Scheduler).toHaveYielded(['App']); expect(result).toBe(true); // Continuing rendering will render B next. expect(Scheduler).toFlushAndYield(['B', 'C']); suspend = false; resolve(); await promise; // After the click, we should prioritize D and the Click first, // and only after that render A and C. expect(Scheduler).toFlushAndYield(['D', 'Clicked D', 'A']); document.body.removeChild(container); }); // @gate experimental it('hydrates at higher pri for secondary discrete events', async () => { let suspend = false; let resolve; const promise = new Promise(resolvePromise => (resolve = resolvePromise)); function Child({text}) { if ((text === 'A' || text === 'D') && suspend) { throw promise; } Scheduler.unstable_yieldValue(text); return ( { e.preventDefault(); Scheduler.unstable_yieldValue('Clicked ' + text); }}> {text} ); } function App() { Scheduler.unstable_yieldValue('App'); return (
); } const finalHTML = ReactDOMServer.renderToString(); expect(Scheduler).toHaveYielded(['App', 'A', 'B', 'C', 'D']); const container = document.createElement('div'); // We need this to be in the document since we'll dispatch events on it. document.body.appendChild(container); container.innerHTML = finalHTML; const spanA = container.getElementsByTagName('span')[0]; const spanC = container.getElementsByTagName('span')[2]; const spanD = container.getElementsByTagName('span')[3]; suspend = true; // A and D will be suspended. We'll click on D which should take // priority, after we unsuspend. const root = ReactDOM.createRoot(container, {hydrate: true}); root.render(); // Nothing has been hydrated so far. expect(Scheduler).toHaveYielded([]); // This click target cannot be hydrated yet because the first is Suspended. dispatchClickEvent(spanA); dispatchClickEvent(spanC); dispatchClickEvent(spanD); expect(Scheduler).toHaveYielded(['App']); suspend = false; resolve(); await promise; // We should prioritize hydrating A, C and D first since we clicked in // them. Only after they're done will we hydrate B. expect(Scheduler).toFlushAndYield([ 'A', 'Clicked A', 'C', 'Clicked C', 'D', 'Clicked D', // B should render last since it wasn't clicked. 'B', ]); document.body.removeChild(container); }); // @gate experimental it('hydrates the target boundary synchronously during a click (createEventHandle)', async () => { const setClick = ReactDOM.unstable_createEventHandle('click'); let isServerRendering = true; function Child({text}) { const ref = React.useRef(null); Scheduler.unstable_yieldValue(text); if (!isServerRendering) { React.useLayoutEffect(() => { return setClick(ref.current, () => { Scheduler.unstable_yieldValue('Clicked ' + text); }); }); } return {text}; } function App() { Scheduler.unstable_yieldValue('App'); return (
); } const finalHTML = ReactDOMServer.renderToString(); expect(Scheduler).toHaveYielded(['App', 'A', 'B']); const container = document.createElement('div'); // We need this to be in the document since we'll dispatch events on it. document.body.appendChild(container); container.innerHTML = finalHTML; isServerRendering = false; const root = ReactDOM.createRoot(container, {hydrate: true}); root.render(); // Nothing has been hydrated so far. expect(Scheduler).toHaveYielded([]); const span = container.getElementsByTagName('span')[1]; const target = createEventTarget(span); // This should synchronously hydrate the root App and the second suspense // boundary. target.virtualclick(); // We rendered App, B and then invoked the event without rendering A. expect(Scheduler).toHaveYielded(['App', 'B', 'Clicked B']); // After continuing the scheduler, we finally hydrate A. expect(Scheduler).toFlushAndYield(['A']); document.body.removeChild(container); }); // @gate experimental it('hydrates at higher pri if sync did not work first time (createEventHandle)', async () => { let suspend = false; let isServerRendering = true; let resolve; const promise = new Promise(resolvePromise => (resolve = resolvePromise)); const setClick = ReactDOM.unstable_createEventHandle('click'); function Child({text}) { const ref = React.useRef(null); if ((text === 'A' || text === 'D') && suspend) { throw promise; } Scheduler.unstable_yieldValue(text); if (!isServerRendering) { React.useLayoutEffect(() => { return setClick(ref.current, () => { Scheduler.unstable_yieldValue('Clicked ' + text); }); }); } return {text}; } function App() { Scheduler.unstable_yieldValue('App'); return (
); } const finalHTML = ReactDOMServer.renderToString(); expect(Scheduler).toHaveYielded(['App', 'A', 'B', 'C', 'D']); const container = document.createElement('div'); // We need this to be in the document since we'll dispatch events on it. document.body.appendChild(container); container.innerHTML = finalHTML; const spanD = container.getElementsByTagName('span')[3]; suspend = true; isServerRendering = false; // A and D will be suspended. We'll click on D which should take // priority, after we unsuspend. const root = ReactDOM.createRoot(container, {hydrate: true}); root.render(); // Nothing has been hydrated so far. expect(Scheduler).toHaveYielded([]); const target = createEventTarget(spanD); target.virtualclick(); expect(Scheduler).toHaveYielded(['App']); // Continuing rendering will render B next. expect(Scheduler).toFlushAndYield(['B', 'C']); suspend = false; resolve(); await promise; // After the click, we should prioritize D and the Click first, // and only after that render A and C. expect(Scheduler).toFlushAndYield(['D', 'Clicked D', 'A']); document.body.removeChild(container); }); // @gate experimental it('hydrates at higher pri for secondary discrete events (createEventHandle)', async () => { const setClick = ReactDOM.unstable_createEventHandle('click'); let suspend = false; let isServerRendering = true; let resolve; const promise = new Promise(resolvePromise => (resolve = resolvePromise)); function Child({text}) { const ref = React.useRef(null); if ((text === 'A' || text === 'D') && suspend) { throw promise; } Scheduler.unstable_yieldValue(text); if (!isServerRendering) { React.useLayoutEffect(() => { return setClick(ref.current, () => { Scheduler.unstable_yieldValue('Clicked ' + text); }); }); } return {text}; } function App() { Scheduler.unstable_yieldValue('App'); return (
); } const finalHTML = ReactDOMServer.renderToString(); expect(Scheduler).toHaveYielded(['App', 'A', 'B', 'C', 'D']); const container = document.createElement('div'); // We need this to be in the document since we'll dispatch events on it. document.body.appendChild(container); container.innerHTML = finalHTML; const spanA = container.getElementsByTagName('span')[0]; const spanC = container.getElementsByTagName('span')[2]; const spanD = container.getElementsByTagName('span')[3]; suspend = true; isServerRendering = false; // A and D will be suspended. We'll click on D which should take // priority, after we unsuspend. const root = ReactDOM.createRoot(container, {hydrate: true}); root.render(); // Nothing has been hydrated so far. expect(Scheduler).toHaveYielded([]); // This click target cannot be hydrated yet because the first is Suspended. createEventTarget(spanA).virtualclick(); createEventTarget(spanC).virtualclick(); createEventTarget(spanD).virtualclick(); expect(Scheduler).toHaveYielded(['App']); suspend = false; resolve(); await promise; // We should prioritize hydrating A, C and D first since we clicked in // them. Only after they're done will we hydrate B. expect(Scheduler).toFlushAndYield([ 'A', 'Clicked A', 'C', 'Clicked C', 'D', 'Clicked D', // B should render last since it wasn't clicked. 'B', ]); document.body.removeChild(container); }); // @gate experimental it('hydrates the hovered targets as higher priority for continuous events', async () => { let suspend = false; let resolve; const promise = new Promise(resolvePromise => (resolve = resolvePromise)); function Child({text}) { if ((text === 'A' || text === 'D') && suspend) { throw promise; } Scheduler.unstable_yieldValue(text); return ( { e.preventDefault(); Scheduler.unstable_yieldValue('Clicked ' + text); }} onMouseEnter={e => { e.preventDefault(); Scheduler.unstable_yieldValue('Hover ' + text); }}> {text} ); } function App() { Scheduler.unstable_yieldValue('App'); return (
); } const finalHTML = ReactDOMServer.renderToString(); expect(Scheduler).toHaveYielded(['App', 'A', 'B', 'C', 'D']); const container = document.createElement('div'); // We need this to be in the document since we'll dispatch events on it. document.body.appendChild(container); container.innerHTML = finalHTML; const spanB = container.getElementsByTagName('span')[1]; const spanC = container.getElementsByTagName('span')[2]; const spanD = container.getElementsByTagName('span')[3]; suspend = true; // A and D will be suspended. We'll click on D which should take // priority, after we unsuspend. const root = ReactDOM.createRoot(container, {hydrate: true}); root.render(); // Nothing has been hydrated so far. expect(Scheduler).toHaveYielded([]); // Click D dispatchMouseHoverEvent(spanD, null); dispatchClickEvent(spanD); // Hover over B and then C. dispatchMouseHoverEvent(spanB, spanD); dispatchMouseHoverEvent(spanC, spanB); expect(Scheduler).toHaveYielded(['App']); suspend = false; resolve(); await promise; // We should prioritize hydrating D first because we clicked it. // Next we should hydrate C since that's the current hover target. // To simplify implementation details we hydrate both B and C at // the same time since B was already scheduled. // This is ok because it will at least not continue for nested // boundary. See the next test below. expect(Scheduler).toFlushAndYield([ 'D', 'Clicked D', 'B', // Ideally this should be later. 'C', 'Hover C', 'A', ]); document.body.removeChild(container); }); // @gate experimental it('hydrates the last target path first for continuous events', async () => { let suspend = false; let resolve; const promise = new Promise(resolvePromise => (resolve = resolvePromise)); function Child({text}) { if ((text === 'A' || text === 'D') && suspend) { throw promise; } Scheduler.unstable_yieldValue(text); return ( { e.preventDefault(); Scheduler.unstable_yieldValue('Hover ' + text); }}> {text} ); } function App() { Scheduler.unstable_yieldValue('App'); return (
); } const finalHTML = ReactDOMServer.renderToString(); expect(Scheduler).toHaveYielded(['App', 'A', 'B', 'C', 'D']); const container = document.createElement('div'); // We need this to be in the document since we'll dispatch events on it. document.body.appendChild(container); container.innerHTML = finalHTML; const spanB = container.getElementsByTagName('span')[1]; const spanC = container.getElementsByTagName('span')[2]; const spanD = container.getElementsByTagName('span')[3]; suspend = true; // A and D will be suspended. We'll click on D which should take // priority, after we unsuspend. const root = ReactDOM.createRoot(container, {hydrate: true}); root.render(); // Nothing has been hydrated so far. expect(Scheduler).toHaveYielded([]); // Hover over B and then C. dispatchMouseHoverEvent(spanB, spanD); dispatchMouseHoverEvent(spanC, spanB); suspend = false; resolve(); await promise; // We should prioritize hydrating D first because we clicked it. // Next we should hydrate C since that's the current hover target. // Next it doesn't matter if we hydrate A or B first but as an // implementation detail we're currently hydrating B first since // we at one point hovered over it and we never deprioritized it. expect(Scheduler).toFlushAndYield(['App', 'C', 'Hover C', 'A', 'B', 'D']); document.body.removeChild(container); }); // @gate experimental it('hydrates the last explicitly hydrated target at higher priority', async () => { function Child({text}) { Scheduler.unstable_yieldValue(text); return {text}; } function App() { Scheduler.unstable_yieldValue('App'); return (
); } const finalHTML = ReactDOMServer.renderToString(); expect(Scheduler).toHaveYielded(['App', 'A', 'B', 'C']); const container = document.createElement('div'); container.innerHTML = finalHTML; const spanB = container.getElementsByTagName('span')[1]; const spanC = container.getElementsByTagName('span')[2]; const root = ReactDOM.createRoot(container, {hydrate: true}); root.render(); // Nothing has been hydrated so far. expect(Scheduler).toHaveYielded([]); // Increase priority of B and then C. ReactDOM.unstable_scheduleHydration(spanB); ReactDOM.unstable_scheduleHydration(spanC); // We should prioritize hydrating C first because the last added // gets highest priority followed by the next added. expect(Scheduler).toFlushAndYield(['App', 'C', 'B', 'A']); }); // @gate experimental it('hydrates before an update even if hydration moves away from it', async () => { function Child({text}) { Scheduler.unstable_yieldValue(text); return {text}; } const ChildWithBoundary = React.memo(function({text}) { return ( ); }); function App({a}) { Scheduler.unstable_yieldValue('App'); React.useEffect(() => { Scheduler.unstable_yieldValue('Commit'); }); return (
); } const finalHTML = ReactDOMServer.renderToString(); expect(Scheduler).toHaveYielded(['App', 'A', 'a', 'B', 'b', 'C', 'c']); 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 spanA = container.getElementsByTagName('span')[0]; const spanB = container.getElementsByTagName('span')[2]; const spanC = container.getElementsByTagName('span')[4]; const root = ReactDOM.createRoot(container, {hydrate: true}); act(() => { root.render(); // Hydrate the shell. expect(Scheduler).toFlushAndYieldThrough(['App', 'Commit']); // Render an update at Idle priority that needs to update A. Scheduler.unstable_runWithPriority( Scheduler.unstable_IdlePriority, () => { root.render(); }, ); // Start rendering. This will force the first boundary to hydrate // by scheduling it at one higher pri than Idle. expect(Scheduler).toFlushAndYieldThrough([ // An update was scheduled to force hydrate the boundary, but React will // continue rendering at Idle until the next time React yields. This is // fine though because it will switch to the hydration level when it // re-enters the work loop. 'App', 'AA', ]); // Hover over A which (could) schedule at one higher pri than Idle. dispatchMouseHoverEvent(spanA, null); // Before, we're done we now switch to hover over B. // This is meant to test that this doesn't cause us to forget that // we still have to hydrate A. The first boundary. // This also tests that we don't do the -1 down-prioritization of // continuous hover events because that would decrease its priority // to Idle. dispatchMouseHoverEvent(spanB, spanA); // Also click C to prioritize that even higher which resets the // priority levels. dispatchClickEvent(spanC); expect(Scheduler).toHaveYielded([ // Hydrate C first since we clicked it. 'C', 'c', ]); expect(Scheduler).toFlushAndYield([ // Finish hydration of A since we forced it to hydrate. 'A', 'a', // Also, hydrate B since we hovered over it. // It's not important which one comes first. A or B. // As long as they both happen before the Idle update. 'B', 'b', // Begin the Idle update again. 'App', 'AA', 'aa', 'Commit', ]); }); const spanA2 = container.getElementsByTagName('span')[0]; // This is supposed to have been hydrated, not replaced. expect(spanA).toBe(spanA2); document.body.removeChild(container); }); });