From b9857805c4c599f315812c971872689a992c6d56 Mon Sep 17 00:00:00 2001 From: Marco Salazar Date: Thu, 2 Dec 2021 02:41:48 +0000 Subject: [PATCH] Replay capture phase for continuous events Co-authored-by: Dan Abramov --- ...MServerSelectiveHydration-test.internal.js | 525 +++++++++++++++++- .../src/events/ReactDOMEventListener.js | 62 +-- .../src/events/ReactDOMEventReplaying.js | 31 +- 3 files changed, 558 insertions(+), 60 deletions(-) diff --git a/packages/react-dom/src/__tests__/ReactDOMServerSelectiveHydration-test.internal.js b/packages/react-dom/src/__tests__/ReactDOMServerSelectiveHydration-test.internal.js index 3adf300f9e709..6c4dafbf5c300 100644 --- a/packages/react-dom/src/__tests__/ReactDOMServerSelectiveHydration-test.internal.js +++ b/packages/react-dom/src/__tests__/ReactDOMServerSelectiveHydration-test.internal.js @@ -684,7 +684,6 @@ describe('ReactDOMServerSelectiveHydration', () => { let suspend = false; let resolve; const promise = new Promise(resolvePromise => (resolve = resolvePromise)); - function Child({text}) { if ((text === 'A' || text === 'D') && suspend) { throw promise; @@ -724,11 +723,8 @@ describe('ReactDOMServerSelectiveHydration', () => { ); } - 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); @@ -746,6 +742,156 @@ describe('ReactDOMServerSelectiveHydration', () => { 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']); + await act(async () => { + suspend = false; + resolve(); + await promise; + }); + if ( + gate( + flags => + flags.enableCapturePhaseSelectiveHydrationWithoutDiscreteEventReplay, + ) + ) { + // We should prioritize hydrating D first because we clicked it. + // but event isnt replayed + expect(Scheduler).toHaveYielded([ + 'D', + 'B', // Ideally this should be later. + 'C', + 'Hover C', + 'A', + ]); + } else { + // 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).toHaveYielded([ + 'D', + 'Clicked D', + 'B', // Ideally this should be later. + 'C', + 'Hover C', + 'A', + ]); + } + + document.body.removeChild(container); + }); + + it('replays capture phase for continuous events and respects stopPropagation', 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('Capture Clicked ' + text); + }} + onClick={e => { + e.preventDefault(); + Scheduler.unstable_yieldValue('Clicked ' + text); + }} + onMouseEnter={e => { + e.preventDefault(); + Scheduler.unstable_yieldValue('Mouse Enter ' + text); + }} + onMouseOut={e => { + e.preventDefault(); + Scheduler.unstable_yieldValue('Mouse Out ' + text); + }} + onMouseOutCapture={e => { + e.preventDefault(); + e.stopPropagation(); + Scheduler.unstable_yieldValue('Mouse Out Capture ' + text); + }} + onMouseOverCapture={e => { + e.preventDefault(); + e.stopPropagation(); + Scheduler.unstable_yieldValue('Mouse Over Capture ' + text); + }} + onMouseOver={e => { + e.preventDefault(); + Scheduler.unstable_yieldValue('Mouse Over ' + text); + }}> +
{ + e.preventDefault(); + Scheduler.unstable_yieldValue('Mouse Over Capture Inner ' + text); + }}> + {text} +
+
+ ); + } + + function App() { + Scheduler.unstable_yieldValue('App'); + return ( +
{ + e.preventDefault(); + Scheduler.unstable_yieldValue('Capture Clicked Parent'); + }} + onMouseOverCapture={e => { + Scheduler.unstable_yieldValue('Mouse Over Capture Parent'); + }}> + + + + + + + + + + + + +
+ ); + } + + 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 = document.getElementById('B').firstChild; + const spanC = document.getElementById('C').firstChild; + const spanD = document.getElementById('D').firstChild; + + suspend = true; + + // A and D will be suspended. We'll click on D which should take + // priority, after we unsuspend. + ReactDOM.hydrateRoot(container, ); + // Nothing has been hydrated so far. expect(Scheduler).toHaveYielded([]); @@ -776,7 +922,14 @@ describe('ReactDOMServerSelectiveHydration', () => { 'D', 'B', // Ideally this should be later. 'C', - 'Hover C', + // Mouse out events aren't replayed + // 'Mouse Out Capture B', + // 'Mouse Out B', + 'Mouse Over Capture Parent', + 'Mouse Over Capture C', + // Stop propagation stops these + // 'Mouse Over Capture Inner C', + // 'Mouse Over C', 'A', ]); } else { @@ -791,11 +944,371 @@ describe('ReactDOMServerSelectiveHydration', () => { 'Clicked D', 'B', // Ideally this should be later. 'C', - 'Hover C', + // Capture phase isn't replayed + // Mouseout isn't replayed + 'Mouse Over C', + 'Mouse Enter C', 'A', ]); } + // This test shows existing quirk where stopPropagation on mouseout + // prevents mouseEnter from firing + dispatchMouseHoverEvent(spanC, spanB); + expect(Scheduler).toHaveYielded([ + 'Mouse Out Capture B', + // stopPropagation stops these + // 'Mouse Out B', + // 'Mouse Enter C', + 'Mouse Over Capture Parent', + 'Mouse Over Capture C', + // Stop propagation stops these + // 'Mouse Over Capture Inner C', + // 'Mouse Over C', + ]); + + document.body.removeChild(container); + }); + + describe('can handle replaying events as part of multiple instances of React', () => { + let resolveInner; + let resolveOuter; + let innerPromise; + let outerPromise; + let OuterScheduler; + let InnerScheduler; + let innerDiv; + + beforeEach(async () => { + document.body.innerHTML = ''; + jest.resetModuleRegistry(); + let OuterReactDOM; + let InnerReactDOM; + jest.isolateModules(() => { + OuterReactDOM = require('react-dom'); + OuterScheduler = require('scheduler'); + }); + jest.isolateModules(() => { + InnerReactDOM = require('react-dom'); + InnerScheduler = require('scheduler'); + }); + + expect(OuterReactDOM).not.toBe(InnerReactDOM); + expect(OuterScheduler).not.toBe(InnerScheduler); + + const outerContainer = document.createElement('div'); + const innerContainer = document.createElement('div'); + + let suspendOuter = false; + outerPromise = new Promise(res => { + resolveOuter = () => { + suspendOuter = false; + res(); + }; + }); + + function Outer() { + if (suspendOuter) { + OuterScheduler.unstable_yieldValue('Suspend Outer'); + throw outerPromise; + } + OuterScheduler.unstable_yieldValue('Outer'); + const innerRoot = outerContainer.querySelector('#inner-root'); + return ( +
{ + Scheduler.unstable_yieldValue('Outer Mouse Enter'); + }} + dangerouslySetInnerHTML={{ + __html: innerRoot ? innerRoot.innerHTML : '', + }} + /> + ); + } + const OuterApp = () => { + return ( + Loading
}> + + + ); + }; + + let suspendInner = false; + innerPromise = new Promise(res => { + resolveInner = () => { + suspendInner = false; + res(); + }; + }); + function Inner() { + if (suspendInner) { + InnerScheduler.unstable_yieldValue('Suspend Inner'); + throw innerPromise; + } + InnerScheduler.unstable_yieldValue('Inner'); + return ( +
{ + Scheduler.unstable_yieldValue('Inner Mouse Enter'); + }} + /> + ); + } + const InnerApp = () => { + return ( + Loading
}> + + + ); + }; + + document.body.appendChild(outerContainer); + const outerHTML = ReactDOMServer.renderToString(); + outerContainer.innerHTML = outerHTML; + + const innerWrapper = document.querySelector('#inner-root'); + innerWrapper.appendChild(innerContainer); + const innerHTML = ReactDOMServer.renderToString(); + innerContainer.innerHTML = innerHTML; + + expect(OuterScheduler).toHaveYielded(['Outer']); + expect(InnerScheduler).toHaveYielded(['Inner']); + + suspendOuter = true; + suspendInner = true; + + OuterReactDOM.hydrateRoot(outerContainer, ); + InnerReactDOM.hydrateRoot(innerContainer, ); + + expect(OuterScheduler).toFlushAndYield(['Suspend Outer']); + expect(InnerScheduler).toFlushAndYield(['Suspend Inner']); + + innerDiv = document.querySelector('#inner'); + + dispatchClickEvent(innerDiv); + + await act(async () => { + jest.runAllTimers(); + Scheduler.unstable_flushAllWithoutAsserting(); + OuterScheduler.unstable_flushAllWithoutAsserting(); + InnerScheduler.unstable_flushAllWithoutAsserting(); + }); + + expect(OuterScheduler).toHaveYielded(['Suspend Outer']); + if ( + gate( + flags => + flags.enableCapturePhaseSelectiveHydrationWithoutDiscreteEventReplay, + ) + ) { + // InnerApp doesn't see the event because OuterApp calls stopPropagation in + // capture phase since the event is blocked on suspended component + expect(InnerScheduler).toHaveYielded([]); + } else { + // no stopPropagation + expect(InnerScheduler).toHaveYielded(['Suspend Inner']); + } + + expect(Scheduler).toHaveYielded([]); + }); + afterEach(async () => { + document.body.innerHTML = ''; + }); + + // @gate enableCapturePhaseSelectiveHydrationWithoutDiscreteEventReplay + it('Inner hydrates first then Outer', async () => { + dispatchMouseHoverEvent(innerDiv); + + await act(async () => { + resolveInner(); + await innerPromise; + jest.runAllTimers(); + Scheduler.unstable_flushAllWithoutAsserting(); + OuterScheduler.unstable_flushAllWithoutAsserting(); + InnerScheduler.unstable_flushAllWithoutAsserting(); + }); + + expect(OuterScheduler).toHaveYielded(['Suspend Outer']); + // Inner App renders because it is unblocked + expect(InnerScheduler).toHaveYielded(['Inner']); + // No event is replayed yet + expect(Scheduler).toHaveYielded([]); + + dispatchMouseHoverEvent(innerDiv); + expect(OuterScheduler).toHaveYielded([]); + expect(InnerScheduler).toHaveYielded([]); + // No event is replayed yet + expect(Scheduler).toHaveYielded([]); + + await act(async () => { + resolveOuter(); + await outerPromise; + jest.runAllTimers(); + Scheduler.unstable_flushAllWithoutAsserting(); + OuterScheduler.unstable_flushAllWithoutAsserting(); + InnerScheduler.unstable_flushAllWithoutAsserting(); + }); + + // Nothing happens to inner app yet. + // Its blocked on the outer app replaying the event + expect(InnerScheduler).toHaveYielded([]); + // Outer hydrates and schedules Replay + expect(OuterScheduler).toHaveYielded(['Outer']); + // No event is replayed yet + expect(Scheduler).toHaveYielded([]); + + // fire scheduled Replay + await act(async () => { + jest.runAllTimers(); + Scheduler.unstable_flushAllWithoutAsserting(); + OuterScheduler.unstable_flushAllWithoutAsserting(); + InnerScheduler.unstable_flushAllWithoutAsserting(); + }); + + // First Inner Mouse Enter fires then Outer Mouse Enter + expect(Scheduler).toHaveYielded([ + 'Inner Mouse Enter', + 'Outer Mouse Enter', + ]); + }); + + // @gate enableCapturePhaseSelectiveHydrationWithoutDiscreteEventReplay + it('Outer hydrates first then Inner', async () => { + dispatchMouseHoverEvent(innerDiv); + + await act(async () => { + resolveOuter(); + await outerPromise; + Scheduler.unstable_flushAllWithoutAsserting(); + OuterScheduler.unstable_flushAllWithoutAsserting(); + InnerScheduler.unstable_flushAllWithoutAsserting(); + }); + + // Outer resolves and scheduled replay + expect(OuterScheduler).toHaveYielded(['Outer']); + // Inner App is still blocked + expect(InnerScheduler).toHaveYielded([]); + + // Replay outer event + await act(async () => { + Scheduler.unstable_flushAllWithoutAsserting(); + OuterScheduler.unstable_flushAllWithoutAsserting(); + InnerScheduler.unstable_flushAllWithoutAsserting(); + }); + + // Inner is still blocked so when Outer replays the event in capture phase + // inner ends up caling stopPropagation + expect(Scheduler).toHaveYielded([]); + expect(OuterScheduler).toHaveYielded([]); + expect(InnerScheduler).toHaveYielded(['Suspend Inner']); + + dispatchMouseHoverEvent(innerDiv); + expect(OuterScheduler).toHaveYielded([]); + expect(InnerScheduler).toHaveYielded([]); + expect(Scheduler).toHaveYielded([]); + + await act(async () => { + resolveInner(); + await innerPromise; + Scheduler.unstable_flushAllWithoutAsserting(); + OuterScheduler.unstable_flushAllWithoutAsserting(); + InnerScheduler.unstable_flushAllWithoutAsserting(); + }); + + // Inner hydrates + expect(InnerScheduler).toHaveYielded(['Inner']); + // Outer was hydrated earlier + expect(OuterScheduler).toHaveYielded([]); + + await act(async () => { + Scheduler.unstable_flushAllWithoutAsserting(); + OuterScheduler.unstable_flushAllWithoutAsserting(); + InnerScheduler.unstable_flushAllWithoutAsserting(); + }); + + // First Inner Mouse Enter fires then Outer Mouse Enter + expect(Scheduler).toHaveYielded([ + 'Inner Mouse Enter', + 'Outer Mouse Enter', + ]); + }); + }); + + // @gate enableCapturePhaseSelectiveHydrationWithoutDiscreteEventReplay + it('replays event with null target when tree is dismounted', async () => { + let suspend = false; + let resolve; + const promise = new Promise(resolvePromise => { + resolve = () => { + suspend = false; + resolvePromise(); + }; + }); + + function Child() { + if (suspend) { + throw promise; + } + Scheduler.unstable_yieldValue('Child'); + return ( +
{ + Scheduler.unstable_yieldValue('on mouse over'); + }}> + Child +
+ ); + } + + function App() { + return ( + + + + ); + } + + const finalHTML = ReactDOMServer.renderToString(); + expect(Scheduler).toHaveYielded(['Child']); + + const container = document.createElement('div'); + + document.body.appendChild(container); + container.innerHTML = finalHTML; + suspend = true; + + ReactDOM.hydrateRoot(container, ); + + const childDiv = container.firstElementChild; + dispatchMouseHoverEvent(childDiv); + + // Not hydrated so event is saved for replay and stopPropagation is called + expect(Scheduler).toHaveYielded([]); + + resolve(); + Scheduler.unstable_flushNumberOfYields(1); + expect(Scheduler).toHaveYielded(['Child']); + + Scheduler.unstable_scheduleCallback( + Scheduler.unstable_ImmediatePriority, + () => { + container.removeChild(childDiv); + + const container2 = document.createElement('div'); + container2.addEventListener('mouseover', () => { + Scheduler.unstable_yieldValue('container2 mouse over'); + }); + container2.appendChild(childDiv); + }, + ); + Scheduler.unstable_flushAllWithoutAsserting(); + + // Even though the tree is remove the event is still dispatched with native event handler + // on the container firing. + expect(Scheduler).toHaveYielded(['container2 mouse over']); + document.body.removeChild(container); }); diff --git a/packages/react-dom/src/events/ReactDOMEventListener.js b/packages/react-dom/src/events/ReactDOMEventListener.js index bfca2a9d2e7a8..f228090221ff0 100644 --- a/packages/react-dom/src/events/ReactDOMEventListener.js +++ b/packages/react-dom/src/events/ReactDOMEventListener.js @@ -267,31 +267,6 @@ function dispatchEventWithEnableCapturePhaseSelectiveHydrationWithoutDiscreteEve targetContainer: EventTarget, nativeEvent: AnyNativeEvent, ) { - // TODO: replaying capture phase events is currently broken - // because we used to do it during top-level native bubble handlers - // but now we use different bubble and capture handlers. - // In eager mode, we attach capture listeners early, so we need - // to filter them out until we fix the logic to handle them correctly. - const allowReplay = (eventSystemFlags & IS_CAPTURE_PHASE) === 0; - - if ( - allowReplay && - hasQueuedDiscreteEvents() && - isDiscreteEventThatRequiresHydration(domEventName) - ) { - // If we already have a queue of discrete events, and this is another discrete - // event, then we can't dispatch it regardless of its target, since they - // need to dispatch in order. - queueDiscreteEvent( - null, // Flags that we're not actually blocked on anything as far as we know. - domEventName, - eventSystemFlags, - targetContainer, - nativeEvent, - ); - return; - } - let blockedOn = findInstanceBlockingEvent( domEventName, eventSystemFlags, @@ -306,28 +281,25 @@ function dispatchEventWithEnableCapturePhaseSelectiveHydrationWithoutDiscreteEve return_targetInst, targetContainer, ); - if (allowReplay) { - clearIfContinuousEvent(domEventName, nativeEvent); - } + clearIfContinuousEvent(domEventName, nativeEvent); return; } - if (allowReplay) { - if ( - queueIfContinuousEvent( - blockedOn, - domEventName, - eventSystemFlags, - targetContainer, - nativeEvent, - ) - ) { - return; - } - // We need to clear only if we didn't queue because - // queueing is accumulative. - clearIfContinuousEvent(domEventName, nativeEvent); + if ( + queueIfContinuousEvent( + blockedOn, + domEventName, + eventSystemFlags, + targetContainer, + nativeEvent, + ) + ) { + nativeEvent.stopPropagation(); + return; } + // We need to clear only if we didn't queue because + // queueing is accumulative. + clearIfContinuousEvent(domEventName, nativeEvent); if ( eventSystemFlags & IS_CAPTURE_PHASE && @@ -358,10 +330,10 @@ function dispatchEventWithEnableCapturePhaseSelectiveHydrationWithoutDiscreteEve } blockedOn = nextBlockedOn; } - if (blockedOn) { + if (blockedOn !== null) { nativeEvent.stopPropagation(); - return; } + return; } // This is not replayable so we'll invoke it but without a target, diff --git a/packages/react-dom/src/events/ReactDOMEventReplaying.js b/packages/react-dom/src/events/ReactDOMEventReplaying.js index bfcc7a4596f41..744f5dfda9d9b 100644 --- a/packages/react-dom/src/events/ReactDOMEventReplaying.js +++ b/packages/react-dom/src/events/ReactDOMEventReplaying.js @@ -472,15 +472,26 @@ function attemptReplayContinuousQueuedEvent( queuedEvent.nativeEvent, ); if (nextBlockedOn === null) { - setReplayingEvent(queuedEvent.nativeEvent); - dispatchEventForPluginEventSystem( - queuedEvent.domEventName, - queuedEvent.eventSystemFlags, - queuedEvent.nativeEvent, - return_targetInst, - targetContainer, - ); - resetReplayingEvent(); + if (enableCapturePhaseSelectiveHydrationWithoutDiscreteEventReplay) { + const nativeEvent = queuedEvent.nativeEvent; + const nativeEventClone = new nativeEvent.constructor( + nativeEvent.type, + (nativeEvent: any), + ); + setReplayingEvent(nativeEventClone); + nativeEvent.target.dispatchEvent(nativeEventClone); + resetReplayingEvent(); + } else { + setReplayingEvent(queuedEvent.nativeEvent); + dispatchEventForPluginEventSystem( + queuedEvent.domEventName, + queuedEvent.eventSystemFlags, + queuedEvent.nativeEvent, + return_targetInst, + targetContainer, + ); + resetReplayingEvent(); + } } else { // We're still blocked. Try again later. const fiber = getInstanceFromNode(nextBlockedOn); @@ -532,6 +543,8 @@ function replayUnblockedEvents() { nextDiscreteEvent.nativeEvent, ); if (nextBlockedOn === null) { + // This whole function is in !enableCapturePhaseSelectiveHydrationWithoutDiscreteEventReplay, + // so we don't need the new replay behavior code branch. setReplayingEvent(nextDiscreteEvent.nativeEvent); dispatchEventForPluginEventSystem( nextDiscreteEvent.domEventName,