[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.
This commit is contained in:
Sebastian Markbåge 2019-10-02 14:52:23 -07:00 committed by GitHub
parent 10277cc5ba
commit bb680a0905
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 219 additions and 16 deletions

View File

@ -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 (
<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);
});
});

View File

@ -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;

View File

@ -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<number, QueuedReplayableEvent>,
): 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(

View File

@ -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,

View File

@ -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};