Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Replay capture phase for continuous events #22680

Closed

Large diffs are not rendered by default.

4 changes: 3 additions & 1 deletion packages/react-dom/src/events/DOMPluginEventSystem.js
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ import * as ChangeEventPlugin from './plugins/ChangeEventPlugin';
import * as EnterLeaveEventPlugin from './plugins/EnterLeaveEventPlugin';
import * as SelectEventPlugin from './plugins/SelectEventPlugin';
import * as SimpleEventPlugin from './plugins/SimpleEventPlugin';
import {isReplayingEvent} from './replayedEvent';

type DispatchListener = {|
instance: null | Fiber,
Expand Down Expand Up @@ -557,7 +558,8 @@ export function dispatchEventForPluginEventSystem(
// for legacy FB support, where the expected behavior was to
// match React < 16 behavior of delegated clicks to the doc.
domEventName === 'click' &&
(eventSystemFlags & SHOULD_NOT_DEFER_CLICK_FOR_FB_SUPPORT_MODE) === 0
(eventSystemFlags & SHOULD_NOT_DEFER_CLICK_FOR_FB_SUPPORT_MODE) === 0 &&
!isReplayingEvent(nativeEvent)
) {
deferClickToDocumentForLegacyFBSupport(domEventName, targetContainer);
return;
Expand Down
5 changes: 2 additions & 3 deletions packages/react-dom/src/events/EventSystemFlags.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,10 @@ export const IS_EVENT_HANDLE_NON_MANAGED_NODE = 1;
export const IS_NON_DELEGATED = 1 << 1;
export const IS_CAPTURE_PHASE = 1 << 2;
export const IS_PASSIVE = 1 << 3;
export const IS_REPLAYED = 1 << 4;
export const IS_LEGACY_FB_SUPPORT_MODE = 1 << 5;
export const IS_LEGACY_FB_SUPPORT_MODE = 1 << 4;

export const SHOULD_NOT_DEFER_CLICK_FOR_FB_SUPPORT_MODE =
IS_LEGACY_FB_SUPPORT_MODE | IS_REPLAYED | IS_CAPTURE_PHASE;
IS_LEGACY_FB_SUPPORT_MODE | IS_CAPTURE_PHASE;

// We do not want to defer if the event system has already been
// set to LEGACY_FB_SUPPORT. LEGACY_FB_SUPPORT only gets set when
Expand Down
200 changes: 145 additions & 55 deletions packages/react-dom/src/events/ReactDOMEventListener.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 type {NullTarget} from './ReactDOMEventReplaying';
import {enableCapturePhaseSelectiveHydrationWithoutDiscreteEventReplay} from 'shared/ReactFeatureFlags';
import {
nullTarget,
isBlocked,
isDiscreteEventThatRequiresHydration,
queueDiscreteEvent,
hasQueuedDiscreteEvents,
Expand Down Expand Up @@ -155,13 +158,128 @@ export function dispatchEvent(
return;
}

if (enableCapturePhaseSelectiveHydrationWithoutDiscreteEventReplay) {
dispatchEventWithEnableCapturePhaseSelectiveHydrationWithoutDiscreteEventReplay(
domEventName,
eventSystemFlags,
targetContainer,
nativeEvent,
);
} else {
dispatchEventOriginal(
domEventName,
eventSystemFlags,
targetContainer,
nativeEvent,
);
}
}

function dispatchEventWithEnableCapturePhaseSelectiveHydrationWithoutDiscreteEventReplay(
domEventName: DOMEventName,
eventSystemFlags: EventSystemFlags,
targetContainer: EventTarget,
nativeEvent: AnyNativeEvent,
) {
let blockedOn = findInstanceBlockingEvent(
domEventName,
eventSystemFlags,
targetContainer,
nativeEvent,
);

// We can dispatch the event now
salazarm marked this conversation as resolved.
Show resolved Hide resolved
let blockedOnInst = isBlocked(blockedOn);
if (!blockedOnInst) {
clearIfContinuousEvent(domEventName, nativeEvent);
dispatchEventForPluginEventSystem(
domEventName,
eventSystemFlags,
nativeEvent,
blockedOn === null
? getClosestInstanceFromNode(getEventTarget(nativeEvent))
: null,
targetContainer,
);
return;
}

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 &&
isDiscreteEventThatRequiresHydration(domEventName)
) {
while (blockedOnInst) {
const fiber = getInstanceFromNode(blockedOnInst);
if (fiber !== null) {
attemptSynchronousHydration(fiber);
}
const nextBlockedOn = findInstanceBlockingEvent(
domEventName,
eventSystemFlags,
targetContainer,
nativeEvent,
);
if (nextBlockedOn === blockedOn) {
break;
}
blockedOn = nextBlockedOn;
blockedOnInst = isBlocked(blockedOn);
}
if (blockedOnInst) {
nativeEvent.stopPropagation();
return;
}
dispatchEventForPluginEventSystem(
domEventName,
eventSystemFlags,
nativeEvent,
blockedOn === null
? getClosestInstanceFromNode(getEventTarget(nativeEvent))
: null,
targetContainer,
);
return;
}

// 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 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() &&
Expand All @@ -180,14 +298,23 @@ export function dispatchEvent(
return;
}

let blockedOn = attemptToDispatchEvent(
const blockedOn = findInstanceBlockingEvent(
domEventName,
eventSystemFlags,
targetContainer,
nativeEvent,
);

if (blockedOn === null) {
const blockedOnInst = isBlocked(blockedOn);
if (!blockedOnInst) {
dispatchEventForPluginEventSystem(
domEventName,
eventSystemFlags,
nativeEvent,
blockedOn === null
? getClosestInstanceFromNode(getEventTarget(nativeEvent))
: null,
targetContainer,
);
// We successfully dispatched this event.
if (allowReplay) {
clearIfContinuousEvent(domEventName, nativeEvent);
Expand All @@ -196,10 +323,7 @@ export function dispatchEvent(
}

if (allowReplay) {
if (
!enableCapturePhaseSelectiveHydrationWithoutDiscreteEventReplay &&
isDiscreteEventThatRequiresHydration(domEventName)
) {
if (isDiscreteEventThatRequiresHydration(domEventName)) {
// This this to be replayed later once the target is available.
queueDiscreteEvent(
blockedOn,
Expand All @@ -226,33 +350,6 @@ export function dispatchEvent(
clearIfContinuousEvent(domEventName, nativeEvent);
}

if (
enableCapturePhaseSelectiveHydrationWithoutDiscreteEventReplay &&
eventSystemFlags & IS_CAPTURE_PHASE &&
isDiscreteEventThatRequiresHydration(domEventName)
) {
while (blockedOn !== null) {
const fiber = getInstanceFromNode(blockedOn);
if (fiber !== null) {
attemptSynchronousHydration(fiber);
}
const nextBlockedOn = attemptToDispatchEvent(
domEventName,
eventSystemFlags,
targetContainer,
nativeEvent,
);
if (nextBlockedOn === blockedOn) {
break;
}
blockedOn = nextBlockedOn;
}
if (blockedOn) {
nativeEvent.stopPropagation();
return;
}
}

// This is not replayable so we'll invoke it but without a target,
// in case the event system needs to trace it.
dispatchEventForPluginEventSystem(
Expand All @@ -264,62 +361,55 @@ export function dispatchEvent(
);
}

// Attempt dispatching an event. Returns a SuspenseInstance or Container if it's blocked.
export function attemptToDispatchEvent(
// Returns a SuspenseInstance or Container if it's blocked.
// Returns null if not blocked and we should use closestInstance
// Returns nullTarget if not blocked but we should dispatch without a targetInst
export function findInstanceBlockingEvent(
domEventName: DOMEventName,
eventSystemFlags: EventSystemFlags,
targetContainer: EventTarget,
nativeEvent: AnyNativeEvent,
): null | Container | SuspenseInstance {
): NullTarget | null | Container | SuspenseInstance {
// TODO: Warn if _enabled is false.

const nativeEventTarget = getEventTarget(nativeEvent);
let targetInst = getClosestInstanceFromNode(nativeEventTarget);
const targetInst = getClosestInstanceFromNode(nativeEventTarget);

if (targetInst !== null) {
const nearestMounted = getNearestMountedFiber(targetInst);
if (nearestMounted === null) {
// This tree has been unmounted already. Dispatch without a target.
salazarm marked this conversation as resolved.
Show resolved Hide resolved
targetInst = null;
} else {
if (nearestMounted !== null) {
const tag = nearestMounted.tag;
if (tag === SuspenseComponent) {
const instance = getSuspenseInstanceFromFiber(nearestMounted);
if (instance !== null) {
// Queue the event to be replayed later. Abort dispatching since we
// don't want this event dispatched twice through the event system.
// TODO: If this is the first discrete event in the queue. Schedule an increased
// priority for this boundary.
return instance;
}
// This shouldn't happen, something went wrong but to avoid blocking
// the whole system, dispatch the event without a target.
// TODO: Warn.
salazarm marked this conversation as resolved.
Show resolved Hide resolved
targetInst = null;
return nullTarget;
} else if (tag === HostRoot) {
const root: FiberRoot = nearestMounted.stateNode;
if (root.isDehydrated) {
// If this happens during a replay something went wrong and it might block
// the whole system.
return getContainerFromFiber(nearestMounted);
salazarm marked this conversation as resolved.
Show resolved Hide resolved
}
targetInst = null;
salazarm marked this conversation as resolved.
Show resolved Hide resolved
return nullTarget;
} else if (nearestMounted !== targetInst) {
// If we get an event (ex: img onload) before committing that
// component's mount, ignore it for now (that is, treat it as if it was an
// event on a non-React tree). We might also consider queueing events and
// dispatching them after the mount.
targetInst = null;
return nullTarget;
}
} else {
// This tree has been unmounted already. Dispatch without a target.
return nullTarget;
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I couldn't come up with a test case where we findInstanceBlockingEvent would return nullTarget. It doesn't seem possible that it would ever happen because it would mean that there is a React Instance defined on the target but then nearestMountedFiber returns null for this case. I added a test which attempted to simulate this by removing the target node from its react tree into a tree where there is no react root but that didn't seem to work.

getClosestInstanceFromNode(getEventTarget(queuedEvent.nativeEvent)) did return null for this case though.

Based off how hydration works it shouldn't be possible for the nearestMountedFiber to ever be anything other than a SuspenseInstance. Unless the node is manually moved in userland to a different tree thats already hydrated, I tried doing that but that also didn't trigger the nullTarget. I'm unsure what else to try to trigger it. Any ideas?

}
}
dispatchEventForPluginEventSystem(
domEventName,
eventSystemFlags,
nativeEvent,
targetInst,
targetContainer,
);
// We're not blocked on anything.
return null;
}
Expand Down
Loading