Repository: facebook/react
Pull request: [Alt] Replay capture phase for continuous events

This is basically https://github.com/facebook/react/pull/22680 (tests are copypasta), split into smaller pieces. There's a bit less abstraction but all important changes should be the same. This PR is scoped to behavior changes. It depends on stacked refactorings.

**All behavior changes are behind flag-only branches.** (Assuming earlier refactorings are safe.)

Changed files:
filename: packages/react-dom/src/__tests__/ReactDOMServerSelectiveHydration-test.internal.js
patch: @@ -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', () => {
         </div>
       );
     }
-
     const finalHTML = ReactDOMServer.renderToString(<App />);
-
     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(<App />);
 
+    // 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 (
+        <span
+          id={text}
+          onClickCapture={e => {
+            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);
+          }}>
+          <div
+            onMouseOverCapture={e => {
+              e.preventDefault();
+              Scheduler.unstable_yieldValue('Mouse Over Capture Inner ' + text);
+            }}>
+            {text}
+          </div>
+        </span>
+      );
+    }
+
+    function App() {
+      Scheduler.unstable_yieldValue('App');
+      return (
+        <div
+          onClickCapture={e => {
+            e.preventDefault();
+            Scheduler.unstable_yieldValue('Capture Clicked Parent');
+          }}
+          onMouseOverCapture={e => {
+            Scheduler.unstable_yieldValue('Mouse Over Capture Parent');
+          }}>
+          <Suspense fallback="Loading...">
+            <Child text="A" />
+          </Suspense>
+          <Suspense fallback="Loading...">
+            <Child text="B" />
+          </Suspense>
+          <Suspense fallback="Loading...">
+            <Child text="C" />
+          </Suspense>
+          <Suspense fallback="Loading...">
+            <Child text="D" />
+          </Suspense>
+        </div>
+      );
+    }
+
+    const finalHTML = ReactDOMServer.renderToString(<App />);
+
+    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, <App />);
+
     // 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 (
+          <div
+            id="inner-root"
+            onMouseEnter={() => {
+              Scheduler.unstable_yieldValue('Outer Mouse Enter');
+            }}
+            dangerouslySetInnerHTML={{
+              __html: innerRoot ? innerRoot.innerHTML : '',
+            }}
+          />
+        );
+      }
+      const OuterApp = () => {
+        return (
+          <Suspense fallback={<div>Loading</div>}>
+            <Outer />
+          </Suspense>
+        );
+      };
+
+      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 (
+          <div
+            id="inner"
+            onMouseEnter={() => {
+              Scheduler.unstable_yieldValue('Inner Mouse Enter');
+            }}
+          />
+        );
+      }
+      const InnerApp = () => {
+        return (
+          <Suspense fallback={<div>Loading</div>}>
+            <Inner />
+          </Suspense>
+        );
+      };
+
+      document.body.appendChild(outerContainer);
+      const outerHTML = ReactDOMServer.renderToString(<OuterApp />);
+      outerContainer.innerHTML = outerHTML;
+
+      const innerWrapper = document.querySelector('#inner-root');
+      innerWrapper.appendChild(innerContainer);
+      const innerHTML = ReactDOMServer.renderToString(<InnerApp />);
+      innerContainer.innerHTML = innerHTML;
+
+      expect(OuterScheduler).toHaveYielded(['Outer']);
+      expect(InnerScheduler).toHaveYielded(['Inner']);
+
+      suspendOuter = true;
+      suspendInner = true;
+
+      OuterReactDOM.hydrateRoot(outerContainer, <OuterApp />);
+      InnerReactDOM.hydrateRoot(innerContainer, <InnerApp />);
+
+      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 (
+        <div
+          onMouseOver={() => {
+            Scheduler.unstable_yieldValue('on mouse over');
+          }}>
+          Child
+        </div>
+      );
+    }
+
+    function App() {
+      return (
+        <Suspense>
+          <Child />
+        </Suspense>
+      );
+    }
+
+    const finalHTML = ReactDOMServer.renderToString(<App />);
+    expect(Scheduler).toHaveYielded(['Child']);
+
+    const container = document.createElement('div');
+
+    document.body.appendChild(container);
+    container.innerHTML = finalHTML;
+    suspend = true;
+
+    ReactDOM.hydrateRoot(container, <App />);
+
+    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);
   });
 

filename: packages/react-dom/src/events/ReactDOMEventListener.js
patch: @@ -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,

filename: packages/react-dom/src/events/ReactDOMEventReplaying.js
patch: @@ -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,