[Selective Hydration] ReactDOM.unstable_scheduleHydration(domNode) (#17004)
Adds an API to explicitly prioritize hydrating the path to a particular DOM node without relying on events to do it. The API uses the current scheduler priority to schedule it. For the same priority, the last one wins. This allows a similar effect as continuous events. This is useful for example to hydrate based on scroll position, or prioritize components that will upgrade to client-rendered-only content. I considered having an API that explicitly overrides the current target(s). However that makes it difficult to coordinate across components in an app. This just hydrates one target at a time but if it is blocked on I/O we could consider increasing priority of later targets too.
Sebastian Markbåge committed
Oct 3, 2019 at 14:48 UTC
3a2b5f148d450c69aab67f055fc441d294c23518
4 files changed
+178
-2
packages/react-dom/src/__tests__/ReactDOMServerSelectiveHydration-test.internal.js
+48
@@ -440,4 +440,52 @@ describe('ReactDOMServerSelectiveHydration', () => {
440
441
document.body.removeChild(container);
442
});
443
+
444
+ it('hydrates the last explicitly hydrated target at higher priority', async () => {
445
+ function Child({text}) {
446
+ Scheduler.unstable_yieldValue(text);
447
+ return <span>{text}</span>;
448
+ }
449
+
450
+ function App() {
451
+ Scheduler.unstable_yieldValue('App');
452
+ return (
453
+ <div>
454
+ <Suspense fallback="Loading...">
455
+ <Child text="A" />
456
+ </Suspense>
457
+ <Suspense fallback="Loading...">
458
+ <Child text="B" />
459
+ </Suspense>
460
+ <Suspense fallback="Loading...">
461
+ <Child text="C" />
462
+ </Suspense>
463
+ </div>
464
+ );
465
+ }
466
+
467
+ let finalHTML = ReactDOMServer.renderToString(<App />);
468
+
469
+ expect(Scheduler).toHaveYielded(['App', 'A', 'B', 'C']);
470
+
471
+ let container = document.createElement('div');
472
+ container.innerHTML = finalHTML;
473
+
474
+ let spanB = container.getElementsByTagName('span')[1];
475
+ let spanC = container.getElementsByTagName('span')[2];
476
+
477
+ let root = ReactDOM.unstable_createRoot(container, {hydrate: true});
478
+ root.render(<App />);
479
+
480
+ // Nothing has been hydrated so far.
481
+ expect(Scheduler).toHaveYielded([]);
482
+
483
+ // Increase priority of B and then C.
484
+ ReactDOM.unstable_scheduleHydration(spanB);
485
+ ReactDOM.unstable_scheduleHydration(spanC);
486
+
487
+ // We should prioritize hydrating C first because the last added
488
+ // gets highest priority followed by the next added.
489
+ expect(Scheduler).toFlushAndYield(['App', 'C', 'B', 'A']);
490
+ });
491
});
packages/react-dom/src/client/ReactDOM.js
+11
-1
@@ -42,6 +42,7 @@ import {
42
attemptSynchronousHydration,
43
attemptUserBlockingHydration,
44
attemptContinuousHydration,
45
+ attemptHydrationAtCurrentPriority,
46
} from 'react-reconciler/inline.dom';
47
import {createPortal as createPortalImpl} from 'shared/ReactPortal';
48
import {canUseDOM} from 'shared/ExecutionEnvironment';
@@ -81,8 +82,10 @@ import {
82
setAttemptSynchronousHydration,
83
setAttemptUserBlockingHydration,
84
setAttemptContinuousHydration,
85
+ setAttemptHydrationAtCurrentPriority,
86
+ eagerlyTrapReplayableEvents,
87
+ queueExplicitHydrationTarget,
88
} from '../events/ReactDOMEventReplaying';
85
-import {eagerlyTrapReplayableEvents} from '../events/ReactDOMEventReplaying';
89
import {
90
ELEMENT_NODE,
91
COMMENT_NODE,
@@ -94,6 +97,7 @@ import {ROOT_ATTRIBUTE_NAME} from '../shared/DOMProperty';
97
setAttemptSynchronousHydration(attemptSynchronousHydration);
98
setAttemptUserBlockingHydration(attemptUserBlockingHydration);
99
setAttemptContinuousHydration(attemptContinuousHydration);
100
+setAttemptHydrationAtCurrentPriority(attemptHydrationAtCurrentPriority);
101
102
const ReactCurrentOwner = ReactSharedInternals.ReactCurrentOwner;
103
@@ -841,6 +845,12 @@ const ReactDOM: Object = {
845
unstable_createSyncRoot: createSyncRoot,
846
unstable_flushControlled: flushControlled,
847
848
+ unstable_scheduleHydration(target: Node) {
849
+ if (target) {
850
+ queueExplicitHydrationTarget(target);
851
+ }
852
+ },
853
+
854
__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED: {
855
// Keep in sync with ReactDOMUnstableNativeDependencies.js
856
// ReactTestUtils.js, and ReactTestUtilsAct.js. This is an array for better minification.
packages/react-dom/src/events/ReactDOMEventReplaying.js
+107
-1
@@ -11,15 +11,23 @@ import type {AnyNativeEvent} from 'legacy-events/PluginModuleType';
11
import type {Container, SuspenseInstance} from '../client/ReactDOMHostConfig';
12
import type {DOMTopLevelEventType} from 'legacy-events/TopLevelEventTypes';
13
import type {EventSystemFlags} from 'legacy-events/EventSystemFlags';
14
+import type {FiberRoot} from 'react-reconciler/src/ReactFiberRoot';
15
16
import {
17
enableFlareAPI,
18
enableSelectiveHydration,
19
} from 'shared/ReactFeatureFlags';
20
import {
21
+ unstable_runWithPriority as runWithPriority,
22
unstable_scheduleCallback as scheduleCallback,
23
unstable_NormalPriority as NormalPriority,
24
+ unstable_getCurrentPriorityLevel as getCurrentPriorityLevel,
25
} from 'scheduler';
26
+import {
27
+ getNearestMountedFiber,
28
+ getContainerFromFiber,
29
+ getSuspenseInstanceFromFiber,
30
+} from 'react-reconciler/reflection';
31
import {
32
attemptToDispatchEvent,
33
trapEventForResponderEventSystem,
@@ -28,8 +36,12 @@ import {
36
getListeningSetForElement,
37
listenToTopLevel,
38
} from './ReactBrowserEventEmitter';
31
-import {getInstanceFromNode} from '../client/ReactDOMComponentTree';
39
+import {
40
+ getInstanceFromNode,
41
+ getClosestInstanceFromNode,
42
+} from '../client/ReactDOMComponentTree';
43
import {unsafeCastDOMTopLevelTypeToString} from 'legacy-events/TopLevelEventTypes';
44
+import {HostRoot, SuspenseComponent} from 'shared/ReactWorkTags';
45
46
let attemptSynchronousHydration: (fiber: Object) => void;
47
@@ -49,6 +61,14 @@ export function setAttemptContinuousHydration(fn: (fiber: Object) => void) {
61
attemptContinuousHydration = fn;
62
}
63
64
+let attemptHydrationAtCurrentPriority: (fiber: Object) => void;
65
+
66
+export function setAttemptHydrationAtCurrentPriority(
67
+ fn: (fiber: Object) => void,
68
+) {
69
+ attemptHydrationAtCurrentPriority = fn;
70
+}
71
+
72
// TODO: Upgrade this definition once we're on a newer version of Flow that
73
// has this definition built-in.
74
type PointerEvent = Event & {
@@ -124,6 +144,13 @@ let queuedPointers: Map<number, QueuedReplayableEvent> = new Map();
144
let queuedPointerCaptures: Map<number, QueuedReplayableEvent> = new Map();
145
// We could consider replaying selectionchange and touchmoves too.
146
147
+type QueuedHydrationTarget = {|
148
+ blockedOn: null | Container | SuspenseInstance,
149
+ target: Node,
150
+ priority: number,
151
+|};
152
+let queuedExplicitHydrationTargets: Array<QueuedHydrationTarget> = [];
153
+
154
export function hasQueuedDiscreteEvents(): boolean {
155
return queuedDiscreteEvents.length > 0;
156
}
@@ -422,6 +449,64 @@ export function queueIfContinuousEvent(
449
return false;
450
}
451
452
+// Check if this target is unblocked. Returns true if it's unblocked.
453
+function attemptExplicitHydrationTarget(
454
+ queuedTarget: QueuedHydrationTarget,
455
+): void {
456
+ // TODO: This function shares a lot of logic with attemptToDispatchEvent.
457
+ // Try to unify them. It's a bit tricky since it would require two return
458
+ // values.
459
+ let targetInst = getClosestInstanceFromNode(queuedTarget.target);
460
+ if (targetInst !== null) {
461
+ let nearestMounted = getNearestMountedFiber(targetInst);
462
+ if (nearestMounted !== null) {
463
+ const tag = nearestMounted.tag;
464
+ if (tag === SuspenseComponent) {
465
+ let instance = getSuspenseInstanceFromFiber(nearestMounted);
466
+ if (instance !== null) {
467
+ // We're blocked on hydrating this boundary.
468
+ // Increase its priority.
469
+ queuedTarget.blockedOn = instance;
470
+ runWithPriority(queuedTarget.priority, () => {
471
+ attemptHydrationAtCurrentPriority(nearestMounted);
472
+ });
473
+ return;
474
+ }
475
+ } else if (tag === HostRoot) {
476
+ const root: FiberRoot = nearestMounted.stateNode;
477
+ if (root.hydrate) {
478
+ queuedTarget.blockedOn = getContainerFromFiber(nearestMounted);
479
+ // We don't currently have a way to increase the priority of
480
+ // a root other than sync.
481
+ return;
482
+ }
483
+ }
484
+ }
485
+ }
486
+ queuedTarget.blockedOn = null;
487
+}
488
+
489
+export function queueExplicitHydrationTarget(target: Node): void {
490
+ if (enableSelectiveHydration) {
491
+ let priority = getCurrentPriorityLevel();
492
+ const queuedTarget: QueuedHydrationTarget = {
493
+ blockedOn: null,
494
+ target: target,
495
+ priority: priority,
496
+ };
497
+ let i = 0;
498
+ for (; i < queuedExplicitHydrationTargets.length; i++) {
499
+ if (priority <= queuedExplicitHydrationTargets[i].priority) {
500
+ break;
501
+ }
502
+ }
503
+ queuedExplicitHydrationTargets.splice(i, 0, queuedTarget);
504
+ if (i === 0) {
505
+ attemptExplicitHydrationTarget(queuedTarget);
506
+ }
507
+ }
508
+}
509
+
510
function attemptReplayContinuousQueuedEvent(
511
queuedEvent: QueuedReplayableEvent,
512
): boolean {
@@ -544,4 +629,25 @@ export function retryIfBlockedOn(
629
scheduleCallbackIfUnblocked(queuedEvent, unblocked);
630
queuedPointers.forEach(unblock);
631
queuedPointerCaptures.forEach(unblock);
632
+
633
+ for (let i = 0; i < queuedExplicitHydrationTargets.length; i++) {
634
+ let queuedTarget = queuedExplicitHydrationTargets[i];
635
+ if (queuedTarget.blockedOn === unblocked) {
636
+ queuedTarget.blockedOn = null;
637
+ }
638
+ }
639
+
640
+ while (queuedExplicitHydrationTargets.length > 0) {
641
+ let nextExplicitTarget = queuedExplicitHydrationTargets[0];
642
+ if (nextExplicitTarget.blockedOn !== null) {
643
+ // We're still blocked.
644
+ break;
645
+ } else {
646
+ attemptExplicitHydrationTarget(nextExplicitTarget);
647
+ if (nextExplicitTarget.blockedOn === null) {
648
+ // We're unblocked.
649
+ queuedExplicitHydrationTargets.shift();
650
+ }
651
+ }
652
+ }
653
}
packages/react-reconciler/src/ReactFiberReconciler.js
+12
@@ -438,6 +438,18 @@ export function attemptContinuousHydration(fiber: Fiber): void {
438
markRetryTimeIfNotHydrated(fiber, expTime);
439
}
440
441
+export function attemptHydrationAtCurrentPriority(fiber: Fiber): void {
442
+ if (fiber.tag !== SuspenseComponent) {
443
+ // We ignore HostRoots here because we can't increase
444
+ // their priority other than synchronously flush it.
445
+ return;
446
+ }
447
+ const currentTime = requestCurrentTime();
448
+ const expTime = computeExpirationForFiber(currentTime, fiber, null);
449
+ scheduleWork(fiber, expTime);
450
+ markRetryTimeIfNotHydrated(fiber, expTime);
451
+}
452
+
453
export {findHostInstance};
454
455
export {findHostInstanceWithWarning};