@samitouri / QOS-React-2 / commits / bb680a0905

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