Skip to content

Commit 3694a3b

Browse files
authored
Selective Hydration (#16880)
* Add Feature Flag for Selective Hydration * Enable Synchronous Hydration of Discrete Events * Resolve cyclic dependency
1 parent 4bb0e96 commit 3694a3b

12 files changed

+202
-16
lines changed
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
/**
2+
* Copyright (c) Facebook, Inc. and its affiliates.
3+
*
4+
* This source code is licensed under the MIT license found in the
5+
* LICENSE file in the root directory of this source tree.
6+
*
7+
* @emails react-core
8+
*/
9+
10+
'use strict';
11+
12+
let React;
13+
let ReactDOM;
14+
let ReactDOMServer;
15+
let Scheduler;
16+
let ReactFeatureFlags;
17+
let Suspense;
18+
19+
function dispatchClickEvent(target) {
20+
const mouseOutEvent = document.createEvent('MouseEvents');
21+
mouseOutEvent.initMouseEvent(
22+
'click',
23+
true,
24+
true,
25+
window,
26+
0,
27+
50,
28+
50,
29+
50,
30+
50,
31+
false,
32+
false,
33+
false,
34+
false,
35+
0,
36+
target,
37+
);
38+
return target.dispatchEvent(mouseOutEvent);
39+
}
40+
41+
describe('ReactDOMServerSelectiveHydration', () => {
42+
beforeEach(() => {
43+
jest.resetModuleRegistry();
44+
45+
ReactFeatureFlags = require('shared/ReactFeatureFlags');
46+
ReactFeatureFlags.enableSuspenseServerRenderer = true;
47+
ReactFeatureFlags.enableSelectiveHydration = true;
48+
49+
React = require('react');
50+
ReactDOM = require('react-dom');
51+
ReactDOMServer = require('react-dom/server');
52+
Scheduler = require('scheduler');
53+
Suspense = React.Suspense;
54+
});
55+
56+
it('hydrates the target boundary synchronously during a click', async () => {
57+
function Child({text}) {
58+
Scheduler.unstable_yieldValue(text);
59+
return (
60+
<span
61+
onClick={e => {
62+
e.preventDefault();
63+
Scheduler.unstable_yieldValue('Clicked ' + text);
64+
}}>
65+
{text}
66+
</span>
67+
);
68+
}
69+
70+
function App() {
71+
Scheduler.unstable_yieldValue('App');
72+
return (
73+
<div>
74+
<Suspense fallback="Loading...">
75+
<Child text="A" />
76+
</Suspense>
77+
<Suspense fallback="Loading...">
78+
<Child text="B" />
79+
</Suspense>
80+
</div>
81+
);
82+
}
83+
84+
let finalHTML = ReactDOMServer.renderToString(<App />);
85+
86+
expect(Scheduler).toHaveYielded(['App', 'A', 'B']);
87+
88+
let container = document.createElement('div');
89+
// We need this to be in the document since we'll dispatch events on it.
90+
document.body.appendChild(container);
91+
92+
container.innerHTML = finalHTML;
93+
94+
let span = container.getElementsByTagName('span')[1];
95+
96+
let root = ReactDOM.unstable_createRoot(container, {hydrate: true});
97+
root.render(<App />);
98+
99+
// Nothing has been hydrated so far.
100+
expect(Scheduler).toHaveYielded([]);
101+
102+
// This should synchronously hydrate the root App and the second suspense
103+
// boundary.
104+
let result = dispatchClickEvent(span);
105+
106+
// The event should have been canceled because we called preventDefault.
107+
expect(result).toBe(false);
108+
109+
// We rendered App, B and then invoked the event without rendering A.
110+
expect(Scheduler).toHaveYielded(['App', 'B', 'Clicked B']);
111+
112+
// After continuing the scheduler, we finally hydrate A.
113+
expect(Scheduler).toFlushAndYield(['A']);
114+
115+
document.body.removeChild(container);
116+
});
117+
});

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ import {
3939
findHostInstanceWithWarning,
4040
flushPassiveEffects,
4141
IsThisRendererActing,
42+
attemptSynchronousHydration,
4243
} from 'react-reconciler/inline.dom';
4344
import {createPortal as createPortalImpl} from 'shared/ReactPortal';
4445
import {canUseDOM} from 'shared/ExecutionEnvironment';
@@ -74,6 +75,7 @@ import {
7475
} from './ReactDOMComponentTree';
7576
import {restoreControlledState} from './ReactDOMComponent';
7677
import {dispatchEvent} from '../events/ReactDOMEventListener';
78+
import {setAttemptSynchronousHydration} from '../events/ReactDOMEventReplaying';
7779
import {eagerlyTrapReplayableEvents} from '../events/ReactDOMEventReplaying';
7880
import {
7981
ELEMENT_NODE,
@@ -83,6 +85,8 @@ import {
8385
} from '../shared/HTMLNodeType';
8486
import {ROOT_ATTRIBUTE_NAME} from '../shared/DOMProperty';
8587

88+
setAttemptSynchronousHydration(attemptSynchronousHydration);
89+
8690
const ReactCurrentOwner = ReactSharedInternals.ReactCurrentOwner;
8791

8892
let topLevelUpdateWarnings;

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

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,12 @@
55
* LICENSE file in the root directory of this source tree.
66
*/
77

8-
import {HostComponent, HostText} from 'shared/ReactWorkTags';
8+
import {
9+
HostComponent,
10+
HostText,
11+
HostRoot,
12+
SuspenseComponent,
13+
} from 'shared/ReactWorkTags';
914
import invariant from 'shared/invariant';
1015

1116
import {getParentSuspenseInstance} from './ReactDOMHostConfig';
@@ -112,9 +117,14 @@ export function getClosestInstanceFromNode(targetNode) {
112117
* instance, or null if the node was not rendered by this React.
113118
*/
114119
export function getInstanceFromNode(node) {
115-
const inst = node[internalInstanceKey];
120+
const inst = node[internalInstanceKey] || node[internalContainerInstanceKey];
116121
if (inst) {
117-
if (inst.tag === HostComponent || inst.tag === HostText) {
122+
if (
123+
inst.tag === HostComponent ||
124+
inst.tag === HostText ||
125+
inst.tag === SuspenseComponent ||
126+
inst.tag === HostRoot
127+
) {
118128
return inst;
119129
} else {
120130
return null;

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

Lines changed: 40 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,10 @@ import type {Container, SuspenseInstance} from '../client/ReactDOMHostConfig';
1212
import type {DOMTopLevelEventType} from 'legacy-events/TopLevelEventTypes';
1313
import type {EventSystemFlags} from 'legacy-events/EventSystemFlags';
1414

15-
import {enableFlareAPI} from 'shared/ReactFeatureFlags';
15+
import {
16+
enableFlareAPI,
17+
enableSelectiveHydration,
18+
} from 'shared/ReactFeatureFlags';
1619
import {
1720
unstable_scheduleCallback as scheduleCallback,
1821
unstable_NormalPriority as NormalPriority,
@@ -25,8 +28,15 @@ import {
2528
getListeningSetForElement,
2629
listenToTopLevel,
2730
} from './ReactBrowserEventEmitter';
31+
import {getInstanceFromNode} from '../client/ReactDOMComponentTree';
2832
import {unsafeCastDOMTopLevelTypeToString} from 'legacy-events/TopLevelEventTypes';
2933

34+
let attemptSynchronousHydration: (fiber: Object) => void;
35+
36+
export function setAttemptSynchronousHydration(fn: (fiber: Object) => void) {
37+
attemptSynchronousHydration = fn;
38+
}
39+
3040
// TODO: Upgrade this definition once we're on a newer version of Flow that
3141
// has this definition built-in.
3242
type PointerEvent = Event & {
@@ -223,18 +233,36 @@ export function queueDiscreteEvent(
223233
eventSystemFlags: EventSystemFlags,
224234
nativeEvent: AnyNativeEvent,
225235
): void {
226-
queuedDiscreteEvents.push(
227-
createQueuedReplayableEvent(
228-
blockedOn,
229-
topLevelType,
230-
eventSystemFlags,
231-
nativeEvent,
232-
),
236+
const queuedEvent = createQueuedReplayableEvent(
237+
blockedOn,
238+
topLevelType,
239+
eventSystemFlags,
240+
nativeEvent,
233241
);
234-
if (blockedOn === null && queuedDiscreteEvents.length === 1) {
235-
// This probably shouldn't happen but some defensive coding might
236-
// help us get unblocked if we have a bug.
237-
replayUnblockedEvents();
242+
queuedDiscreteEvents.push(queuedEvent);
243+
if (enableSelectiveHydration) {
244+
if (queuedDiscreteEvents.length === 1) {
245+
// If this was the first discrete event, we might be able to
246+
// synchronously unblock it so that preventDefault still works.
247+
while (queuedEvent.blockedOn !== null) {
248+
let fiber = getInstanceFromNode(queuedEvent.blockedOn);
249+
if (fiber === null) {
250+
break;
251+
}
252+
attemptSynchronousHydration(fiber);
253+
if (queuedEvent.blockedOn === null) {
254+
// We got unblocked by hydration. Let's try again.
255+
replayUnblockedEvents();
256+
// If we're reblocked, on an inner boundary, we might need
257+
// to attempt hydrating that one.
258+
continue;
259+
} else {
260+
// We're still blocked from hydation, we have to give up
261+
// and replay later.
262+
break;
263+
}
264+
}
265+
}
238266
}
239267
}
240268

packages/react-reconciler/src/ReactFiberReconciler.js

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,12 @@ import {
2727
findCurrentHostFiberWithNoPortals,
2828
} from 'react-reconciler/reflection';
2929
import {get as getInstance} from 'shared/ReactInstanceMap';
30-
import {HostComponent, ClassComponent} from 'shared/ReactWorkTags';
30+
import {
31+
HostComponent,
32+
ClassComponent,
33+
HostRoot,
34+
SuspenseComponent,
35+
} from 'shared/ReactWorkTags';
3136
import getComponentName from 'shared/getComponentName';
3237
import invariant from 'shared/invariant';
3338
import warningWithoutStack from 'shared/warningWithoutStack';
@@ -362,6 +367,21 @@ export function getPublicRootInstance(
362367
}
363368
}
364369

370+
export function attemptSynchronousHydration(fiber: Fiber): void {
371+
switch (fiber.tag) {
372+
case HostRoot:
373+
let root: FiberRoot = fiber.stateNode;
374+
if (root.hydrate) {
375+
// Flush the first scheduled "update".
376+
flushRoot(root, root.firstPendingTime);
377+
}
378+
break;
379+
case SuspenseComponent:
380+
flushSync(() => scheduleWork(fiber, Sync));
381+
break;
382+
}
383+
}
384+
365385
export {findHostInstance};
366386

367387
export {findHostInstanceWithWarning};

packages/shared/ReactFeatureFlags.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ export const enableSchedulerTracing = __PROFILE__;
3333

3434
// Only used in www builds.
3535
export const enableSuspenseServerRenderer = false; // TODO: __DEV__? Here it might just be false.
36+
export const enableSelectiveHydration = false;
3637

3738
// Only used in www builds.
3839
export const enableSchedulerDebugging = false;

packages/shared/forks/ReactFeatureFlags.native-fb.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ export const enableUserTimingAPI = __DEV__;
2222
export const enableProfilerTimer = __PROFILE__;
2323
export const enableSchedulerTracing = __PROFILE__;
2424
export const enableSuspenseServerRenderer = false;
25+
export const enableSelectiveHydration = false;
2526
export const enableStableConcurrentModeAPIs = false;
2627
export const warnAboutShorthandPropertyCollision = false;
2728
export const enableSchedulerDebugging = false;

packages/shared/forks/ReactFeatureFlags.native-oss.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ export const warnAboutDeprecatedLifecycles = true;
2020
export const enableProfilerTimer = __PROFILE__;
2121
export const enableSchedulerTracing = __PROFILE__;
2222
export const enableSuspenseServerRenderer = false;
23+
export const enableSelectiveHydration = false;
2324
export const disableJavaScriptURLs = false;
2425
export const disableInputAttributeSyncing = false;
2526
export const enableStableConcurrentModeAPIs = false;

packages/shared/forks/ReactFeatureFlags.persistent.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ export const replayFailedUnitOfWorkWithInvokeGuardedCallback = __DEV__;
2020
export const enableProfilerTimer = __PROFILE__;
2121
export const enableSchedulerTracing = __PROFILE__;
2222
export const enableSuspenseServerRenderer = false;
23+
export const enableSelectiveHydration = false;
2324
export const disableJavaScriptURLs = false;
2425
export const disableInputAttributeSyncing = false;
2526
export const enableStableConcurrentModeAPIs = false;

packages/shared/forks/ReactFeatureFlags.test-renderer.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ export const replayFailedUnitOfWorkWithInvokeGuardedCallback = false;
2020
export const enableProfilerTimer = __PROFILE__;
2121
export const enableSchedulerTracing = __PROFILE__;
2222
export const enableSuspenseServerRenderer = false;
23+
export const enableSelectiveHydration = false;
2324
export const disableJavaScriptURLs = false;
2425
export const disableInputAttributeSyncing = false;
2526
export const enableStableConcurrentModeAPIs = false;

0 commit comments

Comments
 (0)