Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Selective Hydration #16880

Merged
merged 3 commits into from
Sep 25, 2019
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @emails react-core
*/

'use strict';

let React;
let ReactDOM;
let ReactDOMServer;
let Scheduler;
let ReactFeatureFlags;
let Suspense;

function dispatchClickEvent(target) {
const mouseOutEvent = document.createEvent('MouseEvents');
mouseOutEvent.initMouseEvent(
'click',
true,
true,
window,
0,
50,
50,
50,
50,
false,
false,
false,
false,
0,
target,
);
return target.dispatchEvent(mouseOutEvent);
}

describe('ReactDOMServerSelectiveHydration', () => {
beforeEach(() => {
jest.resetModuleRegistry();

ReactFeatureFlags = require('shared/ReactFeatureFlags');
ReactFeatureFlags.enableSuspenseServerRenderer = true;
ReactFeatureFlags.enableSelectiveHydration = true;

React = require('react');
ReactDOM = require('react-dom');
ReactDOMServer = require('react-dom/server');
Scheduler = require('scheduler');
Suspense = React.Suspense;
});

it('hydrates the target boundary synchronously during a click', async () => {
function Child({text}) {
Scheduler.unstable_yieldValue(text);
return (
<span
onClick={e => {
e.preventDefault();
Scheduler.unstable_yieldValue('Clicked ' + text);
}}>
{text}
</span>
);
}

function App() {
Scheduler.unstable_yieldValue('App');
return (
<div>
<Suspense fallback="Loading...">
<Child text="A" />
</Suspense>
<Suspense fallback="Loading...">
<Child text="B" />
</Suspense>
</div>
);
}

let finalHTML = ReactDOMServer.renderToString(<App />);

expect(Scheduler).toHaveYielded(['App', 'A', 'B']);

let container = document.createElement('div');
// We need this to be in the document since we'll dispatch events on it.
document.body.appendChild(container);

container.innerHTML = finalHTML;

let span = container.getElementsByTagName('span')[1];

let root = ReactDOM.unstable_createRoot(container, {hydrate: true});
root.render(<App />);

// Nothing has been hydrated so far.
expect(Scheduler).toHaveYielded([]);

// This should synchronously hydrate the root App and the second suspense
// boundary.
let result = dispatchClickEvent(span);

// The event should have been canceled because we called preventDefault.
expect(result).toBe(false);

// We rendered App, B and then invoked the event without rendering A.
expect(Scheduler).toHaveYielded(['App', 'B', 'Clicked B']);

// After continuing the scheduler, we finally hydrate A.
expect(Scheduler).toFlushAndYield(['A']);

document.body.removeChild(container);
});
});
4 changes: 4 additions & 0 deletions packages/react-dom/src/client/ReactDOM.js
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ import {
findHostInstanceWithWarning,
flushPassiveEffects,
IsThisRendererActing,
attemptSynchronousHydration,
} from 'react-reconciler/inline.dom';
import {createPortal as createPortalImpl} from 'shared/ReactPortal';
import {canUseDOM} from 'shared/ExecutionEnvironment';
Expand Down Expand Up @@ -74,6 +75,7 @@ import {
} from './ReactDOMComponentTree';
import {restoreControlledState} from './ReactDOMComponent';
import {dispatchEvent} from '../events/ReactDOMEventListener';
import {setAttemptSynchronousHydration} from '../events/ReactDOMEventReplaying';
import {eagerlyTrapReplayableEvents} from '../events/ReactDOMEventReplaying';
import {
ELEMENT_NODE,
Expand All @@ -83,6 +85,8 @@ import {
} from '../shared/HTMLNodeType';
import {ROOT_ATTRIBUTE_NAME} from '../shared/DOMProperty';

setAttemptSynchronousHydration(attemptSynchronousHydration);

const ReactCurrentOwner = ReactSharedInternals.ReactCurrentOwner;

let topLevelUpdateWarnings;
Expand Down
16 changes: 13 additions & 3 deletions packages/react-dom/src/client/ReactDOMComponentTree.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,12 @@
* LICENSE file in the root directory of this source tree.
*/

import {HostComponent, HostText} from 'shared/ReactWorkTags';
import {
HostComponent,
HostText,
HostRoot,
SuspenseComponent,
} from 'shared/ReactWorkTags';
import invariant from 'shared/invariant';

import {getParentSuspenseInstance} from './ReactDOMHostConfig';
Expand Down Expand Up @@ -112,9 +117,14 @@ export function getClosestInstanceFromNode(targetNode) {
* instance, or null if the node was not rendered by this React.
*/
export function getInstanceFromNode(node) {
const inst = node[internalInstanceKey];
const inst = node[internalInstanceKey] || node[internalContainerInstanceKey];
if (inst) {
if (inst.tag === HostComponent || inst.tag === HostText) {
if (
inst.tag === HostComponent ||
inst.tag === HostText ||
inst.tag === SuspenseComponent ||
inst.tag === HostRoot
) {
return inst;
} else {
return null;
Expand Down
52 changes: 40 additions & 12 deletions packages/react-dom/src/events/ReactDOMEventReplaying.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,10 @@ import type {Container, SuspenseInstance} from '../client/ReactDOMHostConfig';
import type {DOMTopLevelEventType} from 'legacy-events/TopLevelEventTypes';
import type {EventSystemFlags} from 'legacy-events/EventSystemFlags';

import {enableFlareAPI} from 'shared/ReactFeatureFlags';
import {
enableFlareAPI,
enableSelectiveHydration,
} from 'shared/ReactFeatureFlags';
import {
unstable_scheduleCallback as scheduleCallback,
unstable_NormalPriority as NormalPriority,
Expand All @@ -25,8 +28,15 @@ import {
getListeningSetForElement,
listenToTopLevel,
} from './ReactBrowserEventEmitter';
import {getInstanceFromNode} from '../client/ReactDOMComponentTree';
import {unsafeCastDOMTopLevelTypeToString} from 'legacy-events/TopLevelEventTypes';

let attemptSynchronousHydration: (fiber: Object) => void;

export function setAttemptSynchronousHydration(fn: (fiber: Object) => void) {
attemptSynchronousHydration = fn;
}

// TODO: Upgrade this definition once we're on a newer version of Flow that
// has this definition built-in.
type PointerEvent = Event & {
Expand Down Expand Up @@ -223,18 +233,36 @@ export function queueDiscreteEvent(
eventSystemFlags: EventSystemFlags,
nativeEvent: AnyNativeEvent,
): void {
queuedDiscreteEvents.push(
createQueuedReplayableEvent(
blockedOn,
topLevelType,
eventSystemFlags,
nativeEvent,
),
const queuedEvent = createQueuedReplayableEvent(
blockedOn,
topLevelType,
eventSystemFlags,
nativeEvent,
);
if (blockedOn === null && queuedDiscreteEvents.length === 1) {
// This probably shouldn't happen but some defensive coding might
// help us get unblocked if we have a bug.
replayUnblockedEvents();
queuedDiscreteEvents.push(queuedEvent);
if (enableSelectiveHydration) {
if (queuedDiscreteEvents.length === 1) {
// If this was the first discrete event, we might be able to
// synchronously unblock it so that preventDefault still works.
while (queuedEvent.blockedOn !== null) {
let fiber = getInstanceFromNode(queuedEvent.blockedOn);
if (fiber === null) {
break;
}
attemptSynchronousHydration(fiber);
if (queuedEvent.blockedOn === null) {
// We got unblocked by hydration. Let's try again.
replayUnblockedEvents();
// If we're reblocked, on an inner boundary, we might need
// to attempt hydrating that one.
continue;
} else {
// We're still blocked from hydation, we have to give up
// and replay later.
break;
}
}
}
}
}

Expand Down
22 changes: 21 additions & 1 deletion packages/react-reconciler/src/ReactFiberReconciler.js
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,12 @@ import {
findCurrentHostFiberWithNoPortals,
} from 'react-reconciler/reflection';
import {get as getInstance} from 'shared/ReactInstanceMap';
import {HostComponent, ClassComponent} from 'shared/ReactWorkTags';
import {
HostComponent,
ClassComponent,
HostRoot,
SuspenseComponent,
} from 'shared/ReactWorkTags';
import getComponentName from 'shared/getComponentName';
import invariant from 'shared/invariant';
import warningWithoutStack from 'shared/warningWithoutStack';
Expand Down Expand Up @@ -362,6 +367,21 @@ export function getPublicRootInstance(
}
}

export function attemptSynchronousHydration(fiber: Fiber): void {
switch (fiber.tag) {
case HostRoot:
let root: FiberRoot = fiber.stateNode;
if (root.hydrate) {
// Flush the first scheduled "update".
flushRoot(root, root.firstPendingTime);
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah this is kind of weird but it makes sense :D

I suppose the other way to model this is, when hydrating, immediately commit the root in a dehydrated state.

}
break;
case SuspenseComponent:
flushSync(() => scheduleWork(fiber, Sync));
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I find the methods that automatically flush at the end confusing unless they are at the bottom of the stack. In the work loop I've started to prefer using two separate calls, one to queue the sync work and a second explicit call that flushes it.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yea I also don't like the closure. I would've done it that way but didn't feel justified to move this whole function into WorkLoop. Although the split between WorkLoop and Reconciler is a bit arbitrary right now. Reconciler is effectively just a smaller set of exposed functions from WorkLoop.

break;
}
}

export {findHostInstance};

export {findHostInstanceWithWarning};
Expand Down
1 change: 1 addition & 0 deletions packages/shared/ReactFeatureFlags.js
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ export const enableSchedulerTracing = __PROFILE__;

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

// Only used in www builds.
export const enableSchedulerDebugging = false;
Expand Down
1 change: 1 addition & 0 deletions packages/shared/forks/ReactFeatureFlags.native-fb.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ export const enableUserTimingAPI = __DEV__;
export const enableProfilerTimer = __PROFILE__;
export const enableSchedulerTracing = __PROFILE__;
export const enableSuspenseServerRenderer = false;
export const enableSelectiveHydration = false;
export const enableStableConcurrentModeAPIs = false;
export const warnAboutShorthandPropertyCollision = false;
export const enableSchedulerDebugging = false;
Expand Down
1 change: 1 addition & 0 deletions packages/shared/forks/ReactFeatureFlags.native-oss.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ export const warnAboutDeprecatedLifecycles = true;
export const enableProfilerTimer = __PROFILE__;
export const enableSchedulerTracing = __PROFILE__;
export const enableSuspenseServerRenderer = false;
export const enableSelectiveHydration = false;
export const disableJavaScriptURLs = false;
export const disableInputAttributeSyncing = false;
export const enableStableConcurrentModeAPIs = false;
Expand Down
1 change: 1 addition & 0 deletions packages/shared/forks/ReactFeatureFlags.persistent.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ export const replayFailedUnitOfWorkWithInvokeGuardedCallback = __DEV__;
export const enableProfilerTimer = __PROFILE__;
export const enableSchedulerTracing = __PROFILE__;
export const enableSuspenseServerRenderer = false;
export const enableSelectiveHydration = false;
export const disableJavaScriptURLs = false;
export const disableInputAttributeSyncing = false;
export const enableStableConcurrentModeAPIs = false;
Expand Down
1 change: 1 addition & 0 deletions packages/shared/forks/ReactFeatureFlags.test-renderer.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ export const replayFailedUnitOfWorkWithInvokeGuardedCallback = false;
export const enableProfilerTimer = __PROFILE__;
export const enableSchedulerTracing = __PROFILE__;
export const enableSuspenseServerRenderer = false;
export const enableSelectiveHydration = false;
export const disableJavaScriptURLs = false;
export const disableInputAttributeSyncing = false;
export const enableStableConcurrentModeAPIs = false;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ export const replayFailedUnitOfWorkWithInvokeGuardedCallback = false;
export const enableProfilerTimer = __PROFILE__;
export const enableSchedulerTracing = __PROFILE__;
export const enableSuspenseServerRenderer = false;
export const enableSelectiveHydration = false;
export const enableStableConcurrentModeAPIs = false;
export const enableSchedulerDebugging = false;
export const warnAboutDeprecatedSetNativeProps = false;
Expand Down
1 change: 1 addition & 0 deletions packages/shared/forks/ReactFeatureFlags.www.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ export const {
debugRenderPhaseSideEffectsForStrictMode,
disableInputAttributeSyncing,
enableTrustedTypesIntegration,
enableSelectiveHydration,
} = require('ReactFeatureFlags');

// In www, we have experimental support for gathering data
Expand Down