Skip to content

Commit

Permalink
[Selective Hydration] Prioritize the last continuous target (#16937)
Browse files Browse the repository at this point in the history
* 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.
  • Loading branch information
sebmarkbage authored Oct 2, 2019
1 parent 10277cc commit bb680a0
Show file tree
Hide file tree
Showing 5 changed files with 219 additions and 16 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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 (
<span
onClick={e => {
e.preventDefault();
Scheduler.unstable_yieldValue('Clicked ' + text);
}}
onMouseEnter={e => {
e.preventDefault();
Scheduler.unstable_yieldValue('Hover ' + text);
}}>
{text}
</span>
);
}

function App() {
Scheduler.unstable_yieldValue('App');
return (
<div>
<Suspense fallback="Loading...">
<Child text="A" />
</Suspense>
<Suspense fallback="Loading...">
<Child text="B" />
</Suspense>
<Suspense fallback="Loading...">
<Child text="C" />
</Suspense>
<Suspense fallback="Loading...">
<Child text="D" />
</Suspense>
</div>
);
}

let finalHTML = ReactDOMServer.renderToString(<App />);

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(<App />);

// 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);
});
});
3 changes: 3 additions & 0 deletions packages/react-dom/src/client/ReactDOM.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -79,6 +80,7 @@ import {dispatchEvent} from '../events/ReactDOMEventListener';
import {
setAttemptSynchronousHydration,
setAttemptUserBlockingHydration,
setAttemptContinuousHydration,
} from '../events/ReactDOMEventReplaying';
import {eagerlyTrapReplayableEvents} from '../events/ReactDOMEventReplaying';
import {
Expand All @@ -91,6 +93,7 @@ import {ROOT_ATTRIBUTE_NAME} from '../shared/DOMProperty';

setAttemptSynchronousHydration(attemptSynchronousHydration);
setAttemptUserBlockingHydration(attemptUserBlockingHydration);
setAttemptContinuousHydration(attemptContinuousHydration);

const ReactCurrentOwner = ReactSharedInternals.ReactCurrentOwner;

Expand Down
50 changes: 35 additions & 15 deletions packages/react-dom/src/events/ReactDOMEventReplaying.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 & {
Expand Down Expand Up @@ -305,7 +311,7 @@ export function clearIfContinuousEvent(
}
}

function accumulateOrCreateQueuedReplayableEvent(
function accumulateOrCreateContinuousQueuedReplayableEvent(
existingQueuedEvent: null | QueuedReplayableEvent,
blockedOn: null | Container | SuspenseInstance,
topLevelType: DOMTopLevelEventType,
Expand All @@ -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.
Expand All @@ -343,7 +357,7 @@ export function queueIfContinuousEvent(
switch (topLevelType) {
case TOP_FOCUS: {
const focusEvent = ((nativeEvent: any): FocusEvent);
queuedFocus = accumulateOrCreateQueuedReplayableEvent(
queuedFocus = accumulateOrCreateContinuousQueuedReplayableEvent(
queuedFocus,
blockedOn,
topLevelType,
Expand All @@ -354,7 +368,7 @@ export function queueIfContinuousEvent(
}
case TOP_DRAG_ENTER: {
const dragEvent = ((nativeEvent: any): DragEvent);
queuedDrag = accumulateOrCreateQueuedReplayableEvent(
queuedDrag = accumulateOrCreateContinuousQueuedReplayableEvent(
queuedDrag,
blockedOn,
topLevelType,
Expand All @@ -365,7 +379,7 @@ export function queueIfContinuousEvent(
}
case TOP_MOUSE_OVER: {
const mouseEvent = ((nativeEvent: any): MouseEvent);
queuedMouse = accumulateOrCreateQueuedReplayableEvent(
queuedMouse = accumulateOrCreateContinuousQueuedReplayableEvent(
queuedMouse,
blockedOn,
topLevelType,
Expand All @@ -379,7 +393,7 @@ export function queueIfContinuousEvent(
const pointerId = pointerEvent.pointerId;
queuedPointers.set(
pointerId,
accumulateOrCreateQueuedReplayableEvent(
accumulateOrCreateContinuousQueuedReplayableEvent(
queuedPointers.get(pointerId) || null,
blockedOn,
topLevelType,
Expand All @@ -394,7 +408,7 @@ export function queueIfContinuousEvent(
const pointerId = pointerEvent.pointerId;
queuedPointerCaptures.set(
pointerId,
accumulateOrCreateQueuedReplayableEvent(
accumulateOrCreateContinuousQueuedReplayableEvent(
queuedPointerCaptures.get(pointerId) || null,
blockedOn,
topLevelType,
Expand All @@ -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;
}
Expand All @@ -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<number, QueuedReplayableEvent>,
): void {
if (attemptReplayQueuedEvent(queuedEvent)) {
if (attemptReplayContinuousQueuedEvent(queuedEvent)) {
map.delete(key);
}
}
Expand Down Expand Up @@ -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(
Expand Down
13 changes: 13 additions & 0 deletions packages/react-reconciler/src/ReactFiberExpirationTime.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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,
Expand Down
19 changes: 18 additions & 1 deletion packages/react-reconciler/src/ReactFiberReconciler.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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};
Expand Down

0 comments on commit bb680a0

Please sign in to comment.