diff --git a/packages/react-dom/src/__tests__/ReactDOMServerPartialHydration-test.internal.js b/packages/react-dom/src/__tests__/ReactDOMServerPartialHydration-test.internal.js index 66bfdf9a39ed4..19846522ee44d 100644 --- a/packages/react-dom/src/__tests__/ReactDOMServerPartialHydration-test.internal.js +++ b/packages/react-dom/src/__tests__/ReactDOMServerPartialHydration-test.internal.js @@ -2415,8 +2415,18 @@ describe('ReactDOMServerPartialHydration', () => { await promise; }); - expect(clicks).toBe(0); - expect(container.textContent).toBe('Click meHello'); + if ( + gate( + flags => + flags.enableCapturePhaseSelectiveHydrationWithoutDiscreteEventReplay, + ) + ) { + expect(clicks).toBe(0); + expect(container.textContent).toBe('Click meHello'); + } else { + expect(clicks).toBe(1); + expect(container.textContent).toBe('Hello'); + } document.body.removeChild(container); }); @@ -2498,7 +2508,17 @@ describe('ReactDOMServerPartialHydration', () => { await promise; }); - expect(onEvent).toHaveBeenCalledTimes(0); + if ( + gate( + flags => + flags.enableCapturePhaseSelectiveHydrationWithoutDiscreteEventReplay, + ) + ) { + expect(onEvent).toHaveBeenCalledTimes(0); + } else { + expect(onEvent).toHaveBeenCalledTimes(2); + } + document.body.removeChild(container); }); @@ -2578,7 +2598,16 @@ describe('ReactDOMServerPartialHydration', () => { await promise; }); - expect(clicks).toBe(0); + if ( + gate( + flags => + flags.enableCapturePhaseSelectiveHydrationWithoutDiscreteEventReplay, + ) + ) { + expect(clicks).toBe(0); + } else { + expect(clicks).toBe(2); + } document.body.removeChild(container); }); @@ -2663,7 +2692,17 @@ describe('ReactDOMServerPartialHydration', () => { resolve(); await promise; }); - expect(onEvent).toHaveBeenCalledTimes(0); + if ( + gate( + flags => + flags.enableCapturePhaseSelectiveHydrationWithoutDiscreteEventReplay, + ) + ) { + expect(onEvent).toHaveBeenCalledTimes(0); + } else { + expect(onEvent).toHaveBeenCalledTimes(2); + } + document.body.removeChild(container); }); @@ -2734,8 +2773,19 @@ describe('ReactDOMServerPartialHydration', () => { await promise; }); - expect(clicksOnChild).toBe(0); - expect(clicksOnParent).toBe(0); + if ( + gate( + flags => + flags.enableCapturePhaseSelectiveHydrationWithoutDiscreteEventReplay, + ) + ) { + expect(clicksOnChild).toBe(0); + expect(clicksOnParent).toBe(0); + } else { + expect(clicksOnChild).toBe(1); + // This will be zero due to the stopPropagation. + expect(clicksOnParent).toBe(0); + } document.body.removeChild(container); }); @@ -2811,7 +2861,16 @@ describe('ReactDOMServerPartialHydration', () => { }); // We're now full hydrated. - expect(clicks).toBe(0); + if ( + gate( + flags => + flags.enableCapturePhaseSelectiveHydrationWithoutDiscreteEventReplay, + ) + ) { + expect(clicks).toBe(0); + } else { + expect(clicks).toBe(1); + } document.body.removeChild(parentContainer); }); @@ -3080,9 +3139,19 @@ describe('ReactDOMServerPartialHydration', () => { await promise; }); - // discrete event not replayed - expect(submits).toBe(0); - expect(container.textContent).toBe('Click meHello'); + if ( + gate( + flags => + flags.enableCapturePhaseSelectiveHydrationWithoutDiscreteEventReplay, + ) + ) { + // discrete event not replayed + expect(submits).toBe(0); + expect(container.textContent).toBe('Click meHello'); + } else { + expect(submits).toBe(1); + expect(container.textContent).toBe('Hello'); + } document.body.removeChild(container); }); diff --git a/packages/react-dom/src/__tests__/ReactDOMServerSelectiveHydration-test.internal.js b/packages/react-dom/src/__tests__/ReactDOMServerSelectiveHydration-test.internal.js index aec22acb3374d..91321ea6dd5bf 100644 --- a/packages/react-dom/src/__tests__/ReactDOMServerSelectiveHydration-test.internal.js +++ b/packages/react-dom/src/__tests__/ReactDOMServerSelectiveHydration-test.internal.js @@ -268,7 +268,18 @@ describe('ReactDOMServerSelectiveHydration', () => { await promise; }); - expect(Scheduler).toHaveYielded(['D', 'A']); + if ( + gate( + flags => + flags.enableCapturePhaseSelectiveHydrationWithoutDiscreteEventReplay, + ) + ) { + expect(Scheduler).toHaveYielded(['D', 'A']); + } else { + // After the click, we should prioritize D and the Click first, + // and only after that render A and C. + expect(Scheduler).toHaveYielded(['D', 'Clicked D', 'A']); + } document.body.removeChild(container); }); @@ -342,7 +353,16 @@ describe('ReactDOMServerSelectiveHydration', () => { dispatchClickEvent(spanC); dispatchClickEvent(spanD); - expect(Scheduler).toHaveYielded(['App', 'C', 'Clicked C']); + if ( + gate( + flags => + flags.enableCapturePhaseSelectiveHydrationWithoutDiscreteEventReplay, + ) + ) { + expect(Scheduler).toHaveYielded(['App', 'C', 'Clicked C']); + } else { + expect(Scheduler).toHaveYielded(['App']); + } await act(async () => { suspend = false; @@ -350,12 +370,29 @@ describe('ReactDOMServerSelectiveHydration', () => { await promise; }); - expect(Scheduler).toHaveYielded([ - 'A', - 'D', - // B should render last since it wasn't clicked. - 'B', - ]); + if ( + ReactFeatureFlags.enableCapturePhaseSelectiveHydrationWithoutDiscreteEventReplay + ) { + expect(Scheduler).toHaveYielded([ + 'A', + 'D', + // B should render last since it wasn't clicked. + 'B', + ]); + } else { + // 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).toHaveYielded([ + 'A', + 'Clicked A', + 'C', + 'Clicked C', + 'D', + 'Clicked D', + // B should render last since it wasn't clicked. + 'B', + ]); + } document.body.removeChild(container); }); @@ -509,8 +546,17 @@ describe('ReactDOMServerSelectiveHydration', () => { resolve(); await promise; }); - // no replay - expect(Scheduler).toHaveYielded(['D', 'A']); + if ( + gate( + flags => + flags.enableCapturePhaseSelectiveHydrationWithoutDiscreteEventReplay, + ) + ) { + // no replay + expect(Scheduler).toHaveYielded(['D', 'A']); + } else { + expect(Scheduler).toHaveYielded(['D', 'Clicked D', 'A']); + } document.body.removeChild(container); }); @@ -589,19 +635,42 @@ describe('ReactDOMServerSelectiveHydration', () => { createEventTarget(spanC).virtualclick(); createEventTarget(spanD).virtualclick(); - expect(Scheduler).toHaveYielded(['App', 'C', 'Clicked C']); + if ( + ReactFeatureFlags.enableCapturePhaseSelectiveHydrationWithoutDiscreteEventReplay + ) { + expect(Scheduler).toHaveYielded(['App', 'C', 'Clicked C']); + } else { + expect(Scheduler).toHaveYielded(['App']); + } await act(async () => { suspend = false; resolve(); await promise; }); - expect(Scheduler).toHaveYielded([ - 'A', - 'D', - // B should render last since it wasn't clicked. - 'B', - ]); + if ( + ReactFeatureFlags.enableCapturePhaseSelectiveHydrationWithoutDiscreteEventReplay + ) { + expect(Scheduler).toHaveYielded([ + 'A', + 'D', + // B should render last since it wasn't clicked. + 'B', + ]); + } else { + // 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).toHaveYielded([ + 'A', + 'Clicked A', + 'C', + 'Clicked C', + 'D', + 'Clicked D', + // B should render last since it wasn't clicked. + 'B', + ]); + } document.body.removeChild(container); }); @@ -681,15 +750,37 @@ describe('ReactDOMServerSelectiveHydration', () => { resolve(); await promise; }); - // 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', - ]); + 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); }); @@ -813,22 +904,47 @@ describe('ReactDOMServerSelectiveHydration', () => { await promise; }); - // 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', - // 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', - ]); + 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', + // 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 { + // 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', + // 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 @@ -975,10 +1091,19 @@ describe('ReactDOMServerSelectiveHydration', () => { }); expect(OuterScheduler).toHaveYielded(['Suspend Outer']); - - // InnerApp doesn't see the event because OuterApp calls stopPropagation in - // capture phase since the event is blocked on suspended component - expect(InnerScheduler).toHaveYielded([]); + 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([]); }); @@ -986,6 +1111,7 @@ describe('ReactDOMServerSelectiveHydration', () => { document.body.innerHTML = ''; }); + // @gate enableCapturePhaseSelectiveHydrationWithoutDiscreteEventReplay it('Inner hydrates first then Outer', async () => { dispatchMouseHoverEvent(innerDiv); @@ -1042,6 +1168,7 @@ describe('ReactDOMServerSelectiveHydration', () => { ]); }); + // @gate enableCapturePhaseSelectiveHydrationWithoutDiscreteEventReplay it('Outer hydrates first then Inner', async () => { dispatchMouseHoverEvent(innerDiv); @@ -1103,6 +1230,7 @@ describe('ReactDOMServerSelectiveHydration', () => { }); }); + // @gate enableCapturePhaseSelectiveHydrationWithoutDiscreteEventReplay it('replays event with null target when tree is dismounted', async () => { let suspend = false; let resolve; @@ -1421,6 +1549,7 @@ describe('ReactDOMServerSelectiveHydration', () => { document.body.removeChild(container); }); + // @gate enableCapturePhaseSelectiveHydrationWithoutDiscreteEventReplay it('fires capture event handlers and native events if content is hydratable during discrete event', async () => { spyOnDev(console, 'error'); function Child({text}) { @@ -1504,6 +1633,7 @@ describe('ReactDOMServerSelectiveHydration', () => { document.body.removeChild(container); }); + // @gate enableCapturePhaseSelectiveHydrationWithoutDiscreteEventReplay it('does not propagate discrete event if it cannot be synchronously hydrated', async () => { let triggeredParent = false; let triggeredChild = false; diff --git a/packages/react-dom/src/client/ReactDOM.js b/packages/react-dom/src/client/ReactDOM.js index 9ec6f67cbd7d7..aaf429cad54c0 100644 --- a/packages/react-dom/src/client/ReactDOM.js +++ b/packages/react-dom/src/client/ReactDOM.js @@ -37,6 +37,7 @@ import { flushControlled, injectIntoDevTools, attemptSynchronousHydration, + attemptDiscreteHydration, attemptContinuousHydration, attemptHydrationAtCurrentPriority, } from 'react-reconciler/src/ReactFiberReconciler'; @@ -58,6 +59,7 @@ import { import {restoreControlledState} from './ReactDOMComponent'; import { setAttemptSynchronousHydration, + setAttemptDiscreteHydration, setAttemptContinuousHydration, setAttemptHydrationAtCurrentPriority, setGetCurrentUpdatePriority, @@ -71,6 +73,7 @@ import { } from '../events/ReactDOMControlledComponent'; setAttemptSynchronousHydration(attemptSynchronousHydration); +setAttemptDiscreteHydration(attemptDiscreteHydration); setAttemptContinuousHydration(attemptContinuousHydration); setAttemptHydrationAtCurrentPriority(attemptHydrationAtCurrentPriority); setGetCurrentUpdatePriority(getCurrentUpdatePriority); diff --git a/packages/react-dom/src/events/ReactDOMEventListener.js b/packages/react-dom/src/events/ReactDOMEventListener.js index 6ebb56e740417..e2974586ec6ac 100644 --- a/packages/react-dom/src/events/ReactDOMEventListener.js +++ b/packages/react-dom/src/events/ReactDOMEventListener.js @@ -11,8 +11,11 @@ import type {AnyNativeEvent} from '../events/PluginModuleType'; import type {FiberRoot} from 'react-reconciler/src/ReactInternalTypes'; import type {Container, SuspenseInstance} from '../client/ReactDOMHostConfig'; import type {DOMEventName} from '../events/DOMEventNames'; +import {enableCapturePhaseSelectiveHydrationWithoutDiscreteEventReplay} from 'shared/ReactFeatureFlags'; import { isDiscreteEventThatRequiresHydration, + queueDiscreteEvent, + hasQueuedDiscreteEvents, clearIfContinuousEvent, queueIfContinuousEvent, attemptSynchronousHydration, @@ -143,7 +146,123 @@ function dispatchContinuousEvent( } } -function dispatchEvent( +export function dispatchEvent( + domEventName: DOMEventName, + eventSystemFlags: EventSystemFlags, + targetContainer: EventTarget, + nativeEvent: AnyNativeEvent, +): void { + if (!_enabled) { + return; + } + if (enableCapturePhaseSelectiveHydrationWithoutDiscreteEventReplay) { + dispatchEventWithEnableCapturePhaseSelectiveHydrationWithoutDiscreteEventReplay( + domEventName, + eventSystemFlags, + targetContainer, + nativeEvent, + ); + } else { + dispatchEventOriginal( + domEventName, + eventSystemFlags, + targetContainer, + nativeEvent, + ); + } +} + +function dispatchEventOriginal( + domEventName: DOMEventName, + eventSystemFlags: EventSystemFlags, + 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; + } + + const blockedOn = findInstanceBlockingEvent( + domEventName, + eventSystemFlags, + targetContainer, + nativeEvent, + ); + if (blockedOn === null) { + dispatchEventForPluginEventSystem( + domEventName, + eventSystemFlags, + nativeEvent, + return_targetInst, + targetContainer, + ); + if (allowReplay) { + clearIfContinuousEvent(domEventName, nativeEvent); + } + return; + } + + if (allowReplay) { + if (isDiscreteEventThatRequiresHydration(domEventName)) { + // This this to be replayed later once the target is available. + queueDiscreteEvent( + blockedOn, + domEventName, + eventSystemFlags, + targetContainer, + nativeEvent, + ); + return; + } + 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); + } + + // This is not replayable so we'll invoke it but without a target, + // in case the event system needs to trace it. + dispatchEventForPluginEventSystem( + domEventName, + eventSystemFlags, + nativeEvent, + null, + targetContainer, + ); +} + +function dispatchEventWithEnableCapturePhaseSelectiveHydrationWithoutDiscreteEventReplay( domEventName: DOMEventName, eventSystemFlags: EventSystemFlags, targetContainer: EventTarget, @@ -229,7 +348,7 @@ function dispatchEvent( ); } -let return_targetInst = null; +export let return_targetInst = null; // Returns a SuspenseInstance or Container if it's blocked. // The return_targetInst field above is conceptually part of the return value. diff --git a/packages/react-dom/src/events/ReactDOMEventReplaying.js b/packages/react-dom/src/events/ReactDOMEventReplaying.js index db79c79d8fad1..9de82a99a7be3 100644 --- a/packages/react-dom/src/events/ReactDOMEventReplaying.js +++ b/packages/react-dom/src/events/ReactDOMEventReplaying.js @@ -14,7 +14,10 @@ import type {EventSystemFlags} from './EventSystemFlags'; import type {FiberRoot} from 'react-reconciler/src/ReactInternalTypes'; import type {EventPriority} from 'react-reconciler/src/ReactEventPriorities'; -import {enableSelectiveHydration} from 'shared/ReactFeatureFlags'; +import { + enableSelectiveHydration, + enableCapturePhaseSelectiveHydrationWithoutDiscreteEventReplay, +} from 'shared/ReactFeatureFlags'; import { unstable_scheduleCallback as scheduleCallback, unstable_NormalPriority as NormalPriority, @@ -24,8 +27,12 @@ import { getContainerFromFiber, getSuspenseInstanceFromFiber, } from 'react-reconciler/src/ReactFiberTreeReflection'; -import {findInstanceBlockingEvent} from './ReactDOMEventListener'; +import { + findInstanceBlockingEvent, + return_targetInst, +} from './ReactDOMEventListener'; import {setReplayingEvent, resetReplayingEvent} from './CurrentReplayingEvent'; +import {dispatchEventForPluginEventSystem} from './DOMPluginEventSystem'; import { getInstanceFromNode, getClosestInstanceFromNode, @@ -44,6 +51,12 @@ export function attemptSynchronousHydration(fiber: Object) { _attemptSynchronousHydration(fiber); } +let attemptDiscreteHydration: (fiber: Object) => void; + +export function setAttemptDiscreteHydration(fn: (fiber: Object) => void) { + attemptDiscreteHydration = fn; +} + let attemptContinuousHydration: (fiber: Object) => void; export function setAttemptContinuousHydration(fn: (fiber: Object) => void) { @@ -120,7 +133,7 @@ export function hasQueuedContinuousEvents(): boolean { return hasAnyQueuedContinuousEvents; } -const synchronouslyHydratedEvents: Array = [ +const discreteReplayableEvents: Array = [ 'mousedown', 'mouseup', 'touchcancel', @@ -154,7 +167,7 @@ const synchronouslyHydratedEvents: Array = [ export function isDiscreteEventThatRequiresHydration( eventType: DOMEventName, ): boolean { - return synchronouslyHydratedEvents.indexOf(eventType) > -1; + return discreteReplayableEvents.indexOf(eventType) > -1; } function createQueuedReplayableEvent( @@ -173,6 +186,50 @@ function createQueuedReplayableEvent( }; } +export function queueDiscreteEvent( + blockedOn: null | Container | SuspenseInstance, + domEventName: DOMEventName, + eventSystemFlags: EventSystemFlags, + targetContainer: EventTarget, + nativeEvent: AnyNativeEvent, +): void { + if (enableCapturePhaseSelectiveHydrationWithoutDiscreteEventReplay) { + return; + } + const queuedEvent = createQueuedReplayableEvent( + blockedOn, + domEventName, + eventSystemFlags, + targetContainer, + nativeEvent, + ); + queuedDiscreteEvents.push(queuedEvent); + if (enableSelectiveHydration) { + if (queuedDiscreteEvents.length === 1) { + // If this was the first discrete event, we might be able to + // synchronously unblock it so that preventDefault still works. + while (queuedEvent.blockedOn !== null) { + const fiber = getInstanceFromNode(queuedEvent.blockedOn); + if (fiber === null) { + break; + } + attemptSynchronousHydration(fiber); + if (queuedEvent.blockedOn === null) { + // We got unblocked by hydration. Let's try again. + replayUnblockedEvents(); + // If we're reblocked, on an inner boundary, we might need + // to attempt hydrating that one. + continue; + } else { + // We're still blocked from hydration, we have to give up + // and replay later. + break; + } + } + } + } +} + // Resets the replaying for this type of continuous event to no event. export function clearIfContinuousEvent( domEventName: DOMEventName, @@ -416,14 +473,26 @@ function attemptReplayContinuousQueuedEvent( queuedEvent.nativeEvent, ); if (nextBlockedOn === null) { - const nativeEvent = queuedEvent.nativeEvent; - const nativeEventClone = new nativeEvent.constructor( - nativeEvent.type, - (nativeEvent: any), - ); - setReplayingEvent(nativeEventClone); - nativeEvent.target.dispatchEvent(nativeEventClone); - 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); @@ -451,6 +520,55 @@ function attemptReplayContinuousQueuedEventInMap( function replayUnblockedEvents() { hasScheduledReplayAttempt = false; + if (!enableCapturePhaseSelectiveHydrationWithoutDiscreteEventReplay) { + // First replay discrete events. + while (queuedDiscreteEvents.length > 0) { + const nextDiscreteEvent = queuedDiscreteEvents[0]; + if (nextDiscreteEvent.blockedOn !== null) { + // We're still blocked. + // Increase the priority of this boundary to unblock + // the next discrete event. + const fiber = getInstanceFromNode(nextDiscreteEvent.blockedOn); + if (fiber !== null) { + attemptDiscreteHydration(fiber); + } + break; + } + const targetContainers = nextDiscreteEvent.targetContainers; + while (targetContainers.length > 0) { + const targetContainer = targetContainers[0]; + const nextBlockedOn = findInstanceBlockingEvent( + nextDiscreteEvent.domEventName, + nextDiscreteEvent.eventSystemFlags, + targetContainer, + 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, + nextDiscreteEvent.eventSystemFlags, + nextDiscreteEvent.nativeEvent, + return_targetInst, + targetContainer, + ); + resetReplayingEvent(); + } else { + // We're still blocked. Try again later. + nextDiscreteEvent.blockedOn = nextBlockedOn; + break; + } + // This target container was successfully dispatched. Try the next. + targetContainers.shift(); + } + if (nextDiscreteEvent.blockedOn === null) { + // We've successfully replayed the first event. Let's try the next one. + queuedDiscreteEvents.shift(); + } + } + } // Next replay any continuous events. if (queuedFocus !== null && attemptReplayContinuousQueuedEvent(queuedFocus)) { queuedFocus = null; diff --git a/packages/react-dom/src/events/__tests__/DOMPluginEventSystem-test.internal.js b/packages/react-dom/src/events/__tests__/DOMPluginEventSystem-test.internal.js index 0362e4e203a83..bcc3f15c44e79 100644 --- a/packages/react-dom/src/events/__tests__/DOMPluginEventSystem-test.internal.js +++ b/packages/react-dom/src/events/__tests__/DOMPluginEventSystem-test.internal.js @@ -650,7 +650,16 @@ describe('DOMPluginEventSystem', () => { // We're now full hydrated. - expect(clicks).toBe(0); + if ( + gate( + flags => + flags.enableCapturePhaseSelectiveHydrationWithoutDiscreteEventReplay, + ) + ) { + expect(clicks).toBe(0); + } else { + expect(clicks).toBe(1); + } document.body.removeChild(parentContainer); }); diff --git a/packages/react-reconciler/src/ReactFiberReconciler.js b/packages/react-reconciler/src/ReactFiberReconciler.js index aacd2018b7d4e..abc676ff23963 100644 --- a/packages/react-reconciler/src/ReactFiberReconciler.js +++ b/packages/react-reconciler/src/ReactFiberReconciler.js @@ -27,6 +27,7 @@ import { flushPassiveEffects as flushPassiveEffects_old, getPublicRootInstance as getPublicRootInstance_old, attemptSynchronousHydration as attemptSynchronousHydration_old, + attemptDiscreteHydration as attemptDiscreteHydration_old, attemptContinuousHydration as attemptContinuousHydration_old, attemptHydrationAtCurrentPriority as attemptHydrationAtCurrentPriority_old, findHostInstance as findHostInstance_old, @@ -64,6 +65,7 @@ import { flushPassiveEffects as flushPassiveEffects_new, getPublicRootInstance as getPublicRootInstance_new, attemptSynchronousHydration as attemptSynchronousHydration_new, + attemptDiscreteHydration as attemptDiscreteHydration_new, attemptContinuousHydration as attemptContinuousHydration_new, attemptHydrationAtCurrentPriority as attemptHydrationAtCurrentPriority_new, findHostInstance as findHostInstance_new, @@ -122,6 +124,9 @@ export const getPublicRootInstance = enableNewReconciler export const attemptSynchronousHydration = enableNewReconciler ? attemptSynchronousHydration_new : attemptSynchronousHydration_old; +export const attemptDiscreteHydration = enableNewReconciler + ? attemptDiscreteHydration_new + : attemptDiscreteHydration_old; export const attemptContinuousHydration = enableNewReconciler ? attemptContinuousHydration_new : attemptContinuousHydration_old; diff --git a/packages/react-reconciler/src/ReactFiberReconciler.new.js b/packages/react-reconciler/src/ReactFiberReconciler.new.js index 8ae8e42de46f7..136276637d3ea 100644 --- a/packages/react-reconciler/src/ReactFiberReconciler.new.js +++ b/packages/react-reconciler/src/ReactFiberReconciler.new.js @@ -452,6 +452,20 @@ function markRetryLaneIfNotHydrated(fiber: Fiber, retryLane: Lane) { } } +export function attemptDiscreteHydration(fiber: Fiber): void { + if (fiber.tag !== SuspenseComponent) { + // We ignore HostRoots here because we can't increase + // their priority and they should not suspend on I/O, + // since you have to wrap anything that might suspend in + // Suspense. + return; + } + const eventTime = requestEventTime(); + const lane = SyncLane; + scheduleUpdateOnFiber(fiber, lane, eventTime); + markRetryLaneIfNotHydrated(fiber, lane); +} + export function attemptContinuousHydration(fiber: Fiber): void { if (fiber.tag !== SuspenseComponent) { // We ignore HostRoots here because we can't increase diff --git a/packages/react-reconciler/src/ReactFiberReconciler.old.js b/packages/react-reconciler/src/ReactFiberReconciler.old.js index 31171bb073f9a..e014519320a51 100644 --- a/packages/react-reconciler/src/ReactFiberReconciler.old.js +++ b/packages/react-reconciler/src/ReactFiberReconciler.old.js @@ -452,6 +452,20 @@ function markRetryLaneIfNotHydrated(fiber: Fiber, retryLane: Lane) { } } +export function attemptDiscreteHydration(fiber: Fiber): void { + if (fiber.tag !== SuspenseComponent) { + // We ignore HostRoots here because we can't increase + // their priority and they should not suspend on I/O, + // since you have to wrap anything that might suspend in + // Suspense. + return; + } + const eventTime = requestEventTime(); + const lane = SyncLane; + scheduleUpdateOnFiber(fiber, lane, eventTime); + markRetryLaneIfNotHydrated(fiber, lane); +} + export function attemptContinuousHydration(fiber: Fiber): void { if (fiber.tag !== SuspenseComponent) { // We ignore HostRoots here because we can't increase diff --git a/packages/shared/ReactFeatureFlags.js b/packages/shared/ReactFeatureFlags.js index 8dfb8fddaddfe..33261df6d76d7 100644 --- a/packages/shared/ReactFeatureFlags.js +++ b/packages/shared/ReactFeatureFlags.js @@ -44,6 +44,9 @@ export const enableSuspenseLayoutEffectSemantics = true; export const enableClientRenderFallbackOnHydrationMismatch = true; export const enableClientRenderFallbackOnTextMismatch = true; +// TODO: Need to review this code one more time before landing +export const enableCapturePhaseSelectiveHydrationWithoutDiscreteEventReplay = true; + // Recoil still uses useMutableSource in www, need to delete export const enableUseMutableSource = false; diff --git a/packages/shared/forks/ReactFeatureFlags.native-fb.js b/packages/shared/forks/ReactFeatureFlags.native-fb.js index 90f3b57a472a6..facc9c7d78369 100644 --- a/packages/shared/forks/ReactFeatureFlags.native-fb.js +++ b/packages/shared/forks/ReactFeatureFlags.native-fb.js @@ -52,6 +52,7 @@ export const warnAboutSpreadingKeyToJSX = false; export const enableSuspenseAvoidThisFallback = false; export const enableSuspenseAvoidThisFallbackFizz = false; export const enableCPUSuspense = true; +export const enableCapturePhaseSelectiveHydrationWithoutDiscreteEventReplay = true; export const enableClientRenderFallbackOnHydrationMismatch = true; export const enableClientRenderFallbackOnTextMismatch = true; export const enableComponentStackLocations = false; diff --git a/packages/shared/forks/ReactFeatureFlags.native-oss.js b/packages/shared/forks/ReactFeatureFlags.native-oss.js index 805812ec9785d..94bd838658962 100644 --- a/packages/shared/forks/ReactFeatureFlags.native-oss.js +++ b/packages/shared/forks/ReactFeatureFlags.native-oss.js @@ -43,6 +43,7 @@ export const warnAboutSpreadingKeyToJSX = false; export const enableSuspenseAvoidThisFallback = false; export const enableSuspenseAvoidThisFallbackFizz = false; export const enableCPUSuspense = false; +export const enableCapturePhaseSelectiveHydrationWithoutDiscreteEventReplay = true; export const enableClientRenderFallbackOnHydrationMismatch = true; export const enableClientRenderFallbackOnTextMismatch = true; export const enableComponentStackLocations = false; diff --git a/packages/shared/forks/ReactFeatureFlags.test-renderer.js b/packages/shared/forks/ReactFeatureFlags.test-renderer.js index a6c8bb51343f3..b0c81a7a8bb34 100644 --- a/packages/shared/forks/ReactFeatureFlags.test-renderer.js +++ b/packages/shared/forks/ReactFeatureFlags.test-renderer.js @@ -43,6 +43,7 @@ export const warnAboutSpreadingKeyToJSX = false; export const enableSuspenseAvoidThisFallback = false; export const enableSuspenseAvoidThisFallbackFizz = false; export const enableCPUSuspense = false; +export const enableCapturePhaseSelectiveHydrationWithoutDiscreteEventReplay = true; export const enableClientRenderFallbackOnHydrationMismatch = true; export const enableClientRenderFallbackOnTextMismatch = true; export const enableComponentStackLocations = true; diff --git a/packages/shared/forks/ReactFeatureFlags.test-renderer.native.js b/packages/shared/forks/ReactFeatureFlags.test-renderer.native.js index ca6ae7e685912..e05dc109843c7 100644 --- a/packages/shared/forks/ReactFeatureFlags.test-renderer.native.js +++ b/packages/shared/forks/ReactFeatureFlags.test-renderer.native.js @@ -53,6 +53,7 @@ export const deferRenderPhaseUpdateToNextBatch = false; export const enableSuspenseAvoidThisFallback = false; export const enableSuspenseAvoidThisFallbackFizz = false; export const enableCPUSuspense = false; +export const enableCapturePhaseSelectiveHydrationWithoutDiscreteEventReplay = true; export const enableClientRenderFallbackOnHydrationMismatch = true; export const enableClientRenderFallbackOnTextMismatch = true; export const enableStrictEffects = false; diff --git a/packages/shared/forks/ReactFeatureFlags.test-renderer.www.js b/packages/shared/forks/ReactFeatureFlags.test-renderer.www.js index 822ca854730a7..042c2519043b1 100644 --- a/packages/shared/forks/ReactFeatureFlags.test-renderer.www.js +++ b/packages/shared/forks/ReactFeatureFlags.test-renderer.www.js @@ -43,6 +43,7 @@ export const warnAboutSpreadingKeyToJSX = false; export const enableSuspenseAvoidThisFallback = true; export const enableSuspenseAvoidThisFallbackFizz = false; export const enableCPUSuspense = false; +export const enableCapturePhaseSelectiveHydrationWithoutDiscreteEventReplay = true; export const enableClientRenderFallbackOnHydrationMismatch = true; export const enableClientRenderFallbackOnTextMismatch = true; export const enableComponentStackLocations = true; diff --git a/packages/shared/forks/ReactFeatureFlags.testing.js b/packages/shared/forks/ReactFeatureFlags.testing.js index 276b744232703..6b640311000fb 100644 --- a/packages/shared/forks/ReactFeatureFlags.testing.js +++ b/packages/shared/forks/ReactFeatureFlags.testing.js @@ -43,6 +43,7 @@ export const warnAboutSpreadingKeyToJSX = false; export const enableSuspenseAvoidThisFallback = false; export const enableSuspenseAvoidThisFallbackFizz = false; export const enableCPUSuspense = false; +export const enableCapturePhaseSelectiveHydrationWithoutDiscreteEventReplay = true; export const enableClientRenderFallbackOnHydrationMismatch = true; export const enableClientRenderFallbackOnTextMismatch = true; export const enableComponentStackLocations = true; diff --git a/packages/shared/forks/ReactFeatureFlags.testing.www.js b/packages/shared/forks/ReactFeatureFlags.testing.www.js index 73015250bf53c..5e41ca9992da4 100644 --- a/packages/shared/forks/ReactFeatureFlags.testing.www.js +++ b/packages/shared/forks/ReactFeatureFlags.testing.www.js @@ -43,6 +43,7 @@ export const warnAboutSpreadingKeyToJSX = false; export const enableSuspenseAvoidThisFallback = true; export const enableSuspenseAvoidThisFallbackFizz = false; export const enableCPUSuspense = true; +export const enableCapturePhaseSelectiveHydrationWithoutDiscreteEventReplay = true; export const enableClientRenderFallbackOnHydrationMismatch = true; export const enableClientRenderFallbackOnTextMismatch = true; export const enableComponentStackLocations = true; diff --git a/packages/shared/forks/ReactFeatureFlags.www-dynamic.js b/packages/shared/forks/ReactFeatureFlags.www-dynamic.js index bdc3abb2a895b..1f7de0b6b5a10 100644 --- a/packages/shared/forks/ReactFeatureFlags.www-dynamic.js +++ b/packages/shared/forks/ReactFeatureFlags.www-dynamic.js @@ -25,6 +25,7 @@ export const disableSchedulerTimeoutInWorkLoop = __VARIANT__; export const enableLazyContextPropagation = __VARIANT__; export const enableSyncDefaultUpdates = __VARIANT__; export const consoleManagedByDevToolsDuringStrictMode = __VARIANT__; +export const enableCapturePhaseSelectiveHydrationWithoutDiscreteEventReplay = __VARIANT__; export const enableClientRenderFallbackOnHydrationMismatch = __VARIANT__; export const enableClientRenderFallbackOnTextMismatch = __VARIANT__; export const enableTransitionTracing = __VARIANT__; diff --git a/packages/shared/forks/ReactFeatureFlags.www.js b/packages/shared/forks/ReactFeatureFlags.www.js index 2a2e0e6ffe204..d5edae5563a5b 100644 --- a/packages/shared/forks/ReactFeatureFlags.www.js +++ b/packages/shared/forks/ReactFeatureFlags.www.js @@ -31,6 +31,7 @@ export const { disableSchedulerTimeoutInWorkLoop, enableLazyContextPropagation, enableSyncDefaultUpdates, + enableCapturePhaseSelectiveHydrationWithoutDiscreteEventReplay, enableClientRenderFallbackOnHydrationMismatch, enableClientRenderFallbackOnTextMismatch, } = dynamicFeatureFlags;