From bb680a09052b5af6543daf28bfcc3283bbc35cf9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Markb=C3=A5ge?= Date: Wed, 2 Oct 2019 14:52:23 -0700 Subject: [PATCH] [Selective Hydration] Prioritize the last continuous target (#16937) * Prioritize the last continuous target This ensures that the current focus target is always hydrated first. Slightly higher than the usual Never expiration time used for hydration. The priority increases with each new queued item so that the last always wins. * Don't export the moving target It's not useful for comparison purposes anyway. --- ...MServerSelectiveHydration-test.internal.js | 150 ++++++++++++++++++ packages/react-dom/src/client/ReactDOM.js | 3 + .../src/events/ReactDOMEventReplaying.js | 50 ++++-- .../src/ReactFiberExpirationTime.js | 13 ++ .../src/ReactFiberReconciler.js | 19 ++- 5 files changed, 219 insertions(+), 16 deletions(-) diff --git a/packages/react-dom/src/__tests__/ReactDOMServerSelectiveHydration-test.internal.js b/packages/react-dom/src/__tests__/ReactDOMServerSelectiveHydration-test.internal.js index 72942d4f2dd6..0283b06ef2cd 100644 --- a/packages/react-dom/src/__tests__/ReactDOMServerSelectiveHydration-test.internal.js +++ b/packages/react-dom/src/__tests__/ReactDOMServerSelectiveHydration-test.internal.js @@ -16,6 +16,57 @@ let Scheduler; let ReactFeatureFlags; let Suspense; +function dispatchMouseHoverEvent(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); + } +} + function dispatchClickEvent(target) { const mouseOutEvent = document.createEvent('MouseEvents'); mouseOutEvent.initMouseEvent( @@ -290,4 +341,103 @@ describe('ReactDOMServerSelectiveHydration', () => { document.body.removeChild(container); }); + + it('hydrates the last target as higher priority for continuous events', async () => { + let suspend = false; + let resolve; + let promise = new Promise(resolvePromise => (resolve = resolvePromise)); + + function Child({text}) { + if ((text === 'A' || text === 'D') && suspend) { + throw promise; + } + Scheduler.unstable_yieldValue(text); + return ( + { + e.preventDefault(); + Scheduler.unstable_yieldValue('Clicked ' + text); + }} + onMouseEnter={e => { + e.preventDefault(); + Scheduler.unstable_yieldValue('Hover ' + text); + }}> + {text} + + ); + } + + function App() { + Scheduler.unstable_yieldValue('App'); + return ( +
+ + + + + + + + + + + + +
+ ); + } + + let finalHTML = ReactDOMServer.renderToString(); + + expect(Scheduler).toHaveYielded(['App', 'A', 'B', 'C', 'D']); + + let container = document.createElement('div'); + // We need this to be in the document since we'll dispatch events on it. + document.body.appendChild(container); + + container.innerHTML = finalHTML; + + let spanB = container.getElementsByTagName('span')[1]; + let spanC = container.getElementsByTagName('span')[2]; + let spanD = container.getElementsByTagName('span')[3]; + + suspend = true; + + // A and D will be suspended. We'll click on D which should take + // priority, after we unsuspend. + let root = ReactDOM.unstable_createRoot(container, {hydrate: true}); + root.render(); + + // Nothing has been hydrated so far. + expect(Scheduler).toHaveYielded([]); + + // Click D + dispatchMouseHoverEvent(spanD, null); + dispatchClickEvent(spanD); + // Hover over B and then C. + dispatchMouseHoverEvent(spanB, spanD); + dispatchMouseHoverEvent(spanC, spanB); + + expect(Scheduler).toHaveYielded(['App']); + + suspend = false; + resolve(); + await promise; + + // We should prioritize hydrating D first because we clicked it. + // Next we should hydrate C since that's the current hover target. + // Next it doesn't matter if we hydrate A or B first but as an + // implementation detail we're currently hydrating B first since + // we at one point hovered over it and we never deprioritized it. + expect(Scheduler).toFlushAndYield([ + 'D', + 'Clicked D', + 'C', + 'Hover C', + 'B', + 'A', + ]); + + document.body.removeChild(container); + }); }); diff --git a/packages/react-dom/src/client/ReactDOM.js b/packages/react-dom/src/client/ReactDOM.js index 27b0eb88f1f9..11c3f37e306d 100644 --- a/packages/react-dom/src/client/ReactDOM.js +++ b/packages/react-dom/src/client/ReactDOM.js @@ -41,6 +41,7 @@ import { IsThisRendererActing, attemptSynchronousHydration, attemptUserBlockingHydration, + attemptContinuousHydration, } from 'react-reconciler/inline.dom'; import {createPortal as createPortalImpl} from 'shared/ReactPortal'; import {canUseDOM} from 'shared/ExecutionEnvironment'; @@ -79,6 +80,7 @@ import {dispatchEvent} from '../events/ReactDOMEventListener'; import { setAttemptSynchronousHydration, setAttemptUserBlockingHydration, + setAttemptContinuousHydration, } from '../events/ReactDOMEventReplaying'; import {eagerlyTrapReplayableEvents} from '../events/ReactDOMEventReplaying'; import { @@ -91,6 +93,7 @@ import {ROOT_ATTRIBUTE_NAME} from '../shared/DOMProperty'; setAttemptSynchronousHydration(attemptSynchronousHydration); setAttemptUserBlockingHydration(attemptUserBlockingHydration); +setAttemptContinuousHydration(attemptContinuousHydration); const ReactCurrentOwner = ReactSharedInternals.ReactCurrentOwner; diff --git a/packages/react-dom/src/events/ReactDOMEventReplaying.js b/packages/react-dom/src/events/ReactDOMEventReplaying.js index 866b8a492e33..53d1a347d635 100644 --- a/packages/react-dom/src/events/ReactDOMEventReplaying.js +++ b/packages/react-dom/src/events/ReactDOMEventReplaying.js @@ -43,6 +43,12 @@ export function setAttemptUserBlockingHydration(fn: (fiber: Object) => void) { attemptUserBlockingHydration = fn; } +let attemptContinuousHydration: (fiber: Object) => void; + +export function setAttemptContinuousHydration(fn: (fiber: Object) => void) { + attemptContinuousHydration = fn; +} + // TODO: Upgrade this definition once we're on a newer version of Flow that // has this definition built-in. type PointerEvent = Event & { @@ -305,7 +311,7 @@ export function clearIfContinuousEvent( } } -function accumulateOrCreateQueuedReplayableEvent( +function accumulateOrCreateContinuousQueuedReplayableEvent( existingQueuedEvent: null | QueuedReplayableEvent, blockedOn: null | Container | SuspenseInstance, topLevelType: DOMTopLevelEventType, @@ -316,12 +322,20 @@ function accumulateOrCreateQueuedReplayableEvent( existingQueuedEvent === null || existingQueuedEvent.nativeEvent !== nativeEvent ) { - return createQueuedReplayableEvent( + let queuedEvent = createQueuedReplayableEvent( blockedOn, topLevelType, eventSystemFlags, nativeEvent, ); + if (blockedOn !== null) { + let fiber = getInstanceFromNode(blockedOn); + if (fiber !== null) { + // Attempt to increase the priority of this target. + attemptContinuousHydration(fiber); + } + } + return queuedEvent; } // If we have already queued this exact event, then it's because // the different event systems have different DOM event listeners. @@ -343,7 +357,7 @@ export function queueIfContinuousEvent( switch (topLevelType) { case TOP_FOCUS: { const focusEvent = ((nativeEvent: any): FocusEvent); - queuedFocus = accumulateOrCreateQueuedReplayableEvent( + queuedFocus = accumulateOrCreateContinuousQueuedReplayableEvent( queuedFocus, blockedOn, topLevelType, @@ -354,7 +368,7 @@ export function queueIfContinuousEvent( } case TOP_DRAG_ENTER: { const dragEvent = ((nativeEvent: any): DragEvent); - queuedDrag = accumulateOrCreateQueuedReplayableEvent( + queuedDrag = accumulateOrCreateContinuousQueuedReplayableEvent( queuedDrag, blockedOn, topLevelType, @@ -365,7 +379,7 @@ export function queueIfContinuousEvent( } case TOP_MOUSE_OVER: { const mouseEvent = ((nativeEvent: any): MouseEvent); - queuedMouse = accumulateOrCreateQueuedReplayableEvent( + queuedMouse = accumulateOrCreateContinuousQueuedReplayableEvent( queuedMouse, blockedOn, topLevelType, @@ -379,7 +393,7 @@ export function queueIfContinuousEvent( const pointerId = pointerEvent.pointerId; queuedPointers.set( pointerId, - accumulateOrCreateQueuedReplayableEvent( + accumulateOrCreateContinuousQueuedReplayableEvent( queuedPointers.get(pointerId) || null, blockedOn, topLevelType, @@ -394,7 +408,7 @@ export function queueIfContinuousEvent( const pointerId = pointerEvent.pointerId; queuedPointerCaptures.set( pointerId, - accumulateOrCreateQueuedReplayableEvent( + accumulateOrCreateContinuousQueuedReplayableEvent( queuedPointerCaptures.get(pointerId) || null, blockedOn, topLevelType, @@ -408,7 +422,9 @@ export function queueIfContinuousEvent( return false; } -function attemptReplayQueuedEvent(queuedEvent: QueuedReplayableEvent): boolean { +function attemptReplayContinuousQueuedEvent( + queuedEvent: QueuedReplayableEvent, +): boolean { if (queuedEvent.blockedOn !== null) { return false; } @@ -419,18 +435,22 @@ function attemptReplayQueuedEvent(queuedEvent: QueuedReplayableEvent): boolean { ); if (nextBlockedOn !== null) { // We're still blocked. Try again later. + let fiber = getInstanceFromNode(nextBlockedOn); + if (fiber !== null) { + attemptContinuousHydration(fiber); + } queuedEvent.blockedOn = nextBlockedOn; return false; } return true; } -function attemptReplayQueuedEventInMap( +function attemptReplayContinuousQueuedEventInMap( queuedEvent: QueuedReplayableEvent, key: number, map: Map, ): void { - if (attemptReplayQueuedEvent(queuedEvent)) { + if (attemptReplayContinuousQueuedEvent(queuedEvent)) { map.delete(key); } } @@ -464,17 +484,17 @@ function replayUnblockedEvents() { } } // Next replay any continuous events. - if (queuedFocus !== null && attemptReplayQueuedEvent(queuedFocus)) { + if (queuedFocus !== null && attemptReplayContinuousQueuedEvent(queuedFocus)) { queuedFocus = null; } - if (queuedDrag !== null && attemptReplayQueuedEvent(queuedDrag)) { + if (queuedDrag !== null && attemptReplayContinuousQueuedEvent(queuedDrag)) { queuedDrag = null; } - if (queuedMouse !== null && attemptReplayQueuedEvent(queuedMouse)) { + if (queuedMouse !== null && attemptReplayContinuousQueuedEvent(queuedMouse)) { queuedMouse = null; } - queuedPointers.forEach(attemptReplayQueuedEventInMap); - queuedPointerCaptures.forEach(attemptReplayQueuedEventInMap); + queuedPointers.forEach(attemptReplayContinuousQueuedEventInMap); + queuedPointerCaptures.forEach(attemptReplayContinuousQueuedEventInMap); } function scheduleCallbackIfUnblocked( diff --git a/packages/react-reconciler/src/ReactFiberExpirationTime.js b/packages/react-reconciler/src/ReactFiberExpirationTime.js index a5a9fd12f903..5f4660dd5175 100644 --- a/packages/react-reconciler/src/ReactFiberExpirationTime.js +++ b/packages/react-reconciler/src/ReactFiberExpirationTime.js @@ -32,6 +32,10 @@ export const Never = 1; // Idle is slightly higher priority than Never. It must completely finish in // order to be consistent. export const Idle = 2; +// Continuous Hydration is a moving priority. It is slightly higher than Idle +// and is used to increase priority of hover targets. It is increasing with +// each usage so that last always wins. +let ContinuousHydration = 3; export const Sync = MAX_SIGNED_31_BIT_INT; export const Batched = Sync - 1; @@ -115,6 +119,15 @@ export function computeInteractiveExpiration(currentTime: ExpirationTime) { ); } +export function computeContinuousHydrationExpiration( + currentTime: ExpirationTime, +) { + // Each time we ask for a new one of these we increase the priority. + // This ensures that the last one always wins since we can't deprioritize + // once we've scheduled work already. + return ContinuousHydration++; +} + export function inferPriorityFromExpirationTime( currentTime: ExpirationTime, expirationTime: ExpirationTime, diff --git a/packages/react-reconciler/src/ReactFiberReconciler.js b/packages/react-reconciler/src/ReactFiberReconciler.js index e008d202ab07..00d2b14ae638 100644 --- a/packages/react-reconciler/src/ReactFiberReconciler.js +++ b/packages/react-reconciler/src/ReactFiberReconciler.js @@ -78,7 +78,11 @@ import { current as ReactCurrentFiberCurrent, } from './ReactCurrentFiber'; import {StrictMode} from './ReactTypeOfMode'; -import {Sync, computeInteractiveExpiration} from './ReactFiberExpirationTime'; +import { + Sync, + computeInteractiveExpiration, + computeContinuousHydrationExpiration, +} from './ReactFiberExpirationTime'; import {requestCurrentSuspenseConfig} from './ReactFiberSuspenseConfig'; import { scheduleRefresh, @@ -421,6 +425,19 @@ export function attemptUserBlockingHydration(fiber: Fiber): void { markRetryTimeIfNotHydrated(fiber, expTime); } +export function attemptContinuousHydration(fiber: Fiber): void { + if (fiber.tag !== SuspenseComponent) { + // We ignore HostRoots here because we can't increase + // their priority and they should not suspend on I/O, + // since you have to wrap anything that might suspend in + // Suspense. + return; + } + let expTime = computeContinuousHydrationExpiration(requestCurrentTime()); + scheduleWork(fiber, expTime); + markRetryTimeIfNotHydrated(fiber, expTime); +} + export {findHostInstance}; export {findHostInstanceWithWarning};