From e3e834513eb932d161d8a79ce2c87ad2266fe88a Mon Sep 17 00:00:00 2001 From: Sebastian Markbage Date: Sat, 7 Sep 2019 15:51:13 -0700 Subject: [PATCH 01/12] Add Event Replaying Infra --- .../src/events/ReactDOMEventListener.js | 71 +++++++-- .../src/events/ReactDOMEventReplaying.js | 144 ++++++++++++++++++ .../src/ReactFiberTreeReflection.js | 27 ++++ 3 files changed, 229 insertions(+), 13 deletions(-) create mode 100644 packages/react-dom/src/events/ReactDOMEventReplaying.js diff --git a/packages/react-dom/src/events/ReactDOMEventListener.js b/packages/react-dom/src/events/ReactDOMEventListener.js index b2d7e33b5d37f..86061f3b84945 100644 --- a/packages/react-dom/src/events/ReactDOMEventListener.js +++ b/packages/react-dom/src/events/ReactDOMEventListener.js @@ -21,8 +21,17 @@ import { flushDiscreteUpdatesIfNeeded, } from 'legacy-events/ReactGenericBatching'; import {runExtractedPluginEventsInBatch} from 'legacy-events/EventPluginHub'; -import {dispatchEventForResponderEventSystem} from '../events/DOMEventResponderSystem'; -import {getNearestMountedFiber} from 'react-reconciler/reflection'; +import {dispatchEventForResponderEventSystem} from './DOMEventResponderSystem'; +import { + isReplayableDiscreteEvent, + queueDiscreteEvent, + hasQueuedDiscreteEvents, +} from './ReactDOMEventReplaying'; +import { + getNearestMountedFiber, + getContainerFromFiber, + getSuspenseInstanceFromFiber, +} from 'react-reconciler/reflection'; import { HostRoot, SuspenseComponent, @@ -319,6 +328,19 @@ export function dispatchEvent( if (!_enabled) { return; } + if (hasQueuedDiscreteEvents() && isReplayableDiscreteEvent(topLevelType)) { + // 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. + topLevelType, + eventSystemFlags, + nativeEvent, + ); + return; + } + const nativeEventTarget = getEventTarget(nativeEvent); let targetInst = getClosestInstanceFromNode(nativeEventTarget); @@ -330,18 +352,41 @@ export function dispatchEvent( } else { const tag = nearestMounted.tag; if (tag === SuspenseComponent) { - // TODO: This is a good opportunity to schedule a replay of - // the event instead once this boundary has been hydrated. - // For now we're going to just ignore this event as if it's - // not mounted. - targetInst = null; + // TODO: Check if this boundary is indeed still hydrating. + if (isReplayableDiscreteEvent(topLevelType)) { + // Queue the event to be replayed later. Abort dispatching since we + // don't want this event dispatched twice through the event system. + // TODO: This is the first discrete event. Schedule an increased + // priority for this boundary. + queueDiscreteEvent( + getSuspenseInstanceFromFiber(nearestMounted), + topLevelType, + eventSystemFlags, + nativeEvent, + ); + return; + } else { + // This is not replayable so we'll invoke it but without a target, + // in case the event system needs to trace it. + targetInst = null; + } } else if (tag === HostRoot) { - // We have not yet mounted/hydrated the first children. - // TODO: This is a good opportunity to schedule a replay of - // the event instead once this root has been hydrated. - // For now we're going to just ignore this event as if it's - // not mounted. - targetInst = null; + // TODO: Check if this boundary is indeed still a not yet mounted/hydrated root. + if (isReplayableDiscreteEvent(topLevelType)) { + // Queue the event to be replayed later. Abort dispatching since we + // don't want this event dispatched twice through the event system. + queueDiscreteEvent( + getContainerFromFiber(nearestMounted), + topLevelType, + eventSystemFlags, + nativeEvent, + ); + return; + } else { + // This is not replayable so we'll invoke it but without a target, + // in case the event system needs to trace it. + targetInst = null; + } } 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 diff --git a/packages/react-dom/src/events/ReactDOMEventReplaying.js b/packages/react-dom/src/events/ReactDOMEventReplaying.js new file mode 100644 index 0000000000000..380df72134623 --- /dev/null +++ b/packages/react-dom/src/events/ReactDOMEventReplaying.js @@ -0,0 +1,144 @@ +/** + * 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. + * + * @flow + */ + +import type {AnyNativeEvent} from 'legacy-events/PluginModuleType'; +import type {Container, SuspenseInstance} from '../client/ReactDOMHostConfig'; +import type {DOMTopLevelEventType} from 'legacy-events/TopLevelEventTypes'; +import type {EventSystemFlags} from 'legacy-events/EventSystemFlags'; + +// TODO: Upgrade this definition once we're on a newer version of Flow that +// has this definition built-in. +type PointerEvent = Event & {pointerId: number}; + +import { + TOP_MOUSE_DOWN, + TOP_MOUSE_UP, + TOP_TOUCH_CANCEL, + TOP_TOUCH_END, + TOP_TOUCH_START, + TOP_AUX_CLICK, + TOP_DOUBLE_CLICK, + TOP_POINTER_CANCEL, + TOP_POINTER_DOWN, + TOP_POINTER_UP, + TOP_DRAG_END, + TOP_DRAG_START, + TOP_DROP, + TOP_COMPOSITION_END, + TOP_COMPOSITION_START, + TOP_KEY_DOWN, + TOP_KEY_PRESS, + TOP_KEY_UP, + TOP_INPUT, + TOP_TEXT_INPUT, + TOP_CLOSE, + TOP_CANCEL, + TOP_COPY, + TOP_CUT, + TOP_PASTE, + TOP_CLICK, + TOP_CHANGE, + TOP_CONTEXT_MENU, + TOP_RESET, + TOP_SUBMIT, + TOP_DRAG_ENTER, + TOP_DRAG_LEAVE, + TOP_MOUSE_OVER, + TOP_MOUSE_OUT, + TOP_POINTER_OVER, + TOP_POINTER_OUT, + TOP_GOT_POINTER_CAPTURE, + TOP_LOST_POINTER_CAPTURE, + TOP_FOCUS, + TOP_BLUR, + TOP_SELECTION_CHANGE, +} from './DOMTopLevelEventTypes'; + +type QueuedReplayableEvent = {| + blockedOn: null | Container | SuspenseInstance, + topLevelType: DOMTopLevelEventType, + eventSystemFlags: EventSystemFlags, + nativeEvent: AnyNativeEvent, +|}; + +// The queue of discrete events to be replayed. +let queuedDiscreteEvents: Array = []; + +export function hasQueuedDiscreteEvents(): boolean { + return queuedDiscreteEvents.length > 0; +} + +export function isReplayableDiscreteEvent( + eventType: DOMTopLevelEventType, +): boolean { + switch (eventType) { + case TOP_MOUSE_DOWN: + case TOP_MOUSE_UP: + case TOP_TOUCH_CANCEL: + case TOP_TOUCH_END: + case TOP_TOUCH_START: + case TOP_AUX_CLICK: + case TOP_DOUBLE_CLICK: + case TOP_POINTER_CANCEL: + case TOP_POINTER_DOWN: + case TOP_POINTER_UP: + case TOP_DRAG_END: + case TOP_DRAG_START: + case TOP_DROP: + case TOP_COMPOSITION_END: + case TOP_COMPOSITION_START: + case TOP_KEY_DOWN: + case TOP_KEY_PRESS: + case TOP_KEY_UP: + case TOP_INPUT: + case TOP_TEXT_INPUT: + case TOP_CLOSE: + case TOP_CANCEL: + case TOP_COPY: + case TOP_CUT: + case TOP_PASTE: + case TOP_CLICK: + case TOP_CHANGE: + case TOP_CONTEXT_MENU: + case TOP_RESET: + case TOP_SUBMIT: + return true; + } + return false; +} + +function createQueuedReplayableEvent( + blockedOn: null | Container | SuspenseInstance, + topLevelType: DOMTopLevelEventType, + eventSystemFlags: EventSystemFlags, + nativeEvent: AnyNativeEvent, +): QueuedReplayableEvent { + return { + blockedOn, + topLevelType, + eventSystemFlags, + nativeEvent, + }; +} + +export function queueDiscreteEvent( + blockedOn: null | Container | SuspenseInstance, + topLevelType: DOMTopLevelEventType, + eventSystemFlags: EventSystemFlags, + nativeEvent: AnyNativeEvent, +): void { + queuedDiscreteEvents.push( + createQueuedReplayableEvent( + blockedOn, + topLevelType, + eventSystemFlags, + nativeEvent, + ), + ); +} diff --git a/packages/react-reconciler/src/ReactFiberTreeReflection.js b/packages/react-reconciler/src/ReactFiberTreeReflection.js index 8d5c360a23c58..01f05d94fbe05 100644 --- a/packages/react-reconciler/src/ReactFiberTreeReflection.js +++ b/packages/react-reconciler/src/ReactFiberTreeReflection.js @@ -8,6 +8,8 @@ */ import type {Fiber} from './ReactFiber'; +import type {Container, SuspenseInstance} from './ReactFiberHostConfig'; +import type {SuspenseState} from './ReactFiberSuspenseComponent'; import invariant from 'shared/invariant'; import warningWithoutStack from 'shared/warningWithoutStack'; @@ -22,6 +24,7 @@ import { HostPortal, HostText, FundamentalComponent, + SuspenseComponent, } from 'shared/ReactWorkTags'; import {NoEffect, Placement, Hydrating} from 'shared/ReactSideEffectTags'; import {enableFundamentalAPI} from 'shared/ReactFeatureFlags'; @@ -60,6 +63,30 @@ export function getNearestMountedFiber(fiber: Fiber): null | Fiber { return null; } +export function getSuspenseInstanceFromFiber( + fiber: Fiber, +): null | SuspenseInstance { + if (fiber.tag === SuspenseComponent) { + let suspenseState: SuspenseState | null = fiber.memoizedState; + if (suspenseState === null) { + const current = fiber.alternate; + if (current !== null) { + suspenseState = current.memoizedState; + } + } + if (suspenseState !== null) { + return suspenseState.dehydrated; + } + } + return null; +} + +export function getContainerFromFiber(fiber: Fiber): null | Container { + return fiber.tag === HostRoot + ? (fiber.stateNode.containerInfo: Container) + : null; +} + export function isFiberMounted(fiber: Fiber): boolean { return getNearestMountedFiber(fiber) === fiber; } From e070313f9210ca2eed143216941149973a2d84e4 Mon Sep 17 00:00:00 2001 From: Sebastian Markbage Date: Mon, 9 Sep 2019 11:34:34 -0700 Subject: [PATCH 02/12] Wire up Roots and Suspense boundaries, to retry events, after they commit --- .../src/client/ReactDOMHostConfig.js | 19 ++++++++ .../src/events/ReactDOMEventReplaying.js | 6 +++ .../src/ReactFiberCommitWork.js | 43 +++++++++++-------- .../src/forks/ReactFiberHostConfig.custom.js | 3 ++ packages/shared/HostConfigWithNoHydration.js | 2 + 5 files changed, 56 insertions(+), 17 deletions(-) diff --git a/packages/react-dom/src/client/ReactDOMHostConfig.js b/packages/react-dom/src/client/ReactDOMHostConfig.js index 966eb293d7c82..c9425be84026c 100644 --- a/packages/react-dom/src/client/ReactDOMHostConfig.js +++ b/packages/react-dom/src/client/ReactDOMHostConfig.js @@ -52,6 +52,7 @@ import { mountEventResponder, unmountEventResponder, } from '../events/DOMEventResponderSystem'; +import {retryIfBlockedOn} from '../events/ReactDOMEventReplaying'; export type Type = string; export type Props = { @@ -477,6 +478,8 @@ export function clearSuspenseBoundary( if (data === SUSPENSE_END_DATA) { if (depth === 0) { parentInstance.removeChild(nextNode); + // Retry if any event replaying was blocked on this. + retryIfBlockedOn(suspenseInstance); return; } else { depth--; @@ -492,6 +495,8 @@ export function clearSuspenseBoundary( node = nextNode; } while (node); // TODO: Warn, we didn't find the end comment boundary. + // Retry if any event replaying was blocked on this. + retryIfBlockedOn(suspenseInstance); } export function clearSuspenseBoundaryFromContainer( @@ -505,6 +510,8 @@ export function clearSuspenseBoundaryFromContainer( } else { // Document nodes should never contain suspense boundaries. } + // Retry if any event replaying was blocked on this. + retryIfBlockedOn(container); } export function hideInstance(instance: Instance): void { @@ -744,6 +751,18 @@ export function getParentSuspenseInstance( return null; } +export function commitHydratedContainer(container: Container): void { + // Retry if any event replaying was blocked on this. + retryIfBlockedOn(container); +} + +export function commitHydratedSuspenseInstance( + suspenseInstance: SuspenseInstance, +): void { + // Retry if any event replaying was blocked on this. + retryIfBlockedOn(suspenseInstance); +} + export function didNotMatchHydratedContainerTextInstance( parentContainer: Container, textInstance: TextInstance, diff --git a/packages/react-dom/src/events/ReactDOMEventReplaying.js b/packages/react-dom/src/events/ReactDOMEventReplaying.js index 380df72134623..8408de552c7f6 100644 --- a/packages/react-dom/src/events/ReactDOMEventReplaying.js +++ b/packages/react-dom/src/events/ReactDOMEventReplaying.js @@ -142,3 +142,9 @@ export function queueDiscreteEvent( ), ); } + +export function retryIfBlockedOn( + blockedOn: Container | SuspenseInstance, +): void { + // TODO: Retry if we're blocked on this boundary. +} diff --git a/packages/react-reconciler/src/ReactFiberCommitWork.js b/packages/react-reconciler/src/ReactFiberCommitWork.js index 963e32408694a..02b525a12391f 100644 --- a/packages/react-reconciler/src/ReactFiberCommitWork.js +++ b/packages/react-reconciler/src/ReactFiberCommitWork.js @@ -103,6 +103,8 @@ import { unmountResponderInstance, unmountFundamentalComponent, updateFundamentalComponent, + commitHydratedContainer, + commitHydratedSuspenseInstance, } from './ReactFiberHostConfig'; import { captureCommitPhaseError, @@ -612,9 +614,7 @@ function commitLifeCycles( return; } case SuspenseComponent: { - if (enableSuspenseCallback) { - commitSuspenseHydrationCallbacks(finishedRoot, finishedWork); - } + commitSuspenseHydrationCallbacks(finishedRoot, finishedWork); return; } case SuspenseListComponent: @@ -1306,11 +1306,12 @@ function commitWork(current: Fiber | null, finishedWork: Fiber): void { return; } case HostRoot: { - const root: FiberRoot = finishedWork.stateNode; if (supportsHydration) { + const root: FiberRoot = finishedWork.stateNode; if (root.hydrate) { // We've just hydrated. No need to hydrate again. root.hydrate = false; + commitHydratedContainer(root.containerInfo); } } break; @@ -1384,11 +1385,12 @@ function commitWork(current: Fiber | null, finishedWork: Fiber): void { return; } case HostRoot: { - const root: FiberRoot = finishedWork.stateNode; if (supportsHydration) { + const root: FiberRoot = finishedWork.stateNode; if (root.hydrate) { // We've just hydrated. No need to hydrate again. root.hydrate = false; + commitHydratedContainer(root.containerInfo); } } return; @@ -1477,18 +1479,25 @@ function commitSuspenseHydrationCallbacks( finishedRoot: FiberRoot, finishedWork: Fiber, ) { - if (enableSuspenseCallback) { - const hydrationCallbacks = finishedRoot.hydrationCallbacks; - if (hydrationCallbacks !== null) { - const onHydrated = hydrationCallbacks.onHydrated; - if (onHydrated) { - const newState: SuspenseState | null = finishedWork.memoizedState; - if (newState === null) { - const current = finishedWork.alternate; - if (current !== null) { - const prevState: SuspenseState | null = current.memoizedState; - if (prevState !== null && prevState.dehydrated !== null) { - onHydrated(prevState.dehydrated); + if (!supportsHydration) { + return; + } + const newState: SuspenseState | null = finishedWork.memoizedState; + if (newState === null) { + const current = finishedWork.alternate; + if (current !== null) { + const prevState: SuspenseState | null = current.memoizedState; + if (prevState !== null) { + const suspenseInstance = prevState.dehydrated; + if (suspenseInstance !== null) { + commitHydratedSuspenseInstance(suspenseInstance); + if (enableSuspenseCallback) { + const hydrationCallbacks = finishedRoot.hydrationCallbacks; + if (hydrationCallbacks !== null) { + const onHydrated = hydrationCallbacks.onHydrated; + if (onHydrated) { + onHydrated(suspenseInstance); + } } } } diff --git a/packages/react-reconciler/src/forks/ReactFiberHostConfig.custom.js b/packages/react-reconciler/src/forks/ReactFiberHostConfig.custom.js index 61c71588a427e..cb35932b20ab2 100644 --- a/packages/react-reconciler/src/forks/ReactFiberHostConfig.custom.js +++ b/packages/react-reconciler/src/forks/ReactFiberHostConfig.custom.js @@ -129,6 +129,9 @@ export const hydrateTextInstance = $$$hostConfig.hydrateTextInstance; export const hydrateSuspenseInstance = $$$hostConfig.hydrateSuspenseInstance; export const getNextHydratableInstanceAfterSuspenseInstance = $$$hostConfig.getNextHydratableInstanceAfterSuspenseInstance; +export const commitHydratedContainer = $$$hostConfig.commitHydratedContainer; +export const commitHydratedSuspenseInstance = + $$$hostConfig.commitHydratedSuspenseInstance; export const clearSuspenseBoundary = $$$hostConfig.clearSuspenseBoundary; export const clearSuspenseBoundaryFromContainer = $$$hostConfig.clearSuspenseBoundaryFromContainer; diff --git a/packages/shared/HostConfigWithNoHydration.js b/packages/shared/HostConfigWithNoHydration.js index 7fa5a025b6a46..b432a1d400736 100644 --- a/packages/shared/HostConfigWithNoHydration.js +++ b/packages/shared/HostConfigWithNoHydration.js @@ -36,6 +36,8 @@ export const hydrateInstance = shim; export const hydrateTextInstance = shim; export const hydrateSuspenseInstance = shim; export const getNextHydratableInstanceAfterSuspenseInstance = shim; +export const commitHydratedContainer = shim; +export const commitHydratedSuspenseInstance = shim; export const clearSuspenseBoundary = shim; export const clearSuspenseBoundaryFromContainer = shim; export const didNotMatchHydratedContainerTextInstance = shim; From 591ccde90546d06f8d37b3333ad5f0e45ee09ef1 Mon Sep 17 00:00:00 2001 From: Sebastian Markbage Date: Mon, 9 Sep 2019 23:03:18 -0700 Subject: [PATCH 03/12] Replay discrete events in order in a separate scheduler callback --- .../src/events/ReactDOMEventListener.js | 73 +++++++++++++++++++ .../src/events/ReactDOMEventReplaying.js | 62 +++++++++++++++- 2 files changed, 134 insertions(+), 1 deletion(-) diff --git a/packages/react-dom/src/events/ReactDOMEventListener.js b/packages/react-dom/src/events/ReactDOMEventListener.js index 86061f3b84945..9335359f8e429 100644 --- a/packages/react-dom/src/events/ReactDOMEventListener.js +++ b/packages/react-dom/src/events/ReactDOMEventListener.js @@ -9,6 +9,7 @@ import type {AnyNativeEvent} from 'legacy-events/PluginModuleType'; import type {Fiber} from 'react-reconciler/src/ReactFiber'; +import type {SuspenseInstance} from '../client/ReactDOMHostConfig'; import type {DOMTopLevelEventType} from 'legacy-events/TopLevelEventTypes'; // Intentionally not named imports because Rollup would use dynamic dispatch for @@ -424,3 +425,75 @@ export function dispatchEvent( ); } } + +// Attempt dispatching a queued event. Returns a SuspenseInstance if it's still blocked on an inner one. +export function attemptToReplayEvent( + topLevelType: DOMTopLevelEventType, + eventSystemFlags: EventSystemFlags, + nativeEvent: AnyNativeEvent, +): null | SuspenseInstance { + // TODO: Warn if _enabled is false. + + const nativeEventTarget = getEventTarget(nativeEvent); + let targetInst = getClosestInstanceFromNode(nativeEventTarget); + + if (targetInst !== null) { + let nearestMounted = getNearestMountedFiber(targetInst); + if (nearestMounted === null) { + // This tree has been unmounted already. Replay without a target. + targetInst = null; + } else { + const tag = nearestMounted.tag; + if (tag === SuspenseComponent) { + let instance = getSuspenseInstanceFromFiber(nearestMounted); + if (instance !== null) { + // We're still blocked on an inner boundary. + // TODO: 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. + targetInst = null; + } else if (tag === HostRoot) { + // This shouldn't happen, something went wrong but to avoid blocking + // the whole system, dispatch the event without a target. + // TODO: Warn. + targetInst = null; + } else if (nearestMounted !== targetInst) { + // This also shouldn't happen but we can't trust the target. + // TODO: Warn. + targetInst = null; + } + } + } + + if (enableFlareAPI) { + if (eventSystemFlags === PLUGIN_EVENT_SYSTEM) { + dispatchEventForPluginEventSystem( + topLevelType, + eventSystemFlags, + nativeEvent, + targetInst, + ); + } else { + // React Flare event system + dispatchEventForResponderEventSystem( + (topLevelType: any), + targetInst, + nativeEvent, + nativeEventTarget, + eventSystemFlags, + ); + } + } else { + dispatchEventForPluginEventSystem( + topLevelType, + eventSystemFlags, + nativeEvent, + targetInst, + ); + } + return null; +} diff --git a/packages/react-dom/src/events/ReactDOMEventReplaying.js b/packages/react-dom/src/events/ReactDOMEventReplaying.js index 8408de552c7f6..f10f4e8585659 100644 --- a/packages/react-dom/src/events/ReactDOMEventReplaying.js +++ b/packages/react-dom/src/events/ReactDOMEventReplaying.js @@ -12,6 +12,12 @@ import type {Container, SuspenseInstance} from '../client/ReactDOMHostConfig'; import type {DOMTopLevelEventType} from 'legacy-events/TopLevelEventTypes'; import type {EventSystemFlags} from 'legacy-events/EventSystemFlags'; +import { + unstable_scheduleCallback as scheduleCallback, + unstable_NormalPriority as NormalPriority, +} from 'scheduler'; +import {attemptToReplayEvent} from './ReactDOMEventListener'; + // TODO: Upgrade this definition once we're on a newer version of Flow that // has this definition built-in. type PointerEvent = Event & {pointerId: number}; @@ -67,6 +73,8 @@ type QueuedReplayableEvent = {| nativeEvent: AnyNativeEvent, |}; +let hasScheduledReplayAttempt = false; + // The queue of discrete events to be replayed. let queuedDiscreteEvents: Array = []; @@ -141,10 +149,62 @@ export function queueDiscreteEvent( nativeEvent, ), ); + if (blockedOn === null && queuedDiscreteEvents.length === 1) { + // This probably shouldn't happen but some defensive coding might + // help us get unblocked if we have a bug. + replayUnblockedEvents(); + } +} + +function replayUnblockedEvents() { + hasScheduledReplayAttempt = false; + while (queuedDiscreteEvents.length > 0) { + let nextDiscreteEvent = queuedDiscreteEvents[0]; + if (nextDiscreteEvent.blockedOn !== null) { + // We're still blocked. + return; + } + let nextBlockedOn = attemptToReplayEvent( + nextDiscreteEvent.topLevelType, + nextDiscreteEvent.eventSystemFlags, + nextDiscreteEvent.nativeEvent, + ); + if (nextBlockedOn !== null) { + // We're still blocked. Try again later. + nextDiscreteEvent.blockedOn = nextBlockedOn; + } else { + // We've successfully replayed the first event. Let's try the next one. + queuedDiscreteEvents.shift(); + } + } } export function retryIfBlockedOn( blockedOn: Container | SuspenseInstance, ): void { - // TODO: Retry if we're blocked on this boundary. + // Mark anything that was blocked on this as no longer blocked + // and eligible for a replay. + if (queuedDiscreteEvents.length < 1) { + return; + } + let queuedEvent = queuedDiscreteEvents[0]; + if (queuedEvent.blockedOn === blockedOn) { + queuedEvent.blockedOn = null; + if (!hasScheduledReplayAttempt) { + hasScheduledReplayAttempt = true; + // Schedule a callback to attempt replaying as many events as are + // now unblocked. This first might not actually be unblocked yet. + // We could check it early to avoid scheduling an unnecessary callback. + scheduleCallback(NormalPriority, replayUnblockedEvents); + } + } + // This is a exponential search for each boundary that commits. I think it's + // worth it because we expect very few discrete events to queue up and once + // we are actually fully unblocked it will be fast to replay them. + for (let i = 1; i < queuedDiscreteEvents.length; i++) { + queuedEvent = queuedDiscreteEvents[i]; + if (queuedEvent.blockedOn === blockedOn) { + queuedEvent.blockedOn = null; + } + } } From feb0c7e38f34ad958cf3685d71ddb497d1130099 Mon Sep 17 00:00:00 2001 From: Sebastian Markbage Date: Tue, 10 Sep 2019 21:28:39 -0700 Subject: [PATCH 04/12] Unify replaying and dispatching --- .../src/events/ReactDOMEventListener.js | 109 +++++++----------- .../src/events/ReactDOMEventReplaying.js | 4 +- 2 files changed, 42 insertions(+), 71 deletions(-) diff --git a/packages/react-dom/src/events/ReactDOMEventListener.js b/packages/react-dom/src/events/ReactDOMEventListener.js index 9335359f8e429..9fdce59b3a62c 100644 --- a/packages/react-dom/src/events/ReactDOMEventListener.js +++ b/packages/react-dom/src/events/ReactDOMEventListener.js @@ -9,7 +9,8 @@ import type {AnyNativeEvent} from 'legacy-events/PluginModuleType'; import type {Fiber} from 'react-reconciler/src/ReactFiber'; -import type {SuspenseInstance} from '../client/ReactDOMHostConfig'; +import type {FiberRoot} from 'react-reconciler/src/ReactFiberRoot'; +import type {Container, SuspenseInstance} from '../client/ReactDOMHostConfig'; import type {DOMTopLevelEventType} from 'legacy-events/TopLevelEventTypes'; // Intentionally not named imports because Rollup would use dynamic dispatch for @@ -342,77 +343,40 @@ export function dispatchEvent( return; } - const nativeEventTarget = getEventTarget(nativeEvent); - let targetInst = getClosestInstanceFromNode(nativeEventTarget); + const blockedOn = attemptToDispatchEvent( + topLevelType, + eventSystemFlags, + nativeEvent, + ); - if (targetInst !== null) { - let nearestMounted = getNearestMountedFiber(targetInst); - if (nearestMounted === null) { - // This tree has been unmounted already. - targetInst = null; - } else { - const tag = nearestMounted.tag; - if (tag === SuspenseComponent) { - // TODO: Check if this boundary is indeed still hydrating. - if (isReplayableDiscreteEvent(topLevelType)) { - // Queue the event to be replayed later. Abort dispatching since we - // don't want this event dispatched twice through the event system. - // TODO: This is the first discrete event. Schedule an increased - // priority for this boundary. - queueDiscreteEvent( - getSuspenseInstanceFromFiber(nearestMounted), - topLevelType, - eventSystemFlags, - nativeEvent, - ); - return; - } else { - // This is not replayable so we'll invoke it but without a target, - // in case the event system needs to trace it. - targetInst = null; - } - } else if (tag === HostRoot) { - // TODO: Check if this boundary is indeed still a not yet mounted/hydrated root. - if (isReplayableDiscreteEvent(topLevelType)) { - // Queue the event to be replayed later. Abort dispatching since we - // don't want this event dispatched twice through the event system. - queueDiscreteEvent( - getContainerFromFiber(nearestMounted), - topLevelType, - eventSystemFlags, - nativeEvent, - ); - return; - } else { - // This is not replayable so we'll invoke it but without a target, - // in case the event system needs to trace it. - targetInst = null; - } - } 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; - } - } + if (blockedOn === null) { + // We successfully dispatched this event. + return; + } + + if (isReplayableDiscreteEvent(topLevelType)) { + // This this to be replayed later once the target is available. + queueDiscreteEvent(blockedOn, topLevelType, eventSystemFlags, nativeEvent); + return; } + // This is not replayable so we'll invoke it but without a target, + // in case the event system needs to trace it. if (enableFlareAPI) { if (eventSystemFlags === PLUGIN_EVENT_SYSTEM) { dispatchEventForPluginEventSystem( topLevelType, eventSystemFlags, nativeEvent, - targetInst, + null, ); } else { // React Flare event system dispatchEventForResponderEventSystem( (topLevelType: any), - targetInst, + null, nativeEvent, - nativeEventTarget, + getEventTarget(nativeEvent), eventSystemFlags, ); } @@ -421,17 +385,17 @@ export function dispatchEvent( topLevelType, eventSystemFlags, nativeEvent, - targetInst, + null, ); } } -// Attempt dispatching a queued event. Returns a SuspenseInstance if it's still blocked on an inner one. -export function attemptToReplayEvent( +// Attempt dispatching an event. Returns a SuspenseInstance or Container if it's blocked. +export function attemptToDispatchEvent( topLevelType: DOMTopLevelEventType, eventSystemFlags: EventSystemFlags, nativeEvent: AnyNativeEvent, -): null | SuspenseInstance { +): null | Container | SuspenseInstance { // TODO: Warn if _enabled is false. const nativeEventTarget = getEventTarget(nativeEvent); @@ -440,15 +404,16 @@ export function attemptToReplayEvent( if (targetInst !== null) { let nearestMounted = getNearestMountedFiber(targetInst); if (nearestMounted === null) { - // This tree has been unmounted already. Replay without a target. + // This tree has been unmounted already. Dispatch without a target. targetInst = null; } else { const tag = nearestMounted.tag; if (tag === SuspenseComponent) { let instance = getSuspenseInstanceFromFiber(nearestMounted); if (instance !== null) { - // We're still blocked on an inner boundary. - // TODO: This is the first discrete event in the queue. Schedule an increased + // 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; } @@ -457,13 +422,18 @@ export function attemptToReplayEvent( // TODO: Warn. targetInst = null; } else if (tag === HostRoot) { - // This shouldn't happen, something went wrong but to avoid blocking - // the whole system, dispatch the event without a target. - // TODO: Warn. + const root: FiberRoot = nearestMounted.stateNode; + if (root.hydrate) { + // If this happens during a replay something went wrong and it might block + // the whole system. + return getContainerFromFiber(nearestMounted); + } targetInst = null; } else if (nearestMounted !== targetInst) { - // This also shouldn't happen but we can't trust the target. - // TODO: Warn. + // 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; } } @@ -495,5 +465,6 @@ export function attemptToReplayEvent( targetInst, ); } + // We're not blocked on anything. return null; } diff --git a/packages/react-dom/src/events/ReactDOMEventReplaying.js b/packages/react-dom/src/events/ReactDOMEventReplaying.js index f10f4e8585659..79db963fb5182 100644 --- a/packages/react-dom/src/events/ReactDOMEventReplaying.js +++ b/packages/react-dom/src/events/ReactDOMEventReplaying.js @@ -16,7 +16,7 @@ import { unstable_scheduleCallback as scheduleCallback, unstable_NormalPriority as NormalPriority, } from 'scheduler'; -import {attemptToReplayEvent} from './ReactDOMEventListener'; +import {attemptToDispatchEvent} from './ReactDOMEventListener'; // TODO: Upgrade this definition once we're on a newer version of Flow that // has this definition built-in. @@ -164,7 +164,7 @@ function replayUnblockedEvents() { // We're still blocked. return; } - let nextBlockedOn = attemptToReplayEvent( + let nextBlockedOn = attemptToDispatchEvent( nextDiscreteEvent.topLevelType, nextDiscreteEvent.eventSystemFlags, nextDiscreteEvent.nativeEvent, From af5e6524e0962f9f61c0963b93bdba3a054b2f2f Mon Sep 17 00:00:00 2001 From: Sebastian Markbage Date: Tue, 10 Sep 2019 11:43:24 -0700 Subject: [PATCH 05/12] Enable tests from before These tests were written with replaying in mind and now we can properly enable them. --- ...DOMServerPartialHydration-test.internal.js | 23 ------------------- .../ReactServerRenderingHydration-test.js | 20 +++++----------- 2 files changed, 6 insertions(+), 37 deletions(-) diff --git a/packages/react-dom/src/__tests__/ReactDOMServerPartialHydration-test.internal.js b/packages/react-dom/src/__tests__/ReactDOMServerPartialHydration-test.internal.js index f4e465ea4d39a..496cf09f6f552 100644 --- a/packages/react-dom/src/__tests__/ReactDOMServerPartialHydration-test.internal.js +++ b/packages/react-dom/src/__tests__/ReactDOMServerPartialHydration-test.internal.js @@ -1805,12 +1805,6 @@ describe('ReactDOMServerPartialHydration', () => { Scheduler.unstable_flushAll(); jest.runAllTimers(); - // TODO: With selective hydration the event should've been replayed - // but for now we'll have to issue it again. - act(() => { - a.click(); - }); - expect(clicks).toBe(1); expect(container.textContent).toBe('Hello'); @@ -1885,12 +1879,6 @@ describe('ReactDOMServerPartialHydration', () => { Scheduler.unstable_flushAll(); jest.runAllTimers(); - // TODO: With selective hydration the event should've been replayed - // but for now we'll have to issue it again. - act(() => { - a.click(); - }); - expect(onEvent).toHaveBeenCalledTimes(1); document.body.removeChild(container); @@ -1963,12 +1951,6 @@ describe('ReactDOMServerPartialHydration', () => { Scheduler.unstable_flushAll(); jest.runAllTimers(); - // TODO: With selective hydration the event should've been replayed - // but for now we'll have to issue it again. - act(() => { - span.click(); - }); - expect(clicksOnChild).toBe(1); // This will be zero due to the stopPropagation. expect(clicksOnParent).toBe(0); @@ -2047,11 +2029,6 @@ describe('ReactDOMServerPartialHydration', () => { jest.runAllTimers(); // We're now full hydrated. - // TODO: With selective hydration the event should've been replayed - // but for now we'll have to issue it again. - act(() => { - a.click(); - }); expect(clicks).toBe(1); diff --git a/packages/react-dom/src/__tests__/ReactServerRenderingHydration-test.js b/packages/react-dom/src/__tests__/ReactServerRenderingHydration-test.js index 82cc1443729f4..571de9d12eeda 100644 --- a/packages/react-dom/src/__tests__/ReactServerRenderingHydration-test.js +++ b/packages/react-dom/src/__tests__/ReactServerRenderingHydration-test.js @@ -13,7 +13,6 @@ let React; let ReactDOM; let ReactDOMServer; let Scheduler; -let act; // These tests rely both on ReactDOMServer and ReactDOM. // If a test only needs ReactDOMServer, put it in ReactServerRendering-test instead. @@ -24,7 +23,6 @@ describe('ReactDOMServerHydration', () => { ReactDOM = require('react-dom'); ReactDOMServer = require('react-dom/server'); Scheduler = require('scheduler'); - act = require('react-dom/test-utils').act; }); it('should have the correct mounting behavior (old hydrate API)', () => { @@ -587,13 +585,12 @@ describe('ReactDOMServerHydration', () => { expect(clicks).toBe(0); // Finish the rest of the hydration. - expect(Scheduler).toFlushAndYield(['Sibling2']); - - // TODO: With selective hydration the event should've been replayed - // but for now we'll have to issue it again. - act(() => { - a.click(); - }); + if (__DEV__) { + // In DEV effects gets double invoked. + expect(Scheduler).toFlushAndYield(['Sibling2', 'Button', 'Button']); + } else { + expect(Scheduler).toFlushAndYield(['Sibling2', 'Button']); + } expect(clicks).toBe(1); @@ -649,11 +646,6 @@ describe('ReactDOMServerHydration', () => { Scheduler.unstable_flushAll(); // We're now full hydrated. - // TODO: With selective hydration the event should've been replayed - // but for now we'll have to issue it again. - act(() => { - a.click(); - }); expect(clicks).toBe(1); From ba6736868d7b89b2313ed9db8997f9e364e58b22 Mon Sep 17 00:00:00 2001 From: Sebastian Markbage Date: Mon, 9 Sep 2019 23:14:17 -0700 Subject: [PATCH 06/12] Add continuous events These events only replay their last target if the target is not yet hydrated. That way we don't have to wait for a previously hovered boundary before invoking the current target. --- .../src/events/ReactDOMEventListener.js | 28 +- .../src/events/ReactDOMEventReplaying.js | 253 ++++++++++++++++-- 2 files changed, 257 insertions(+), 24 deletions(-) diff --git a/packages/react-dom/src/events/ReactDOMEventListener.js b/packages/react-dom/src/events/ReactDOMEventListener.js index 9fdce59b3a62c..d6903872aac70 100644 --- a/packages/react-dom/src/events/ReactDOMEventListener.js +++ b/packages/react-dom/src/events/ReactDOMEventListener.js @@ -28,6 +28,8 @@ import { isReplayableDiscreteEvent, queueDiscreteEvent, hasQueuedDiscreteEvents, + clearIfContinuousEvent, + queueIfContinuousEvent, } from './ReactDOMEventReplaying'; import { getNearestMountedFiber, @@ -351,6 +353,7 @@ export function dispatchEvent( if (blockedOn === null) { // We successfully dispatched this event. + clearIfContinuousEvent(topLevelType, nativeEvent); return; } @@ -360,17 +363,33 @@ export function dispatchEvent( return; } + if ( + queueIfContinuousEvent( + blockedOn, + topLevelType, + eventSystemFlags, + nativeEvent, + ) + ) { + return; + } + + // We need to clear only if we didn't queue because + // queueing is accummulative. + clearIfContinuousEvent(topLevelType, nativeEvent); + // This is not replayable so we'll invoke it but without a target, // in case the event system needs to trace it. if (enableFlareAPI) { - if (eventSystemFlags === PLUGIN_EVENT_SYSTEM) { + if (eventSystemFlags & PLUGIN_EVENT_SYSTEM) { dispatchEventForPluginEventSystem( topLevelType, eventSystemFlags, nativeEvent, null, ); - } else { + } + if (eventSystemFlags & RESPONDER_EVENT_SYSTEM) { // React Flare event system dispatchEventForResponderEventSystem( (topLevelType: any), @@ -440,14 +459,15 @@ export function attemptToDispatchEvent( } if (enableFlareAPI) { - if (eventSystemFlags === PLUGIN_EVENT_SYSTEM) { + if (eventSystemFlags & PLUGIN_EVENT_SYSTEM) { dispatchEventForPluginEventSystem( topLevelType, eventSystemFlags, nativeEvent, targetInst, ); - } else { + } + if (eventSystemFlags & RESPONDER_EVENT_SYSTEM) { // React Flare event system dispatchEventForResponderEventSystem( (topLevelType: any), diff --git a/packages/react-dom/src/events/ReactDOMEventReplaying.js b/packages/react-dom/src/events/ReactDOMEventReplaying.js index 79db963fb5182..16c14a1882966 100644 --- a/packages/react-dom/src/events/ReactDOMEventReplaying.js +++ b/packages/react-dom/src/events/ReactDOMEventReplaying.js @@ -20,7 +20,10 @@ import {attemptToDispatchEvent} from './ReactDOMEventListener'; // TODO: Upgrade this definition once we're on a newer version of Flow that // has this definition built-in. -type PointerEvent = Event & {pointerId: number}; +type PointerEvent = Event & { + pointerId: number, + relatedTarget: EventTarget | null, +}; import { TOP_MOUSE_DOWN, @@ -63,7 +66,6 @@ import { TOP_LOST_POINTER_CAPTURE, TOP_FOCUS, TOP_BLUR, - TOP_SELECTION_CHANGE, } from './DOMTopLevelEventTypes'; type QueuedReplayableEvent = {| @@ -78,10 +80,26 @@ let hasScheduledReplayAttempt = false; // The queue of discrete events to be replayed. let queuedDiscreteEvents: Array = []; +// Indicates if any continuous event targets are non-null for early bailout. +let hasAnyQueuedContinuousEvents: boolean = false; +// The last of each continuous event type. We only need to replay the last one +// if the last target was dehydrated. +let queuedFocus: null | QueuedReplayableEvent = null; +let queuedDrag: null | QueuedReplayableEvent = null; +let queuedMouse: null | QueuedReplayableEvent = null; +// For pointer events there can be one latest event per pointerId. +let queuedPointers: Map = new Map(); +let queuedPointerCaptures: Map = new Map(); +// We could consider replaying selectionchange and touchmoves too. + export function hasQueuedDiscreteEvents(): boolean { return queuedDiscreteEvents.length > 0; } +export function hasQueuedContinuousEvents(): boolean { + return hasAnyQueuedContinuousEvents; +} + export function isReplayableDiscreteEvent( eventType: DOMTopLevelEventType, ): boolean { @@ -156,13 +174,177 @@ export function queueDiscreteEvent( } } +// Resets the replaying for this type of continuous event to no event. +export function clearIfContinuousEvent( + topLevelType: DOMTopLevelEventType, + nativeEvent: AnyNativeEvent, +): void { + switch (topLevelType) { + case TOP_FOCUS: + case TOP_BLUR: + queuedFocus = null; + break; + case TOP_DRAG_ENTER: + case TOP_DRAG_LEAVE: + queuedDrag = null; + break; + case TOP_MOUSE_OVER: + case TOP_MOUSE_OUT: + queuedMouse = null; + break; + case TOP_POINTER_OVER: + case TOP_POINTER_OUT: { + let pointerId = ((nativeEvent: any): PointerEvent).pointerId; + queuedPointers.delete(pointerId); + break; + } + case TOP_GOT_POINTER_CAPTURE: + case TOP_LOST_POINTER_CAPTURE: { + let pointerId = ((nativeEvent: any): PointerEvent).pointerId; + queuedPointerCaptures.delete(pointerId); + break; + } + } +} + +function accumulateOrCreateQueuedReplayableEvent( + existingQueuedEvent: null | QueuedReplayableEvent, + blockedOn: null | Container | SuspenseInstance, + topLevelType: DOMTopLevelEventType, + eventSystemFlags: EventSystemFlags, + nativeEvent: AnyNativeEvent, +): QueuedReplayableEvent { + if ( + existingQueuedEvent === null || + existingQueuedEvent.nativeEvent !== nativeEvent + ) { + return createQueuedReplayableEvent( + blockedOn, + topLevelType, + eventSystemFlags, + nativeEvent, + ); + } + // If we have already queued this exact event, then it's because + // the different event systems have different DOM event listeners. + // We can accumulate the flags and store a single event to be + // replayed. + existingQueuedEvent.eventSystemFlags |= eventSystemFlags; + return existingQueuedEvent; +} + +export function queueIfContinuousEvent( + blockedOn: null | Container | SuspenseInstance, + topLevelType: DOMTopLevelEventType, + eventSystemFlags: EventSystemFlags, + nativeEvent: AnyNativeEvent, +): boolean { + // These set relatedTarget to null because the replayed event will be treated as if we + // moved from outside the window (no target) onto the target once it hydrates. + // Instead of mutating we could clone the event. + switch (topLevelType) { + case TOP_FOCUS: { + const focusEvent = ((nativeEvent: any): FocusEvent); + queuedFocus = accumulateOrCreateQueuedReplayableEvent( + queuedFocus, + blockedOn, + topLevelType, + eventSystemFlags, + focusEvent, + ); + return true; + } + case TOP_DRAG_ENTER: { + const dragEvent = ((nativeEvent: any): DragEvent); + queuedDrag = accumulateOrCreateQueuedReplayableEvent( + queuedDrag, + blockedOn, + topLevelType, + eventSystemFlags, + dragEvent, + ); + return true; + } + case TOP_MOUSE_OVER: { + const mouseEvent = ((nativeEvent: any): MouseEvent); + queuedMouse = accumulateOrCreateQueuedReplayableEvent( + queuedMouse, + blockedOn, + topLevelType, + eventSystemFlags, + mouseEvent, + ); + return true; + } + case TOP_POINTER_OVER: { + const pointerEvent = ((nativeEvent: any): PointerEvent); + const pointerId = pointerEvent.pointerId; + queuedPointers.set( + pointerId, + accumulateOrCreateQueuedReplayableEvent( + queuedPointers.get(pointerId) || null, + blockedOn, + topLevelType, + eventSystemFlags, + pointerEvent, + ), + ); + return true; + } + case TOP_GOT_POINTER_CAPTURE: { + const pointerEvent = ((nativeEvent: any): PointerEvent); + const pointerId = pointerEvent.pointerId; + queuedPointerCaptures.set( + pointerId, + accumulateOrCreateQueuedReplayableEvent( + queuedPointerCaptures.get(pointerId) || null, + blockedOn, + topLevelType, + eventSystemFlags, + pointerEvent, + ), + ); + return true; + } + } + return false; +} + +function attemptReplayQueuedEvent(queuedEvent: QueuedReplayableEvent): boolean { + if (queuedEvent.blockedOn !== null) { + return false; + } + let nextBlockedOn = attemptToDispatchEvent( + queuedEvent.topLevelType, + queuedEvent.eventSystemFlags, + queuedEvent.nativeEvent, + ); + if (nextBlockedOn !== null) { + // We're still blocked. Try again later. + queuedEvent.blockedOn = nextBlockedOn; + return false; + } + return true; +} + +function attemptReplayQueuedEventInMap( + queuedEvent: QueuedReplayableEvent, + key: number, + map: Map, +): void { + if (attemptReplayQueuedEvent(queuedEvent)) { + map.delete(key); + } +} + function replayUnblockedEvents() { hasScheduledReplayAttempt = false; + // First replay discrete events. while (queuedDiscreteEvents.length > 0) { let nextDiscreteEvent = queuedDiscreteEvents[0]; if (nextDiscreteEvent.blockedOn !== null) { // We're still blocked. - return; + break; } let nextBlockedOn = attemptToDispatchEvent( nextDiscreteEvent.topLevelType, @@ -177,18 +359,25 @@ function replayUnblockedEvents() { queuedDiscreteEvents.shift(); } } + // Next replay any continuous events. + if (queuedFocus !== null && attemptReplayQueuedEvent(queuedFocus)) { + queuedFocus = null; + } + if (queuedDrag !== null && attemptReplayQueuedEvent(queuedDrag)) { + queuedDrag = null; + } + if (queuedMouse !== null && attemptReplayQueuedEvent(queuedMouse)) { + queuedMouse = null; + } + queuedPointers.forEach(attemptReplayQueuedEventInMap); + queuedPointerCaptures.forEach(attemptReplayQueuedEventInMap); } -export function retryIfBlockedOn( - blockedOn: Container | SuspenseInstance, -): void { - // Mark anything that was blocked on this as no longer blocked - // and eligible for a replay. - if (queuedDiscreteEvents.length < 1) { - return; - } - let queuedEvent = queuedDiscreteEvents[0]; - if (queuedEvent.blockedOn === blockedOn) { +function scheduleCallbackIfUnblocked( + queuedEvent: QueuedReplayableEvent, + unblocked: Container | SuspenseInstance, +) { + if (queuedEvent.blockedOn === unblocked) { queuedEvent.blockedOn = null; if (!hasScheduledReplayAttempt) { hasScheduledReplayAttempt = true; @@ -198,13 +387,37 @@ export function retryIfBlockedOn( scheduleCallback(NormalPriority, replayUnblockedEvents); } } - // This is a exponential search for each boundary that commits. I think it's - // worth it because we expect very few discrete events to queue up and once - // we are actually fully unblocked it will be fast to replay them. - for (let i = 1; i < queuedDiscreteEvents.length; i++) { - queuedEvent = queuedDiscreteEvents[i]; - if (queuedEvent.blockedOn === blockedOn) { - queuedEvent.blockedOn = null; +} + +export function retryIfBlockedOn( + unblocked: Container | SuspenseInstance, +): void { + // Mark anything that was blocked on this as no longer blocked + // and eligible for a replay. + if (queuedDiscreteEvents.length > 0) { + scheduleCallbackIfUnblocked(queuedDiscreteEvents[0], unblocked); + // This is a exponential search for each boundary that commits. I think it's + // worth it because we expect very few discrete events to queue up and once + // we are actually fully unblocked it will be fast to replay them. + for (let i = 1; i < queuedDiscreteEvents.length; i++) { + let queuedEvent = queuedDiscreteEvents[i]; + if (queuedEvent.blockedOn === unblocked) { + queuedEvent.blockedOn = null; + } } } + + if (queuedFocus !== null) { + scheduleCallbackIfUnblocked(queuedFocus, unblocked); + } + if (queuedDrag !== null) { + scheduleCallbackIfUnblocked(queuedDrag, unblocked); + } + if (queuedMouse !== null) { + scheduleCallbackIfUnblocked(queuedMouse, unblocked); + } + const unblock = queuedEvent => + scheduleCallbackIfUnblocked(queuedEvent, unblocked); + queuedPointers.forEach(unblock); + queuedPointerCaptures.forEach(unblock); } From a7dc6a06e363b1a7505195080fae4d43134d5efa Mon Sep 17 00:00:00 2001 From: Sebastian Markbage Date: Fri, 13 Sep 2019 10:20:29 -0700 Subject: [PATCH 07/12] Mark system flags as a replay and pass to legacy events That way we can check if this is a replay and therefore needs a special case. One such special case is "mouseover" where we check the relatedTarget. --- packages/legacy-events/EventPluginHub.js | 5 +++++ packages/legacy-events/EventSystemFlags.js | 1 + packages/legacy-events/PluginModuleType.js | 2 ++ packages/legacy-events/ResponderEventPlugin.js | 1 + .../__tests__/ResponderEventPlugin-test.internal.js | 2 ++ .../react-dom/src/events/BeforeInputEventPlugin.js | 1 + packages/react-dom/src/events/ChangeEventPlugin.js | 1 + packages/react-dom/src/events/EnterLeaveEventPlugin.js | 8 +++++++- packages/react-dom/src/events/ReactDOMEventListener.js | 10 ++++++++-- .../react-dom/src/events/ReactDOMEventReplaying.js | 3 ++- packages/react-dom/src/events/SelectEventPlugin.js | 1 + packages/react-dom/src/events/SimpleEventPlugin.js | 2 ++ .../src/ReactFabricEventEmitter.js | 2 ++ .../src/ReactNativeBridgeEventPlugin.js | 2 ++ .../src/ReactNativeEventEmitter.js | 2 ++ 15 files changed, 39 insertions(+), 4 deletions(-) diff --git a/packages/legacy-events/EventPluginHub.js b/packages/legacy-events/EventPluginHub.js index 297d0cbdcc44a..1ab4bc3fa4561 100644 --- a/packages/legacy-events/EventPluginHub.js +++ b/packages/legacy-events/EventPluginHub.js @@ -22,6 +22,7 @@ import type {ReactSyntheticEvent} from './ReactSyntheticEventType'; import type {Fiber} from 'react-reconciler/src/ReactFiber'; import type {AnyNativeEvent} from './PluginModuleType'; import type {TopLevelType} from './TopLevelEventTypes'; +import type {EventSystemFlags} from 'legacy-events/EventSystemFlags'; function isInteractive(tag) { return ( @@ -131,6 +132,7 @@ export function getListener(inst: Fiber, registrationName: string) { */ function extractPluginEvents( topLevelType: TopLevelType, + eventSystemFlags: EventSystemFlags, targetInst: null | Fiber, nativeEvent: AnyNativeEvent, nativeEventTarget: EventTarget, @@ -142,6 +144,7 @@ function extractPluginEvents( if (possiblePlugin) { const extractedEvents = possiblePlugin.extractEvents( topLevelType, + eventSystemFlags, targetInst, nativeEvent, nativeEventTarget, @@ -156,12 +159,14 @@ function extractPluginEvents( export function runExtractedPluginEventsInBatch( topLevelType: TopLevelType, + eventSystemFlags: EventSystemFlags, targetInst: null | Fiber, nativeEvent: AnyNativeEvent, nativeEventTarget: EventTarget, ) { const events = extractPluginEvents( topLevelType, + eventSystemFlags, targetInst, nativeEvent, nativeEventTarget, diff --git a/packages/legacy-events/EventSystemFlags.js b/packages/legacy-events/EventSystemFlags.js index be5e3544a2ea4..1ad8aa01c2518 100644 --- a/packages/legacy-events/EventSystemFlags.js +++ b/packages/legacy-events/EventSystemFlags.js @@ -14,3 +14,4 @@ export const RESPONDER_EVENT_SYSTEM = 1 << 1; export const IS_PASSIVE = 1 << 2; export const IS_ACTIVE = 1 << 3; export const PASSIVE_NOT_SUPPORTED = 1 << 4; +export const IS_REPLAYED = 1 << 5; diff --git a/packages/legacy-events/PluginModuleType.js b/packages/legacy-events/PluginModuleType.js index cd7a07661ab4e..c1cf5fc6783e8 100644 --- a/packages/legacy-events/PluginModuleType.js +++ b/packages/legacy-events/PluginModuleType.js @@ -13,6 +13,7 @@ import type { ReactSyntheticEvent, } from './ReactSyntheticEventType'; import type {TopLevelType} from './TopLevelEventTypes'; +import type {EventSystemFlags} from 'legacy-events/EventSystemFlags'; export type EventTypes = {[key: string]: DispatchConfig}; @@ -24,6 +25,7 @@ export type PluginModule = { eventTypes: EventTypes, extractEvents: ( topLevelType: TopLevelType, + eventSystemFlags: EventSystemFlags, targetInst: null | Fiber, nativeTarget: NativeEvent, nativeEventTarget: EventTarget, diff --git a/packages/legacy-events/ResponderEventPlugin.js b/packages/legacy-events/ResponderEventPlugin.js index 235e688299475..aea49578a397f 100644 --- a/packages/legacy-events/ResponderEventPlugin.js +++ b/packages/legacy-events/ResponderEventPlugin.js @@ -504,6 +504,7 @@ const ResponderEventPlugin = { */ extractEvents: function( topLevelType, + eventSystemFlags, targetInst, nativeEvent, nativeEventTarget, diff --git a/packages/legacy-events/__tests__/ResponderEventPlugin-test.internal.js b/packages/legacy-events/__tests__/ResponderEventPlugin-test.internal.js index c1959659a1db6..bfe72b3d238d7 100644 --- a/packages/legacy-events/__tests__/ResponderEventPlugin-test.internal.js +++ b/packages/legacy-events/__tests__/ResponderEventPlugin-test.internal.js @@ -10,6 +10,7 @@ 'use strict'; const {HostComponent} = require('shared/ReactWorkTags'); +const {RESPONDER_EVENT_SYSTEM} = require('legacy-events/EventSystemFlags'); let EventBatching; let EventPluginUtils; @@ -313,6 +314,7 @@ const run = function(config, hierarchyConfig, nativeEventConfig) { // Trigger the event const extractedEvents = ResponderEventPlugin.extractEvents( nativeEventConfig.topLevelType, + RESPONDER_EVENT_SYSTEM, nativeEventConfig.targetInst, nativeEventConfig.nativeEvent, nativeEventConfig.target, diff --git a/packages/react-dom/src/events/BeforeInputEventPlugin.js b/packages/react-dom/src/events/BeforeInputEventPlugin.js index 1a6225e827f7f..0f6155a217942 100644 --- a/packages/react-dom/src/events/BeforeInputEventPlugin.js +++ b/packages/react-dom/src/events/BeforeInputEventPlugin.js @@ -464,6 +464,7 @@ const BeforeInputEventPlugin = { extractEvents: function( topLevelType, + eventSystemFlags, targetInst, nativeEvent, nativeEventTarget, diff --git a/packages/react-dom/src/events/ChangeEventPlugin.js b/packages/react-dom/src/events/ChangeEventPlugin.js index 3f4690b81538b..11f405b54a544 100644 --- a/packages/react-dom/src/events/ChangeEventPlugin.js +++ b/packages/react-dom/src/events/ChangeEventPlugin.js @@ -262,6 +262,7 @@ const ChangeEventPlugin = { extractEvents: function( topLevelType, + eventSystemFlags, targetInst, nativeEvent, nativeEventTarget, diff --git a/packages/react-dom/src/events/EnterLeaveEventPlugin.js b/packages/react-dom/src/events/EnterLeaveEventPlugin.js index 4133006488171..4b3e1e32e0d49 100644 --- a/packages/react-dom/src/events/EnterLeaveEventPlugin.js +++ b/packages/react-dom/src/events/EnterLeaveEventPlugin.js @@ -13,6 +13,7 @@ import { TOP_POINTER_OUT, TOP_POINTER_OVER, } from './DOMTopLevelEventTypes'; +import {IS_REPLAYED} from 'legacy-events/EventSystemFlags'; import SyntheticMouseEvent from './SyntheticMouseEvent'; import SyntheticPointerEvent from './SyntheticPointerEvent'; import { @@ -52,6 +53,7 @@ const EnterLeaveEventPlugin = { */ extractEvents: function( topLevelType, + eventSystemFlags, targetInst, nativeEvent, nativeEventTarget, @@ -61,7 +63,11 @@ const EnterLeaveEventPlugin = { const isOutEvent = topLevelType === TOP_MOUSE_OUT || topLevelType === TOP_POINTER_OUT; - if (isOverEvent && (nativeEvent.relatedTarget || nativeEvent.fromElement)) { + if (isOverEvent && (eventSystemFlags & IS_REPLAYED) === 0 && (nativeEvent.relatedTarget || nativeEvent.fromElement)) { + // If this is an over event with a target, then we've already dispatched + // the event in the out event of the other target. If this is replayed, + // then it's because we couldn't dispatch against this target previously + // so we have to do it now instead. return null; } diff --git a/packages/react-dom/src/events/ReactDOMEventListener.js b/packages/react-dom/src/events/ReactDOMEventListener.js index d6903872aac70..d0287e3d83282 100644 --- a/packages/react-dom/src/events/ReactDOMEventListener.js +++ b/packages/react-dom/src/events/ReactDOMEventListener.js @@ -82,12 +82,13 @@ const {getEventPriority} = SimpleEventPlugin; const CALLBACK_BOOKKEEPING_POOL_SIZE = 10; const callbackBookkeepingPool = []; -type BookKeepingInstance = { +type BookKeepingInstance = {| topLevelType: DOMTopLevelEventType | null, + eventSystemFlags: EventSystemFlags, nativeEvent: AnyNativeEvent | null, targetInst: Fiber | null, ancestors: Array, -}; +|}; /** * Find the deepest React component completely containing the root of the @@ -116,16 +117,19 @@ function getTopLevelCallbackBookKeeping( topLevelType: DOMTopLevelEventType, nativeEvent: AnyNativeEvent, targetInst: Fiber | null, + eventSystemFlags: EventSystemFlags, ): BookKeepingInstance { if (callbackBookkeepingPool.length) { const instance = callbackBookkeepingPool.pop(); instance.topLevelType = topLevelType; + instance.eventSystemFlags = eventSystemFlags; instance.nativeEvent = nativeEvent; instance.targetInst = targetInst; return instance; } return { topLevelType, + eventSystemFlags, nativeEvent, targetInst, ancestors: [], @@ -177,6 +181,7 @@ function handleTopLevel(bookKeeping: BookKeepingInstance) { runExtractedPluginEventsInBatch( topLevelType, + bookKeeping.eventSystemFlags, targetInst, nativeEvent, eventTarget, @@ -313,6 +318,7 @@ function dispatchEventForPluginEventSystem( topLevelType, nativeEvent, targetInst, + eventSystemFlags, ); try { diff --git a/packages/react-dom/src/events/ReactDOMEventReplaying.js b/packages/react-dom/src/events/ReactDOMEventReplaying.js index 16c14a1882966..8aad6e7fdb1af 100644 --- a/packages/react-dom/src/events/ReactDOMEventReplaying.js +++ b/packages/react-dom/src/events/ReactDOMEventReplaying.js @@ -67,6 +67,7 @@ import { TOP_FOCUS, TOP_BLUR, } from './DOMTopLevelEventTypes'; +import {IS_REPLAYED} from 'legacy-events/EventSystemFlags'; type QueuedReplayableEvent = {| blockedOn: null | Container | SuspenseInstance, @@ -148,7 +149,7 @@ function createQueuedReplayableEvent( return { blockedOn, topLevelType, - eventSystemFlags, + eventSystemFlags: eventSystemFlags | IS_REPLAYED, nativeEvent, }; } diff --git a/packages/react-dom/src/events/SelectEventPlugin.js b/packages/react-dom/src/events/SelectEventPlugin.js index cff6bcf9f5c04..4af2a8e97256c 100644 --- a/packages/react-dom/src/events/SelectEventPlugin.js +++ b/packages/react-dom/src/events/SelectEventPlugin.js @@ -162,6 +162,7 @@ const SelectEventPlugin = { extractEvents: function( topLevelType, + eventSystemFlags, targetInst, nativeEvent, nativeEventTarget, diff --git a/packages/react-dom/src/events/SimpleEventPlugin.js b/packages/react-dom/src/events/SimpleEventPlugin.js index ffcc9b61b296b..ea90a61ae1804 100644 --- a/packages/react-dom/src/events/SimpleEventPlugin.js +++ b/packages/react-dom/src/events/SimpleEventPlugin.js @@ -18,6 +18,7 @@ import type { } from 'legacy-events/ReactSyntheticEventType'; import type {Fiber} from 'react-reconciler/src/ReactFiber'; import type {EventTypes, PluginModule} from 'legacy-events/PluginModuleType'; +import type {EventSystemFlags} from 'legacy-events/EventSystemFlags'; import { DiscreteEvent, @@ -247,6 +248,7 @@ const SimpleEventPlugin: PluginModule & { extractEvents: function( topLevelType: TopLevelType, + eventSystemFlags: EventSystemFlags, targetInst: null | Fiber, nativeEvent: MouseEvent, nativeEventTarget: EventTarget, diff --git a/packages/react-native-renderer/src/ReactFabricEventEmitter.js b/packages/react-native-renderer/src/ReactFabricEventEmitter.js index f702c80408a31..66e434f811a2e 100644 --- a/packages/react-native-renderer/src/ReactFabricEventEmitter.js +++ b/packages/react-native-renderer/src/ReactFabricEventEmitter.js @@ -9,6 +9,7 @@ import type {Fiber} from 'react-reconciler/src/ReactFiber'; +import {RESPONDER_EVENT_SYSTEM} from 'legacy-events/EventSystemFlags'; import { getListener, runExtractedPluginEventsInBatch, @@ -41,6 +42,7 @@ export function dispatchEvent( // Heritage plugin event system runExtractedPluginEventsInBatch( topLevelType, + RESPONDER_EVENT_SYSTEM, targetFiber, nativeEvent, nativeEvent.target, diff --git a/packages/react-native-renderer/src/ReactNativeBridgeEventPlugin.js b/packages/react-native-renderer/src/ReactNativeBridgeEventPlugin.js index 27debbe52c91d..eaae46a50723d 100644 --- a/packages/react-native-renderer/src/ReactNativeBridgeEventPlugin.js +++ b/packages/react-native-renderer/src/ReactNativeBridgeEventPlugin.js @@ -8,6 +8,7 @@ */ import type {AnyNativeEvent} from 'legacy-events/PluginModuleType'; +import type {EventSystemFlags} from 'legacy-events/EventSystemFlags'; import { accumulateTwoPhaseDispatches, accumulateDirectDispatches, @@ -32,6 +33,7 @@ const ReactNativeBridgeEventPlugin = { */ extractEvents: function( topLevelType: TopLevelType, + eventSystemFlags: EventSystemFlags, targetInst: null | Object, nativeEvent: AnyNativeEvent, nativeEventTarget: Object, diff --git a/packages/react-native-renderer/src/ReactNativeEventEmitter.js b/packages/react-native-renderer/src/ReactNativeEventEmitter.js index 9f21b39c7d619..63f356c44bd17 100644 --- a/packages/react-native-renderer/src/ReactNativeEventEmitter.js +++ b/packages/react-native-renderer/src/ReactNativeEventEmitter.js @@ -7,6 +7,7 @@ * @flow */ +import {RESPONDER_EVENT_SYSTEM} from 'legacy-events/EventSystemFlags'; import { getListener, runExtractedPluginEventsInBatch, @@ -100,6 +101,7 @@ function _receiveRootNodeIDEvent( batchedUpdates(function() { runExtractedPluginEventsInBatch( topLevelType, + RESPONDER_EVENT_SYSTEM, inst, nativeEvent, nativeEvent.target, From b2811b0f05e065d858ccd4c94f280ef88267ee60 Mon Sep 17 00:00:00 2001 From: Sebastian Markbage Date: Fri, 13 Sep 2019 14:40:16 -0700 Subject: [PATCH 08/12] Eagerly listen to all replayable events To minimize breakages in a minor, I only do this for the new root APIs since replaying only matters there anyway. Only if hydrating. For Flare, I have to attach all active listeners since the current system has one DOM listener for each. In a follow up I plan on optimizing that by only attaching one if there's at least one active listener which would allow us to start with only passive and then upgrade. --- ...DOMServerPartialHydration-test.internal.js | 8 +- .../ReactServerRenderingHydration-test.js | 10 ++- packages/react-dom/src/client/ReactDOM.js | 32 ++++--- .../src/events/EnterLeaveEventPlugin.js | 6 +- .../src/events/ReactBrowserEventEmitter.js | 80 +++++++++-------- .../src/events/ReactDOMEventReplaying.js | 86 ++++++++++++++++++- 6 files changed, 169 insertions(+), 53 deletions(-) diff --git a/packages/react-dom/src/__tests__/ReactDOMServerPartialHydration-test.internal.js b/packages/react-dom/src/__tests__/ReactDOMServerPartialHydration-test.internal.js index 496cf09f6f552..d9c7aa787b49b 100644 --- a/packages/react-dom/src/__tests__/ReactDOMServerPartialHydration-test.internal.js +++ b/packages/react-dom/src/__tests__/ReactDOMServerPartialHydration-test.internal.js @@ -1862,6 +1862,12 @@ describe('ReactDOMServerPartialHydration', () => { suspend = true; let root = ReactDOM.unstable_createRoot(container, {hydrate: true}); root.render(); + + // We'll do one click before hydrating. + a.click(); + // This should be delayed. + expect(onEvent).toHaveBeenCalledTimes(0); + Scheduler.unstable_flushAll(); jest.runAllTimers(); @@ -1879,7 +1885,7 @@ describe('ReactDOMServerPartialHydration', () => { Scheduler.unstable_flushAll(); jest.runAllTimers(); - expect(onEvent).toHaveBeenCalledTimes(1); + expect(onEvent).toHaveBeenCalledTimes(2); document.body.removeChild(container); }); diff --git a/packages/react-dom/src/__tests__/ReactServerRenderingHydration-test.js b/packages/react-dom/src/__tests__/ReactServerRenderingHydration-test.js index 571de9d12eeda..33e56bba260ef 100644 --- a/packages/react-dom/src/__tests__/ReactServerRenderingHydration-test.js +++ b/packages/react-dom/src/__tests__/ReactServerRenderingHydration-test.js @@ -568,6 +568,13 @@ describe('ReactDOMServerHydration', () => { // Hydrate asynchronously. let root = ReactDOM.unstable_createRoot(container, {hydrate: true}); root.render(); + + // We haven't started hydrating yet. + a.click(); + // Clicking should not invoke the event yet because we haven't committed + // the hydration yet. + expect(clicks).toBe(0); + // Flush part way through the render. if (__DEV__) { // In DEV effects gets double invoked. @@ -592,7 +599,8 @@ describe('ReactDOMServerHydration', () => { expect(Scheduler).toFlushAndYield(['Sibling2', 'Button']); } - expect(clicks).toBe(1); + // We should have picked up both events now. + expect(clicks).toBe(2); expect(container.textContent).toBe('Sibling'); diff --git a/packages/react-dom/src/client/ReactDOM.js b/packages/react-dom/src/client/ReactDOM.js index 80ee16b7bf013..778796f3a6403 100644 --- a/packages/react-dom/src/client/ReactDOM.js +++ b/packages/react-dom/src/client/ReactDOM.js @@ -74,6 +74,7 @@ import { } from './ReactDOMComponentTree'; import {restoreControlledState} from './ReactDOMComponent'; import {dispatchEvent} from '../events/ReactDOMEventListener'; +import {eagerlyTrapReplayableEvents} from '../events/ReactDOMEventReplaying'; import { ELEMENT_NODE, COMMENT_NODE, @@ -365,7 +366,7 @@ ReactWork.prototype._onCommit = function(): void { } }; -function ReactSyncRoot( +function createRootImpl( container: DOMContainer, tag: RootTag, options: void | RootOptions, @@ -375,22 +376,27 @@ function ReactSyncRoot( const hydrationCallbacks = (options != null && options.hydrationOptions) || null; const root = createContainer(container, tag, hydrate, hydrationCallbacks); - this._internalRoot = root; markContainerAsRoot(root.current, container); + if (hydrate && tag !== LegacyRoot) { + const doc = + container.nodeType === DOCUMENT_NODE + ? container + : container.ownerDocument; + eagerlyTrapReplayableEvents(doc); + } + return root; +} + +function ReactSyncRoot( + container: DOMContainer, + tag: RootTag, + options: void | RootOptions, +) { + this._internalRoot = createRootImpl(container, tag, options); } function ReactRoot(container: DOMContainer, options: void | RootOptions) { - const hydrate = options != null && options.hydrate === true; - const hydrationCallbacks = - (options != null && options.hydrationOptions) || null; - const root = createContainer( - container, - ConcurrentRoot, - hydrate, - hydrationCallbacks, - ); - this._internalRoot = root; - markContainerAsRoot(root.current, container); + this._internalRoot = createRootImpl(container, ConcurrentRoot, options); } ReactRoot.prototype.render = ReactSyncRoot.prototype.render = function( diff --git a/packages/react-dom/src/events/EnterLeaveEventPlugin.js b/packages/react-dom/src/events/EnterLeaveEventPlugin.js index 4b3e1e32e0d49..4f0b14f455218 100644 --- a/packages/react-dom/src/events/EnterLeaveEventPlugin.js +++ b/packages/react-dom/src/events/EnterLeaveEventPlugin.js @@ -63,7 +63,11 @@ const EnterLeaveEventPlugin = { const isOutEvent = topLevelType === TOP_MOUSE_OUT || topLevelType === TOP_POINTER_OUT; - if (isOverEvent && (eventSystemFlags & IS_REPLAYED) === 0 && (nativeEvent.relatedTarget || nativeEvent.fromElement)) { + if ( + isOverEvent && + (eventSystemFlags & IS_REPLAYED) === 0 && + (nativeEvent.relatedTarget || nativeEvent.fromElement) + ) { // If this is an over event with a target, then we've already dispatched // the event in the out event of the other target. If this is replayed, // then it's because we couldn't dispatch against this target previously diff --git a/packages/react-dom/src/events/ReactBrowserEventEmitter.js b/packages/react-dom/src/events/ReactBrowserEventEmitter.js index da9d477e4d7e0..b1c612f7e5e8e 100644 --- a/packages/react-dom/src/events/ReactBrowserEventEmitter.js +++ b/packages/react-dom/src/events/ReactBrowserEventEmitter.js @@ -134,43 +134,51 @@ export function listenTo( for (let i = 0; i < dependencies.length; i++) { const dependency = dependencies[i]; - if (!listeningSet.has(dependency)) { - switch (dependency) { - case TOP_SCROLL: - trapCapturedEvent(TOP_SCROLL, mountAt); - break; - case TOP_FOCUS: - case TOP_BLUR: - trapCapturedEvent(TOP_FOCUS, mountAt); - trapCapturedEvent(TOP_BLUR, mountAt); - // We set the flag for a single dependency later in this function, - // but this ensures we mark both as attached rather than just one. - listeningSet.add(TOP_BLUR); - listeningSet.add(TOP_FOCUS); - break; - case TOP_CANCEL: - case TOP_CLOSE: - if (isEventSupported(getRawEventName(dependency))) { - trapCapturedEvent(dependency, mountAt); - } - break; - case TOP_INVALID: - case TOP_SUBMIT: - case TOP_RESET: - // We listen to them on the target DOM elements. - // Some of them bubble so we don't want them to fire twice. - break; - default: - // By default, listen on the top level to all non-media events. - // Media events don't bubble so adding the listener wouldn't do anything. - const isMediaEvent = mediaEventTypes.indexOf(dependency) !== -1; - if (!isMediaEvent) { - trapBubbledEvent(dependency, mountAt); - } - break; - } - listeningSet.add(dependency); + listenToTopLevel(dependency, mountAt, listeningSet); + } +} + +export function listenToTopLevel( + topLevelType: DOMTopLevelEventType, + mountAt: Document | Element | Node, + listeningSet: Set, +): void { + if (!listeningSet.has(topLevelType)) { + switch (topLevelType) { + case TOP_SCROLL: + trapCapturedEvent(TOP_SCROLL, mountAt); + break; + case TOP_FOCUS: + case TOP_BLUR: + trapCapturedEvent(TOP_FOCUS, mountAt); + trapCapturedEvent(TOP_BLUR, mountAt); + // We set the flag for a single dependency later in this function, + // but this ensures we mark both as attached rather than just one. + listeningSet.add(TOP_BLUR); + listeningSet.add(TOP_FOCUS); + break; + case TOP_CANCEL: + case TOP_CLOSE: + if (isEventSupported(getRawEventName(topLevelType))) { + trapCapturedEvent(topLevelType, mountAt); + } + break; + case TOP_INVALID: + case TOP_SUBMIT: + case TOP_RESET: + // We listen to them on the target DOM elements. + // Some of them bubble so we don't want them to fire twice. + break; + default: + // By default, listen on the top level to all non-media events. + // Media events don't bubble so adding the listener wouldn't do anything. + const isMediaEvent = mediaEventTypes.indexOf(topLevelType) !== -1; + if (!isMediaEvent) { + trapBubbledEvent(topLevelType, mountAt); + } + break; } + listeningSet.add(topLevelType); } } diff --git a/packages/react-dom/src/events/ReactDOMEventReplaying.js b/packages/react-dom/src/events/ReactDOMEventReplaying.js index 8aad6e7fdb1af..22e1db2821cd5 100644 --- a/packages/react-dom/src/events/ReactDOMEventReplaying.js +++ b/packages/react-dom/src/events/ReactDOMEventReplaying.js @@ -12,11 +12,20 @@ import type {Container, SuspenseInstance} from '../client/ReactDOMHostConfig'; import type {DOMTopLevelEventType} from 'legacy-events/TopLevelEventTypes'; import type {EventSystemFlags} from 'legacy-events/EventSystemFlags'; +import {enableFlareAPI} from 'shared/ReactFeatureFlags'; import { unstable_scheduleCallback as scheduleCallback, unstable_NormalPriority as NormalPriority, } from 'scheduler'; -import {attemptToDispatchEvent} from './ReactDOMEventListener'; +import { + attemptToDispatchEvent, + trapEventForResponderEventSystem, +} from './ReactDOMEventListener'; +import { + getListeningSetForElement, + listenToTopLevel, +} from './ReactBrowserEventEmitter'; +import {unsafeCastDOMTopLevelTypeToString} from 'legacy-events/TopLevelEventTypes'; // TODO: Upgrade this definition once we're on a newer version of Flow that // has this definition built-in. @@ -140,6 +149,81 @@ export function isReplayableDiscreteEvent( return false; } +function trapReplayableEvent( + topLevelType: DOMTopLevelEventType, + document: Document, + listeningSet: Set, +) { + listenToTopLevel(topLevelType, document, listeningSet); + if (enableFlareAPI) { + // Trap events for the responder system. + const passiveEventKey = + unsafeCastDOMTopLevelTypeToString(topLevelType) + '_passive'; + if (!listeningSet.has(passiveEventKey)) { + trapEventForResponderEventSystem(document, topLevelType, true); + listeningSet.add(passiveEventKey); + } + // TODO: This listens to all events as active which might have + // undesirable effects. It's also unnecessary to have both + // passive and active listeners. Instead, we could start with + // a passive and upgrade it to an active one if needed. + // For replaying purposes the active is never needed since we + // currently don't preventDefault. + const activeEventKey = + unsafeCastDOMTopLevelTypeToString(topLevelType) + '_active'; + if (!listeningSet.has(activeEventKey)) { + trapEventForResponderEventSystem(document, topLevelType, false); + listeningSet.add(activeEventKey); + } + } +} + +export function eagerlyTrapReplayableEvents(document: Document) { + const listeningSet = getListeningSetForElement(document); + // Discrete + trapReplayableEvent(TOP_MOUSE_DOWN, document, listeningSet); + trapReplayableEvent(TOP_MOUSE_UP, document, listeningSet); + trapReplayableEvent(TOP_TOUCH_CANCEL, document, listeningSet); + trapReplayableEvent(TOP_TOUCH_END, document, listeningSet); + trapReplayableEvent(TOP_TOUCH_START, document, listeningSet); + trapReplayableEvent(TOP_AUX_CLICK, document, listeningSet); + trapReplayableEvent(TOP_DOUBLE_CLICK, document, listeningSet); + trapReplayableEvent(TOP_POINTER_CANCEL, document, listeningSet); + trapReplayableEvent(TOP_POINTER_DOWN, document, listeningSet); + trapReplayableEvent(TOP_POINTER_UP, document, listeningSet); + trapReplayableEvent(TOP_DRAG_END, document, listeningSet); + trapReplayableEvent(TOP_DRAG_START, document, listeningSet); + trapReplayableEvent(TOP_DROP, document, listeningSet); + trapReplayableEvent(TOP_COMPOSITION_END, document, listeningSet); + trapReplayableEvent(TOP_COMPOSITION_START, document, listeningSet); + trapReplayableEvent(TOP_KEY_DOWN, document, listeningSet); + trapReplayableEvent(TOP_KEY_PRESS, document, listeningSet); + trapReplayableEvent(TOP_KEY_UP, document, listeningSet); + trapReplayableEvent(TOP_INPUT, document, listeningSet); + trapReplayableEvent(TOP_TEXT_INPUT, document, listeningSet); + trapReplayableEvent(TOP_CLOSE, document, listeningSet); + trapReplayableEvent(TOP_CANCEL, document, listeningSet); + trapReplayableEvent(TOP_COPY, document, listeningSet); + trapReplayableEvent(TOP_CUT, document, listeningSet); + trapReplayableEvent(TOP_PASTE, document, listeningSet); + trapReplayableEvent(TOP_CLICK, document, listeningSet); + trapReplayableEvent(TOP_CHANGE, document, listeningSet); + trapReplayableEvent(TOP_CONTEXT_MENU, document, listeningSet); + trapReplayableEvent(TOP_RESET, document, listeningSet); + trapReplayableEvent(TOP_SUBMIT, document, listeningSet); + // Continuous + trapReplayableEvent(TOP_FOCUS, document, listeningSet); + trapReplayableEvent(TOP_BLUR, document, listeningSet); + trapReplayableEvent(TOP_DRAG_ENTER, document, listeningSet); + trapReplayableEvent(TOP_DRAG_LEAVE, document, listeningSet); + trapReplayableEvent(TOP_MOUSE_OVER, document, listeningSet); + trapReplayableEvent(TOP_MOUSE_OUT, document, listeningSet); + trapReplayableEvent(TOP_POINTER_OVER, document, listeningSet); + trapReplayableEvent(TOP_POINTER_OUT, document, listeningSet); + trapReplayableEvent(TOP_GOT_POINTER_CAPTURE, document, listeningSet); + trapReplayableEvent(TOP_LOST_POINTER_CAPTURE, document, listeningSet); +} + function createQueuedReplayableEvent( blockedOn: null | Container | SuspenseInstance, topLevelType: DOMTopLevelEventType, From 640a27a6770a41332c975cc73835d3ddc1cce003 Mon Sep 17 00:00:00 2001 From: Sebastian Markbage Date: Fri, 13 Sep 2019 17:42:28 -0700 Subject: [PATCH 09/12] Desperate attempt to save bytese --- .../src/events/ReactDOMEventReplaying.js | 127 ++++++++---------- 1 file changed, 53 insertions(+), 74 deletions(-) diff --git a/packages/react-dom/src/events/ReactDOMEventReplaying.js b/packages/react-dom/src/events/ReactDOMEventReplaying.js index 22e1db2821cd5..682b9ac19b1a6 100644 --- a/packages/react-dom/src/events/ReactDOMEventReplaying.js +++ b/packages/react-dom/src/events/ReactDOMEventReplaying.js @@ -110,43 +110,56 @@ export function hasQueuedContinuousEvents(): boolean { return hasAnyQueuedContinuousEvents; } +const discreteReplayableEvents = [ + TOP_MOUSE_DOWN, + TOP_MOUSE_UP, + TOP_TOUCH_CANCEL, + TOP_TOUCH_END, + TOP_TOUCH_START, + TOP_AUX_CLICK, + TOP_DOUBLE_CLICK, + TOP_POINTER_CANCEL, + TOP_POINTER_DOWN, + TOP_POINTER_UP, + TOP_DRAG_END, + TOP_DRAG_START, + TOP_DROP, + TOP_COMPOSITION_END, + TOP_COMPOSITION_START, + TOP_KEY_DOWN, + TOP_KEY_PRESS, + TOP_KEY_UP, + TOP_INPUT, + TOP_TEXT_INPUT, + TOP_CLOSE, + TOP_CANCEL, + TOP_COPY, + TOP_CUT, + TOP_PASTE, + TOP_CLICK, + TOP_CHANGE, + TOP_CONTEXT_MENU, + TOP_RESET, + TOP_SUBMIT, +]; + +const continuousReplayableEvents = [ + TOP_FOCUS, + TOP_BLUR, + TOP_DRAG_ENTER, + TOP_DRAG_LEAVE, + TOP_MOUSE_OVER, + TOP_MOUSE_OUT, + TOP_POINTER_OVER, + TOP_POINTER_OUT, + TOP_GOT_POINTER_CAPTURE, + TOP_LOST_POINTER_CAPTURE, +]; + export function isReplayableDiscreteEvent( eventType: DOMTopLevelEventType, ): boolean { - switch (eventType) { - case TOP_MOUSE_DOWN: - case TOP_MOUSE_UP: - case TOP_TOUCH_CANCEL: - case TOP_TOUCH_END: - case TOP_TOUCH_START: - case TOP_AUX_CLICK: - case TOP_DOUBLE_CLICK: - case TOP_POINTER_CANCEL: - case TOP_POINTER_DOWN: - case TOP_POINTER_UP: - case TOP_DRAG_END: - case TOP_DRAG_START: - case TOP_DROP: - case TOP_COMPOSITION_END: - case TOP_COMPOSITION_START: - case TOP_KEY_DOWN: - case TOP_KEY_PRESS: - case TOP_KEY_UP: - case TOP_INPUT: - case TOP_TEXT_INPUT: - case TOP_CLOSE: - case TOP_CANCEL: - case TOP_COPY: - case TOP_CUT: - case TOP_PASTE: - case TOP_CLICK: - case TOP_CHANGE: - case TOP_CONTEXT_MENU: - case TOP_RESET: - case TOP_SUBMIT: - return true; - } - return false; + return discreteReplayableEvents.indexOf(eventType) > -1; } function trapReplayableEvent( @@ -181,47 +194,13 @@ function trapReplayableEvent( export function eagerlyTrapReplayableEvents(document: Document) { const listeningSet = getListeningSetForElement(document); // Discrete - trapReplayableEvent(TOP_MOUSE_DOWN, document, listeningSet); - trapReplayableEvent(TOP_MOUSE_UP, document, listeningSet); - trapReplayableEvent(TOP_TOUCH_CANCEL, document, listeningSet); - trapReplayableEvent(TOP_TOUCH_END, document, listeningSet); - trapReplayableEvent(TOP_TOUCH_START, document, listeningSet); - trapReplayableEvent(TOP_AUX_CLICK, document, listeningSet); - trapReplayableEvent(TOP_DOUBLE_CLICK, document, listeningSet); - trapReplayableEvent(TOP_POINTER_CANCEL, document, listeningSet); - trapReplayableEvent(TOP_POINTER_DOWN, document, listeningSet); - trapReplayableEvent(TOP_POINTER_UP, document, listeningSet); - trapReplayableEvent(TOP_DRAG_END, document, listeningSet); - trapReplayableEvent(TOP_DRAG_START, document, listeningSet); - trapReplayableEvent(TOP_DROP, document, listeningSet); - trapReplayableEvent(TOP_COMPOSITION_END, document, listeningSet); - trapReplayableEvent(TOP_COMPOSITION_START, document, listeningSet); - trapReplayableEvent(TOP_KEY_DOWN, document, listeningSet); - trapReplayableEvent(TOP_KEY_PRESS, document, listeningSet); - trapReplayableEvent(TOP_KEY_UP, document, listeningSet); - trapReplayableEvent(TOP_INPUT, document, listeningSet); - trapReplayableEvent(TOP_TEXT_INPUT, document, listeningSet); - trapReplayableEvent(TOP_CLOSE, document, listeningSet); - trapReplayableEvent(TOP_CANCEL, document, listeningSet); - trapReplayableEvent(TOP_COPY, document, listeningSet); - trapReplayableEvent(TOP_CUT, document, listeningSet); - trapReplayableEvent(TOP_PASTE, document, listeningSet); - trapReplayableEvent(TOP_CLICK, document, listeningSet); - trapReplayableEvent(TOP_CHANGE, document, listeningSet); - trapReplayableEvent(TOP_CONTEXT_MENU, document, listeningSet); - trapReplayableEvent(TOP_RESET, document, listeningSet); - trapReplayableEvent(TOP_SUBMIT, document, listeningSet); + discreteReplayableEvents.forEach(topLevelType => { + trapReplayableEvent(topLevelType, document, listeningSet); + }); // Continuous - trapReplayableEvent(TOP_FOCUS, document, listeningSet); - trapReplayableEvent(TOP_BLUR, document, listeningSet); - trapReplayableEvent(TOP_DRAG_ENTER, document, listeningSet); - trapReplayableEvent(TOP_DRAG_LEAVE, document, listeningSet); - trapReplayableEvent(TOP_MOUSE_OVER, document, listeningSet); - trapReplayableEvent(TOP_MOUSE_OUT, document, listeningSet); - trapReplayableEvent(TOP_POINTER_OVER, document, listeningSet); - trapReplayableEvent(TOP_POINTER_OUT, document, listeningSet); - trapReplayableEvent(TOP_GOT_POINTER_CAPTURE, document, listeningSet); - trapReplayableEvent(TOP_LOST_POINTER_CAPTURE, document, listeningSet); + continuousReplayableEvents.forEach(topLevelType => { + trapReplayableEvent(topLevelType, document, listeningSet); + }); } function createQueuedReplayableEvent( From 23593520cba4a0f01e190d77ed0ffd066e2ff531 Mon Sep 17 00:00:00 2001 From: Sebastian Markbage Date: Tue, 17 Sep 2019 11:50:01 -0700 Subject: [PATCH 10/12] Add test for mouseover replaying We need to check if the "relatedTarget" is mounted due to how the old event system dispatches from the "out" event. --- ...DOMServerPartialHydration-test.internal.js | 273 ++++++++++++++++++ .../src/events/EnterLeaveEventPlugin.js | 11 +- 2 files changed, 282 insertions(+), 2 deletions(-) diff --git a/packages/react-dom/src/__tests__/ReactDOMServerPartialHydration-test.internal.js b/packages/react-dom/src/__tests__/ReactDOMServerPartialHydration-test.internal.js index d9c7aa787b49b..f4512162ddec8 100644 --- a/packages/react-dom/src/__tests__/ReactDOMServerPartialHydration-test.internal.js +++ b/packages/react-dom/src/__tests__/ReactDOMServerPartialHydration-test.internal.js @@ -17,6 +17,58 @@ let ReactFeatureFlags; let Suspense; let SuspenseList; let act; +let useHover; + +function dispatchMouseEvent(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); + } +} describe('ReactDOMServerPartialHydration', () => { beforeEach(() => { @@ -34,6 +86,8 @@ describe('ReactDOMServerPartialHydration', () => { Scheduler = require('scheduler'); Suspense = React.Suspense; SuspenseList = React.unstable_SuspenseList; + + useHover = require('react-interactions/events/hover').useHover; }); it('hydrates a parent even if a child Suspense boundary is blocked', async () => { @@ -2040,4 +2094,223 @@ describe('ReactDOMServerPartialHydration', () => { document.body.removeChild(parentContainer); }); + + it('blocks only on the last continuous event (legacy system)', async () => { + let suspend1 = false; + let resolve1; + let promise1 = new Promise(resolvePromise => (resolve1 = resolvePromise)); + let suspend2 = false; + let resolve2; + let promise2 = new Promise(resolvePromise => (resolve2 = resolvePromise)); + + function First({text}) { + if (suspend1) { + throw promise1; + } else { + return 'Hello'; + } + } + + function Second({text}) { + if (suspend2) { + throw promise2; + } else { + return 'World'; + } + } + + let ops = []; + + function App() { + return ( +
+ + ops.push('Mouse Enter First')} + onMouseLeave={() => ops.push('Mouse Leave First')} + /> + {/* We suspend after to test what happens when we eager + attach the listener. */} + + + + ops.push('Mouse Enter Second')} + onMouseLeave={() => ops.push('Mouse Leave Second')}> + + + +
+ ); + } + + let finalHTML = ReactDOMServer.renderToString(); + let 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); + + let appDiv = container.getElementsByTagName('div')[0]; + let firstSpan = appDiv.getElementsByTagName('span')[0]; + let secondSpan = appDiv.getElementsByTagName('span')[1]; + expect(firstSpan.textContent).toBe(''); + expect(secondSpan.textContent).toBe('World'); + + // On the client we don't have all data yet but we want to start + // hydrating anyway. + suspend1 = true; + suspend2 = true; + let root = ReactDOM.unstable_createRoot(container, {hydrate: true}); + root.render(); + + Scheduler.unstable_flushAll(); + jest.runAllTimers(); + + dispatchMouseEvent(appDiv, null); + dispatchMouseEvent(firstSpan, appDiv); + dispatchMouseEvent(secondSpan, firstSpan); + + // Neither target is yet hydrated. + expect(ops).toEqual([]); + + // Resolving the second promise so that rendering can complete. + suspend2 = false; + resolve2(); + await promise2; + + Scheduler.unstable_flushAll(); + jest.runAllTimers(); + + // We've unblocked the current hover target so we should be + // able to replay it now. + expect(ops).toEqual(['Mouse Enter Second']); + + // Resolving the first promise has no effect now. + suspend1 = false; + resolve1(); + await promise1; + + Scheduler.unstable_flushAll(); + jest.runAllTimers(); + + expect(ops).toEqual(['Mouse Enter Second']); + + document.body.removeChild(container); + }); + + it('blocks only on the last continuous event (Responder system)', async () => { + let suspend1 = false; + let resolve1; + let promise1 = new Promise(resolvePromise => (resolve1 = resolvePromise)); + let suspend2 = false; + let resolve2; + let promise2 = new Promise(resolvePromise => (resolve2 = resolvePromise)); + + function First({text}) { + if (suspend1) { + throw promise1; + } else { + return 'Hello'; + } + } + + function Second({text}) { + if (suspend2) { + throw promise2; + } else { + return 'World'; + } + } + + let ops = []; + + function App() { + const listener1 = useHover({ + onHoverStart() { + ops.push('Hover Start First'); + }, + onHoverEnd() { + ops.push('Hover End First'); + }, + }); + const listener2 = useHover({ + onHoverStart() { + ops.push('Hover Start Second'); + }, + onHoverEnd() { + ops.push('Hover End Second'); + }, + }); + return ( +
+ + + {/* We suspend after to test what happens when we eager + attach the listener. */} + + + + + + + +
+ ); + } + + let finalHTML = ReactDOMServer.renderToString(); + let 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); + + let appDiv = container.getElementsByTagName('div')[0]; + let firstSpan = appDiv.getElementsByTagName('span')[0]; + let secondSpan = appDiv.getElementsByTagName('span')[1]; + expect(firstSpan.textContent).toBe(''); + expect(secondSpan.textContent).toBe('World'); + + // On the client we don't have all data yet but we want to start + // hydrating anyway. + suspend1 = true; + suspend2 = true; + let root = ReactDOM.unstable_createRoot(container, {hydrate: true}); + root.render(); + + Scheduler.unstable_flushAll(); + jest.runAllTimers(); + + dispatchMouseEvent(appDiv, null); + dispatchMouseEvent(firstSpan, appDiv); + dispatchMouseEvent(secondSpan, firstSpan); + + // Neither target is yet hydrated. + expect(ops).toEqual([]); + + // Resolving the second promise so that rendering can complete. + suspend2 = false; + resolve2(); + await promise2; + + Scheduler.unstable_flushAll(); + jest.runAllTimers(); + + // We've unblocked the current hover target so we should be + // able to replay it now. + expect(ops).toEqual(['Hover Start Second']); + + // Resolving the first promise has no effect now. + suspend1 = false; + resolve1(); + await promise1; + + Scheduler.unstable_flushAll(); + jest.runAllTimers(); + + expect(ops).toEqual(['Hover Start Second']); + + document.body.removeChild(container); + }); }); diff --git a/packages/react-dom/src/events/EnterLeaveEventPlugin.js b/packages/react-dom/src/events/EnterLeaveEventPlugin.js index 4f0b14f455218..57ba9e7659153 100644 --- a/packages/react-dom/src/events/EnterLeaveEventPlugin.js +++ b/packages/react-dom/src/events/EnterLeaveEventPlugin.js @@ -21,6 +21,7 @@ import { getNodeFromInstance, } from '../client/ReactDOMComponentTree'; import {HostComponent, HostText} from 'shared/ReactWorkTags'; +import {getNearestMountedFiber} from 'react-reconciler/reflection'; const eventTypes = { mouseEnter: { @@ -100,8 +101,14 @@ const EnterLeaveEventPlugin = { from = targetInst; const related = nativeEvent.relatedTarget || nativeEvent.toElement; to = related ? getClosestInstanceFromNode(related) : null; - if (to !== null && to.tag !== HostComponent && to.tag !== HostText) { - to = null; + if (to !== null) { + const nearestMounted = getNearestMountedFiber(to); + if ( + to !== nearestMounted || + (to.tag !== HostComponent && to.tag !== HostText) + ) { + to = null; + } } } else { // Moving to a node from outside the window. From a651d778a5804914a3050c59e3ecad718e2a95a4 Mon Sep 17 00:00:00 2001 From: Sebastian Markbage Date: Sun, 22 Sep 2019 18:32:09 -0700 Subject: [PATCH 11/12] Fix for nested boundaries and suspense in root container This is a follow up to #16673 which didn't have a test because it wasn't observable yet. This shows that it had a bug. --- ...DOMServerPartialHydration-test.internal.js | 158 ++++++++++++++++++ .../src/client/ReactDOMComponentTree.js | 28 +++- 2 files changed, 177 insertions(+), 9 deletions(-) diff --git a/packages/react-dom/src/__tests__/ReactDOMServerPartialHydration-test.internal.js b/packages/react-dom/src/__tests__/ReactDOMServerPartialHydration-test.internal.js index f4512162ddec8..dffe284c69b25 100644 --- a/packages/react-dom/src/__tests__/ReactDOMServerPartialHydration-test.internal.js +++ b/packages/react-dom/src/__tests__/ReactDOMServerPartialHydration-test.internal.js @@ -1944,6 +1944,164 @@ describe('ReactDOMServerPartialHydration', () => { document.body.removeChild(container); }); + it('invokes discrete events on nested suspense boundaries in a root (legacy system)', async () => { + let suspend = false; + let resolve; + let promise = new Promise(resolvePromise => (resolve = resolvePromise)); + + let clicks = 0; + + function Button() { + return ( + { + clicks++; + }}> + Click me + + ); + } + + function Child() { + if (suspend) { + throw promise; + } else { + return ( + +