diff --git a/packages/react-dom/src/__tests__/ReactDOMServerSelectiveHydration-test.internal.js b/packages/react-dom/src/__tests__/ReactDOMServerSelectiveHydration-test.internal.js
index 72942d4f2dd6e..0283b06ef2cd5 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 27b0eb88f1f9d..11c3f37e306d2 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 866b8a492e33e..53d1a347d6357 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 a5a9fd12f903f..5f4660dd5175f 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 e008d202ab07f..00d2b14ae6389 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};