[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.
Sebastian Markbåge committed
Oct 2, 2019 at 14:52 UTC
bb680a09052b5af6543daf28bfcc3283bbc35cf9
5 files changed
+219
-16
packages/react-dom/src/__tests__/ReactDOMServerSelectiveHydration-test.internal.js
+150
@@ -16,6 +16,57 @@ let Scheduler;
16
let ReactFeatureFlags;
17
let Suspense;
18
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
+
70
function dispatchClickEvent(target) {
71
const mouseOutEvent = document.createEvent('MouseEvents');
72
mouseOutEvent.initMouseEvent(
@@ -290,4 +341,103 @@ describe('ReactDOMServerSelectiveHydration', () => {
341
342
document.body.removeChild(container);
343
});
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
+ });
443
});
packages/react-dom/src/client/ReactDOM.js
+3
@@ -41,6 +41,7 @@ import {
41
IsThisRendererActing,
42
attemptSynchronousHydration,
43
attemptUserBlockingHydration,
44
+ attemptContinuousHydration,
45
} from 'react-reconciler/inline.dom';
46
import {createPortal as createPortalImpl} from 'shared/ReactPortal';
47
import {canUseDOM} from 'shared/ExecutionEnvironment';
@@ -79,6 +80,7 @@ import {dispatchEvent} from '../events/ReactDOMEventListener';
80
import {
81
setAttemptSynchronousHydration,
82
setAttemptUserBlockingHydration,
83
+ setAttemptContinuousHydration,
84
} from '../events/ReactDOMEventReplaying';
85
import {eagerlyTrapReplayableEvents} from '../events/ReactDOMEventReplaying';
86
import {
@@ -91,6 +93,7 @@ import {ROOT_ATTRIBUTE_NAME} from '../shared/DOMProperty';
93
94
setAttemptSynchronousHydration(attemptSynchronousHydration);
95
setAttemptUserBlockingHydration(attemptUserBlockingHydration);
96
+setAttemptContinuousHydration(attemptContinuousHydration);
97
98
const ReactCurrentOwner = ReactSharedInternals.ReactCurrentOwner;
99
packages/react-dom/src/events/ReactDOMEventReplaying.js
+35
-15
@@ -43,6 +43,12 @@ export function setAttemptUserBlockingHydration(fn: (fiber: Object) => void) {
43
attemptUserBlockingHydration = fn;
44
}
45
46
+let attemptContinuousHydration: (fiber: Object) => void;
47
+
48
+export function setAttemptContinuousHydration(fn: (fiber: Object) => void) {
49
+ attemptContinuousHydration = fn;
50
+}
51
+
52
// TODO: Upgrade this definition once we're on a newer version of Flow that
53
// has this definition built-in.
54
type PointerEvent = Event & {
@@ -305,7 +311,7 @@ export function clearIfContinuousEvent(
311
}
312
}
313
308
-function accumulateOrCreateQueuedReplayableEvent(
314
+function accumulateOrCreateContinuousQueuedReplayableEvent(
315
existingQueuedEvent: null | QueuedReplayableEvent,
316
blockedOn: null | Container | SuspenseInstance,
317
topLevelType: DOMTopLevelEventType,
@@ -316,12 +322,20 @@ function accumulateOrCreateQueuedReplayableEvent(
322
existingQueuedEvent === null ||
323
existingQueuedEvent.nativeEvent !== nativeEvent
324
) {
319
- return createQueuedReplayableEvent(
325
+ let queuedEvent = createQueuedReplayableEvent(
326
blockedOn,
327
topLevelType,
328
eventSystemFlags,
329
nativeEvent,
330
);
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;
339
}
340
// If we have already queued this exact event, then it's because
341
// the different event systems have different DOM event listeners.
@@ -343,7 +357,7 @@ export function queueIfContinuousEvent(
357
switch (topLevelType) {
358
case TOP_FOCUS: {
359
const focusEvent = ((nativeEvent: any): FocusEvent);
346
- queuedFocus = accumulateOrCreateQueuedReplayableEvent(
360
+ queuedFocus = accumulateOrCreateContinuousQueuedReplayableEvent(
361
queuedFocus,
362
blockedOn,
363
topLevelType,
@@ -354,7 +368,7 @@ export function queueIfContinuousEvent(
368
}
369
case TOP_DRAG_ENTER: {
370
const dragEvent = ((nativeEvent: any): DragEvent);
357
- queuedDrag = accumulateOrCreateQueuedReplayableEvent(
371
+ queuedDrag = accumulateOrCreateContinuousQueuedReplayableEvent(
372
queuedDrag,
373
blockedOn,
374
topLevelType,
@@ -365,7 +379,7 @@ export function queueIfContinuousEvent(
379
}
380
case TOP_MOUSE_OVER: {
381
const mouseEvent = ((nativeEvent: any): MouseEvent);
368
- queuedMouse = accumulateOrCreateQueuedReplayableEvent(
382
+ queuedMouse = accumulateOrCreateContinuousQueuedReplayableEvent(
383
queuedMouse,
384
blockedOn,
385
topLevelType,
@@ -379,7 +393,7 @@ export function queueIfContinuousEvent(
393
const pointerId = pointerEvent.pointerId;
394
queuedPointers.set(
395
pointerId,
382
- accumulateOrCreateQueuedReplayableEvent(
396
+ accumulateOrCreateContinuousQueuedReplayableEvent(
397
queuedPointers.get(pointerId) || null,
398
blockedOn,
399
topLevelType,
@@ -394,7 +408,7 @@ export function queueIfContinuousEvent(
408
const pointerId = pointerEvent.pointerId;
409
queuedPointerCaptures.set(
410
pointerId,
397
- accumulateOrCreateQueuedReplayableEvent(
411
+ accumulateOrCreateContinuousQueuedReplayableEvent(
412
queuedPointerCaptures.get(pointerId) || null,
413
blockedOn,
414
topLevelType,
@@ -408,7 +422,9 @@ export function queueIfContinuousEvent(
422
return false;
423
}
424
411
-function attemptReplayQueuedEvent(queuedEvent: QueuedReplayableEvent): boolean {
425
+function attemptReplayContinuousQueuedEvent(
426
+ queuedEvent: QueuedReplayableEvent,
427
+): boolean {
428
if (queuedEvent.blockedOn !== null) {
429
return false;
430
}
@@ -419,18 +435,22 @@ function attemptReplayQueuedEvent(queuedEvent: QueuedReplayableEvent): boolean {
435
);
436
if (nextBlockedOn !== null) {
437
// We're still blocked. Try again later.
438
+ let fiber = getInstanceFromNode(nextBlockedOn);
439
+ if (fiber !== null) {
440
+ attemptContinuousHydration(fiber);
441
+ }
442
queuedEvent.blockedOn = nextBlockedOn;
443
return false;
444
}
445
return true;
446
}
447
428
-function attemptReplayQueuedEventInMap(
448
+function attemptReplayContinuousQueuedEventInMap(
449
queuedEvent: QueuedReplayableEvent,
450
key: number,
451
map: Map<number, QueuedReplayableEvent>,
452
): void {
433
- if (attemptReplayQueuedEvent(queuedEvent)) {
453
+ if (attemptReplayContinuousQueuedEvent(queuedEvent)) {
454
map.delete(key);
455
}
456
}
@@ -464,17 +484,17 @@ function replayUnblockedEvents() {
484
}
485
}
486
// Next replay any continuous events.
467
- if (queuedFocus !== null && attemptReplayQueuedEvent(queuedFocus)) {
487
+ if (queuedFocus !== null && attemptReplayContinuousQueuedEvent(queuedFocus)) {
488
queuedFocus = null;
489
}
470
- if (queuedDrag !== null && attemptReplayQueuedEvent(queuedDrag)) {
490
+ if (queuedDrag !== null && attemptReplayContinuousQueuedEvent(queuedDrag)) {
491
queuedDrag = null;
492
}
473
- if (queuedMouse !== null && attemptReplayQueuedEvent(queuedMouse)) {
493
+ if (queuedMouse !== null && attemptReplayContinuousQueuedEvent(queuedMouse)) {
494
queuedMouse = null;
495
}
476
- queuedPointers.forEach(attemptReplayQueuedEventInMap);
477
- queuedPointerCaptures.forEach(attemptReplayQueuedEventInMap);
496
+ queuedPointers.forEach(attemptReplayContinuousQueuedEventInMap);
497
+ queuedPointerCaptures.forEach(attemptReplayContinuousQueuedEventInMap);
498
}
499
500
function scheduleCallbackIfUnblocked(
packages/react-reconciler/src/ReactFiberExpirationTime.js
+13
@@ -32,6 +32,10 @@ export const Never = 1;
32
// Idle is slightly higher priority than Never. It must completely finish in
33
// order to be consistent.
34
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;
39
export const Sync = MAX_SIGNED_31_BIT_INT;
40
export const Batched = Sync - 1;
41
@@ -115,6 +119,15 @@ export function computeInteractiveExpiration(currentTime: ExpirationTime) {
119
);
120
}
121
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
+
131
export function inferPriorityFromExpirationTime(
132
currentTime: ExpirationTime,
133
expirationTime: ExpirationTime,
packages/react-reconciler/src/ReactFiberReconciler.js
+18
-1
@@ -78,7 +78,11 @@ import {
78
current as ReactCurrentFiberCurrent,
79
} from './ReactCurrentFiber';
80
import {StrictMode} from './ReactTypeOfMode';
81
-import {Sync, computeInteractiveExpiration} from './ReactFiberExpirationTime';
81
+import {
82
+ Sync,
83
+ computeInteractiveExpiration,
84
+ computeContinuousHydrationExpiration,
85
+} from './ReactFiberExpirationTime';
86
import {requestCurrentSuspenseConfig} from './ReactFiberSuspenseConfig';
87
import {
88
scheduleRefresh,
@@ -421,6 +425,19 @@ export function attemptUserBlockingHydration(fiber: Fiber): void {
425
markRetryTimeIfNotHydrated(fiber, expTime);
426
}
427
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
+
441
export {findHostInstance};
442
443
export {findHostInstanceWithWarning};