Skip to content

Commit bb680a0

Browse files
authored
[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.
1 parent 10277cc commit bb680a0

File tree

5 files changed

+219
-16
lines changed

5 files changed

+219
-16
lines changed

packages/react-dom/src/__tests__/ReactDOMServerSelectiveHydration-test.internal.js

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,57 @@ let Scheduler;
1616
let ReactFeatureFlags;
1717
let Suspense;
1818

19+
function dispatchMouseHoverEvent(to, from) {
20+
if (!to) {
21+
to = null;
22+
}
23+
if (!from) {
24+
from = null;
25+
}
26+
if (from) {
27+
const mouseOutEvent = document.createEvent('MouseEvents');
28+
mouseOutEvent.initMouseEvent(
29+
'mouseout',
30+
true,
31+
true,
32+
window,
33+
0,
34+
50,
35+
50,
36+
50,
37+
50,
38+
false,
39+
false,
40+
false,
41+
false,
42+
0,
43+
to,
44+
);
45+
from.dispatchEvent(mouseOutEvent);
46+
}
47+
if (to) {
48+
const mouseOverEvent = document.createEvent('MouseEvents');
49+
mouseOverEvent.initMouseEvent(
50+
'mouseover',
51+
true,
52+
true,
53+
window,
54+
0,
55+
50,
56+
50,
57+
50,
58+
50,
59+
false,
60+
false,
61+
false,
62+
false,
63+
0,
64+
from,
65+
);
66+
to.dispatchEvent(mouseOverEvent);
67+
}
68+
}
69+
1970
function dispatchClickEvent(target) {
2071
const mouseOutEvent = document.createEvent('MouseEvents');
2172
mouseOutEvent.initMouseEvent(
@@ -290,4 +341,103 @@ describe('ReactDOMServerSelectiveHydration', () => {
290341

291342
document.body.removeChild(container);
292343
});
344+
345+
it('hydrates the last target as higher priority for continuous events', async () => {
346+
let suspend = false;
347+
let resolve;
348+
let promise = new Promise(resolvePromise => (resolve = resolvePromise));
349+
350+
function Child({text}) {
351+
if ((text === 'A' || text === 'D') && suspend) {
352+
throw promise;
353+
}
354+
Scheduler.unstable_yieldValue(text);
355+
return (
356+
<span
357+
onClick={e => {
358+
e.preventDefault();
359+
Scheduler.unstable_yieldValue('Clicked ' + text);
360+
}}
361+
onMouseEnter={e => {
362+
e.preventDefault();
363+
Scheduler.unstable_yieldValue('Hover ' + text);
364+
}}>
365+
{text}
366+
</span>
367+
);
368+
}
369+
370+
function App() {
371+
Scheduler.unstable_yieldValue('App');
372+
return (
373+
<div>
374+
<Suspense fallback="Loading...">
375+
<Child text="A" />
376+
</Suspense>
377+
<Suspense fallback="Loading...">
378+
<Child text="B" />
379+
</Suspense>
380+
<Suspense fallback="Loading...">
381+
<Child text="C" />
382+
</Suspense>
383+
<Suspense fallback="Loading...">
384+
<Child text="D" />
385+
</Suspense>
386+
</div>
387+
);
388+
}
389+
390+
let finalHTML = ReactDOMServer.renderToString(<App />);
391+
392+
expect(Scheduler).toHaveYielded(['App', 'A', 'B', 'C', 'D']);
393+
394+
let container = document.createElement('div');
395+
// We need this to be in the document since we'll dispatch events on it.
396+
document.body.appendChild(container);
397+
398+
container.innerHTML = finalHTML;
399+
400+
let spanB = container.getElementsByTagName('span')[1];
401+
let spanC = container.getElementsByTagName('span')[2];
402+
let spanD = container.getElementsByTagName('span')[3];
403+
404+
suspend = true;
405+
406+
// A and D will be suspended. We'll click on D which should take
407+
// priority, after we unsuspend.
408+
let root = ReactDOM.unstable_createRoot(container, {hydrate: true});
409+
root.render(<App />);
410+
411+
// Nothing has been hydrated so far.
412+
expect(Scheduler).toHaveYielded([]);
413+
414+
// Click D
415+
dispatchMouseHoverEvent(spanD, null);
416+
dispatchClickEvent(spanD);
417+
// Hover over B and then C.
418+
dispatchMouseHoverEvent(spanB, spanD);
419+
dispatchMouseHoverEvent(spanC, spanB);
420+
421+
expect(Scheduler).toHaveYielded(['App']);
422+
423+
suspend = false;
424+
resolve();
425+
await promise;
426+
427+
// We should prioritize hydrating D first because we clicked it.
428+
// Next we should hydrate C since that's the current hover target.
429+
// Next it doesn't matter if we hydrate A or B first but as an
430+
// implementation detail we're currently hydrating B first since
431+
// we at one point hovered over it and we never deprioritized it.
432+
expect(Scheduler).toFlushAndYield([
433+
'D',
434+
'Clicked D',
435+
'C',
436+
'Hover C',
437+
'B',
438+
'A',
439+
]);
440+
441+
document.body.removeChild(container);
442+
});
293443
});

packages/react-dom/src/client/ReactDOM.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ import {
4141
IsThisRendererActing,
4242
attemptSynchronousHydration,
4343
attemptUserBlockingHydration,
44+
attemptContinuousHydration,
4445
} from 'react-reconciler/inline.dom';
4546
import {createPortal as createPortalImpl} from 'shared/ReactPortal';
4647
import {canUseDOM} from 'shared/ExecutionEnvironment';
@@ -79,6 +80,7 @@ import {dispatchEvent} from '../events/ReactDOMEventListener';
7980
import {
8081
setAttemptSynchronousHydration,
8182
setAttemptUserBlockingHydration,
83+
setAttemptContinuousHydration,
8284
} from '../events/ReactDOMEventReplaying';
8385
import {eagerlyTrapReplayableEvents} from '../events/ReactDOMEventReplaying';
8486
import {
@@ -91,6 +93,7 @@ import {ROOT_ATTRIBUTE_NAME} from '../shared/DOMProperty';
9193

9294
setAttemptSynchronousHydration(attemptSynchronousHydration);
9395
setAttemptUserBlockingHydration(attemptUserBlockingHydration);
96+
setAttemptContinuousHydration(attemptContinuousHydration);
9497

9598
const ReactCurrentOwner = ReactSharedInternals.ReactCurrentOwner;
9699

packages/react-dom/src/events/ReactDOMEventReplaying.js

Lines changed: 35 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,12 @@ export function setAttemptUserBlockingHydration(fn: (fiber: Object) => void) {
4343
attemptUserBlockingHydration = fn;
4444
}
4545

46+
let attemptContinuousHydration: (fiber: Object) => void;
47+
48+
export function setAttemptContinuousHydration(fn: (fiber: Object) => void) {
49+
attemptContinuousHydration = fn;
50+
}
51+
4652
// TODO: Upgrade this definition once we're on a newer version of Flow that
4753
// has this definition built-in.
4854
type PointerEvent = Event & {
@@ -305,7 +311,7 @@ export function clearIfContinuousEvent(
305311
}
306312
}
307313

308-
function accumulateOrCreateQueuedReplayableEvent(
314+
function accumulateOrCreateContinuousQueuedReplayableEvent(
309315
existingQueuedEvent: null | QueuedReplayableEvent,
310316
blockedOn: null | Container | SuspenseInstance,
311317
topLevelType: DOMTopLevelEventType,
@@ -316,12 +322,20 @@ function accumulateOrCreateQueuedReplayableEvent(
316322
existingQueuedEvent === null ||
317323
existingQueuedEvent.nativeEvent !== nativeEvent
318324
) {
319-
return createQueuedReplayableEvent(
325+
let queuedEvent = createQueuedReplayableEvent(
320326
blockedOn,
321327
topLevelType,
322328
eventSystemFlags,
323329
nativeEvent,
324330
);
331+
if (blockedOn !== null) {
332+
let fiber = getInstanceFromNode(blockedOn);
333+
if (fiber !== null) {
334+
// Attempt to increase the priority of this target.
335+
attemptContinuousHydration(fiber);
336+
}
337+
}
338+
return queuedEvent;
325339
}
326340
// If we have already queued this exact event, then it's because
327341
// the different event systems have different DOM event listeners.
@@ -343,7 +357,7 @@ export function queueIfContinuousEvent(
343357
switch (topLevelType) {
344358
case TOP_FOCUS: {
345359
const focusEvent = ((nativeEvent: any): FocusEvent);
346-
queuedFocus = accumulateOrCreateQueuedReplayableEvent(
360+
queuedFocus = accumulateOrCreateContinuousQueuedReplayableEvent(
347361
queuedFocus,
348362
blockedOn,
349363
topLevelType,
@@ -354,7 +368,7 @@ export function queueIfContinuousEvent(
354368
}
355369
case TOP_DRAG_ENTER: {
356370
const dragEvent = ((nativeEvent: any): DragEvent);
357-
queuedDrag = accumulateOrCreateQueuedReplayableEvent(
371+
queuedDrag = accumulateOrCreateContinuousQueuedReplayableEvent(
358372
queuedDrag,
359373
blockedOn,
360374
topLevelType,
@@ -365,7 +379,7 @@ export function queueIfContinuousEvent(
365379
}
366380
case TOP_MOUSE_OVER: {
367381
const mouseEvent = ((nativeEvent: any): MouseEvent);
368-
queuedMouse = accumulateOrCreateQueuedReplayableEvent(
382+
queuedMouse = accumulateOrCreateContinuousQueuedReplayableEvent(
369383
queuedMouse,
370384
blockedOn,
371385
topLevelType,
@@ -379,7 +393,7 @@ export function queueIfContinuousEvent(
379393
const pointerId = pointerEvent.pointerId;
380394
queuedPointers.set(
381395
pointerId,
382-
accumulateOrCreateQueuedReplayableEvent(
396+
accumulateOrCreateContinuousQueuedReplayableEvent(
383397
queuedPointers.get(pointerId) || null,
384398
blockedOn,
385399
topLevelType,
@@ -394,7 +408,7 @@ export function queueIfContinuousEvent(
394408
const pointerId = pointerEvent.pointerId;
395409
queuedPointerCaptures.set(
396410
pointerId,
397-
accumulateOrCreateQueuedReplayableEvent(
411+
accumulateOrCreateContinuousQueuedReplayableEvent(
398412
queuedPointerCaptures.get(pointerId) || null,
399413
blockedOn,
400414
topLevelType,
@@ -408,7 +422,9 @@ export function queueIfContinuousEvent(
408422
return false;
409423
}
410424

411-
function attemptReplayQueuedEvent(queuedEvent: QueuedReplayableEvent): boolean {
425+
function attemptReplayContinuousQueuedEvent(
426+
queuedEvent: QueuedReplayableEvent,
427+
): boolean {
412428
if (queuedEvent.blockedOn !== null) {
413429
return false;
414430
}
@@ -419,18 +435,22 @@ function attemptReplayQueuedEvent(queuedEvent: QueuedReplayableEvent): boolean {
419435
);
420436
if (nextBlockedOn !== null) {
421437
// We're still blocked. Try again later.
438+
let fiber = getInstanceFromNode(nextBlockedOn);
439+
if (fiber !== null) {
440+
attemptContinuousHydration(fiber);
441+
}
422442
queuedEvent.blockedOn = nextBlockedOn;
423443
return false;
424444
}
425445
return true;
426446
}
427447

428-
function attemptReplayQueuedEventInMap(
448+
function attemptReplayContinuousQueuedEventInMap(
429449
queuedEvent: QueuedReplayableEvent,
430450
key: number,
431451
map: Map<number, QueuedReplayableEvent>,
432452
): void {
433-
if (attemptReplayQueuedEvent(queuedEvent)) {
453+
if (attemptReplayContinuousQueuedEvent(queuedEvent)) {
434454
map.delete(key);
435455
}
436456
}
@@ -464,17 +484,17 @@ function replayUnblockedEvents() {
464484
}
465485
}
466486
// Next replay any continuous events.
467-
if (queuedFocus !== null && attemptReplayQueuedEvent(queuedFocus)) {
487+
if (queuedFocus !== null && attemptReplayContinuousQueuedEvent(queuedFocus)) {
468488
queuedFocus = null;
469489
}
470-
if (queuedDrag !== null && attemptReplayQueuedEvent(queuedDrag)) {
490+
if (queuedDrag !== null && attemptReplayContinuousQueuedEvent(queuedDrag)) {
471491
queuedDrag = null;
472492
}
473-
if (queuedMouse !== null && attemptReplayQueuedEvent(queuedMouse)) {
493+
if (queuedMouse !== null && attemptReplayContinuousQueuedEvent(queuedMouse)) {
474494
queuedMouse = null;
475495
}
476-
queuedPointers.forEach(attemptReplayQueuedEventInMap);
477-
queuedPointerCaptures.forEach(attemptReplayQueuedEventInMap);
496+
queuedPointers.forEach(attemptReplayContinuousQueuedEventInMap);
497+
queuedPointerCaptures.forEach(attemptReplayContinuousQueuedEventInMap);
478498
}
479499

480500
function scheduleCallbackIfUnblocked(

packages/react-reconciler/src/ReactFiberExpirationTime.js

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,10 @@ export const Never = 1;
3232
// Idle is slightly higher priority than Never. It must completely finish in
3333
// order to be consistent.
3434
export const Idle = 2;
35+
// Continuous Hydration is a moving priority. It is slightly higher than Idle
36+
// and is used to increase priority of hover targets. It is increasing with
37+
// each usage so that last always wins.
38+
let ContinuousHydration = 3;
3539
export const Sync = MAX_SIGNED_31_BIT_INT;
3640
export const Batched = Sync - 1;
3741

@@ -115,6 +119,15 @@ export function computeInteractiveExpiration(currentTime: ExpirationTime) {
115119
);
116120
}
117121

122+
export function computeContinuousHydrationExpiration(
123+
currentTime: ExpirationTime,
124+
) {
125+
// Each time we ask for a new one of these we increase the priority.
126+
// This ensures that the last one always wins since we can't deprioritize
127+
// once we've scheduled work already.
128+
return ContinuousHydration++;
129+
}
130+
118131
export function inferPriorityFromExpirationTime(
119132
currentTime: ExpirationTime,
120133
expirationTime: ExpirationTime,

packages/react-reconciler/src/ReactFiberReconciler.js

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,11 @@ import {
7878
current as ReactCurrentFiberCurrent,
7979
} from './ReactCurrentFiber';
8080
import {StrictMode} from './ReactTypeOfMode';
81-
import {Sync, computeInteractiveExpiration} from './ReactFiberExpirationTime';
81+
import {
82+
Sync,
83+
computeInteractiveExpiration,
84+
computeContinuousHydrationExpiration,
85+
} from './ReactFiberExpirationTime';
8286
import {requestCurrentSuspenseConfig} from './ReactFiberSuspenseConfig';
8387
import {
8488
scheduleRefresh,
@@ -421,6 +425,19 @@ export function attemptUserBlockingHydration(fiber: Fiber): void {
421425
markRetryTimeIfNotHydrated(fiber, expTime);
422426
}
423427

428+
export function attemptContinuousHydration(fiber: Fiber): void {
429+
if (fiber.tag !== SuspenseComponent) {
430+
// We ignore HostRoots here because we can't increase
431+
// their priority and they should not suspend on I/O,
432+
// since you have to wrap anything that might suspend in
433+
// Suspense.
434+
return;
435+
}
436+
let expTime = computeContinuousHydrationExpiration(requestCurrentTime());
437+
scheduleWork(fiber, expTime);
438+
markRetryTimeIfNotHydrated(fiber, expTime);
439+
}
440+
424441
export {findHostInstance};
425442

426443
export {findHostInstanceWithWarning};

0 commit comments

Comments
 (0)