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..6ec4bc7cf841 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. +export 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};