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

Add ReactDOM.hydrate() as explicit SSR hydration API #10339

Merged
merged 4 commits into from
Aug 1, 2017
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
25 changes: 24 additions & 1 deletion src/renderers/__tests__/ReactCompositeComponent-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ var ChildUpdates;
var MorphingComponent;
var React;
var ReactDOM;
var ReactDOMFeatureFlags;
var ReactDOMServer;
var ReactCurrentOwner;
var ReactTestUtils;
Expand All @@ -27,6 +28,7 @@ describe('ReactCompositeComponent', () => {
jest.resetModules();
React = require('react');
ReactDOM = require('react-dom');
ReactDOMFeatureFlags = require('ReactDOMFeatureFlags');
ReactDOMServer = require('react-dom/server');
ReactCurrentOwner = require('react')
.__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED.ReactCurrentOwner;
Expand Down Expand Up @@ -116,11 +118,32 @@ describe('ReactCompositeComponent', () => {
}
}

spyOn(console, 'warn');
var markup = ReactDOMServer.renderToString(<Parent />);

// Old API based on heuristic
var container = document.createElement('div');
container.innerHTML = markup;

ReactDOM.render(<Parent />, container);
if (ReactDOMFeatureFlags.useFiber) {
expectDev(console.warn.calls.count()).toBe(1);
expectDev(console.warn.calls.argsFor(0)[0]).toContain(
'render(): Calling ReactDOM.render() to hydrate server-rendered markup ' +
'will stop working in React v17. Replace the ReactDOM.render() call ' +
'with ReactDOM.hydrate() if you want React to attach to the server HTML.',
);
} else {
expectDev(console.warn.calls.count()).toBe(0);
}

// New explicit API
console.warn.calls.reset();
if (ReactDOMFeatureFlags.useFiber) {
container = document.createElement('div');
container.innerHTML = markup;
ReactDOM.hydrate(<Parent />, container);
expectDev(console.warn.calls.count()).toBe(0);
}
});

it('should react to state changes from callbacks', () => {
Expand Down
57 changes: 49 additions & 8 deletions src/renderers/dom/fiber/ReactDOMFiberEntry.js
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ var {
DOCUMENT_NODE,
DOCUMENT_FRAGMENT_NODE,
} = require('HTMLNodeType');
var {ID_ATTRIBUTE_NAME} = require('DOMProperty');
var {ROOT_ATTRIBUTE_NAME} = require('DOMProperty');

var findDOMNode = require('findDOMNode');
var invariant = require('fbjs/lib/invariant');
Expand All @@ -58,6 +58,7 @@ var {
var {precacheFiberNode, updateFiberProps} = ReactDOMComponentTree;

if (__DEV__) {
var lowPriorityWarning = require('lowPriorityWarning');
var warning = require('fbjs/lib/warning');
var validateDOMNesting = require('validateDOMNesting');
var {updatedAncestorInfo} = validateDOMNesting;
Expand Down Expand Up @@ -127,11 +128,11 @@ function getReactRootElementInContainer(container: any) {
}
}

function shouldReuseContent(container) {
function shouldHydrateDueToLegacyHeuristic(container) {
const rootElement = getReactRootElementInContainer(container);
return !!(rootElement &&
rootElement.nodeType === ELEMENT_NODE &&
rootElement.hasAttribute(ID_ATTRIBUTE_NAME));
rootElement.hasAttribute(ROOT_ATTRIBUTE_NAME));
}

function shouldAutoFocusHostComponent(type: string, props: Props): boolean {
Expand Down Expand Up @@ -523,10 +524,14 @@ ReactGenericBatching.injection.injectFiberBatchedUpdates(
DOMRenderer.batchedUpdates,
);

var warnedAboutHydrateAPI = false;
var warnedAboutEmptyContainer = false;

function renderSubtreeIntoContainer(
parentComponent: ?ReactComponent<any, any, any>,
children: ReactNodeList,
container: DOMContainer,
forceHydrate: boolean,
callback: ?Function,
) {
invariant(
Expand Down Expand Up @@ -577,17 +582,18 @@ function renderSubtreeIntoContainer(

let root = container._reactRootContainer;
if (!root) {
const shouldHydrate =
forceHydrate || shouldHydrateDueToLegacyHeuristic(container);
// First clear any existing content.
// TODO: Figure out the best heuristic here.
if (!shouldReuseContent(container)) {
if (!shouldHydrate) {
let warned = false;
let rootSibling;
while ((rootSibling = container.lastChild)) {
if (__DEV__) {
if (
!warned &&
rootSibling.nodeType === ELEMENT_NODE &&
(rootSibling: any).hasAttribute(ID_ATTRIBUTE_NAME)
(rootSibling: any).hasAttribute(ROOT_ATTRIBUTE_NAME)
) {
warned = true;
warning(
Expand All @@ -601,6 +607,25 @@ function renderSubtreeIntoContainer(
container.removeChild(rootSibling);
}
}
if (__DEV__) {
if (shouldHydrate && !forceHydrate && !warnedAboutHydrateAPI) {
warnedAboutHydrateAPI = true;
lowPriorityWarning(
false,
'render(): Calling ReactDOM.render() to hydrate server-rendered markup ' +
'will stop working in React v17. Replace the ReactDOM.render() call ' +
'with ReactDOM.hydrate() if you want React to attach to the server HTML.',
);
}
if (forceHydrate && !container.firstChild && !warnedAboutEmptyContainer) {
warnedAboutEmptyContainer = true;
warning(
false,
'hydrate(): Expected to hydrate from server-rendered markup, but the passed ' +
'DOM container node was empty. React will create the DOM from scratch.',
Copy link
Collaborator

@sebmarkbage sebmarkbage Aug 1, 2017

Choose a reason for hiding this comment

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

IMO we should revert this warning since it's valid to have an empty container if your server rendered "", null, false or [] at the root. You'll get a warning about hydration being a non-match later anyway, if it's not. We can potentially special case the bulk error message in #10085 if the only diff was that the container was empty.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Ah okay. Will do.

);
}
}
const newRoot = DOMRenderer.createContainer(container);
root = container._reactRootContainer = newRoot;
// Initial mount should not be batched.
Expand All @@ -614,6 +639,15 @@ function renderSubtreeIntoContainer(
}

var ReactDOMFiber = {
hydrate(
element: ReactElement<any>,
container: DOMContainer,
callback: ?Function,
) {
// TODO: throw or warn if we couldn't hydrate?
return renderSubtreeIntoContainer(null, element, container, true, callback);
},

render(
element: ReactElement<any>,
container: DOMContainer,
Expand Down Expand Up @@ -651,7 +685,13 @@ var ReactDOMFiber = {
}
}
}
return renderSubtreeIntoContainer(null, element, container, callback);
return renderSubtreeIntoContainer(
null,
element,
container,
false,
callback,
);
},

unstable_renderSubtreeIntoContainer(
Expand All @@ -668,6 +708,7 @@ var ReactDOMFiber = {
parentComponent,
element,
containerNode,
false,
callback,
);
},
Expand All @@ -692,7 +733,7 @@ var ReactDOMFiber = {

// Unmount should not be batched.
DOMRenderer.unbatchedUpdates(() => {
renderSubtreeIntoContainer(null, null, container, () => {
renderSubtreeIntoContainer(null, null, container, false, () => {
container._reactRootContainer = null;
});
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@

'use strict';

var ReactDOMFeatureFlags = require('ReactDOMFeatureFlags');

describe('ReactDOMComponentTree', () => {
var React;
var ReactDOM;
Expand All @@ -21,7 +23,11 @@ describe('ReactDOMComponentTree', () => {
var container = document.createElement('div');
// Force server-rendering path:
container.innerHTML = ReactDOMServer.renderToString(elt);
return ReactDOM.render(elt, container);
if (ReactDOMFeatureFlags.useFiber) {
return ReactDOM.hydrate(elt, container);
} else {
return ReactDOM.render(elt, container);
}
}

function getTypeOf(instance) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,9 +30,13 @@ const COMMENT_NODE_TYPE = 8;
// ====================================

// promisified version of ReactDOM.render()
function asyncReactDOMRender(reactElement, domElement) {
function asyncReactDOMRender(reactElement, domElement, forceHydrate) {
return new Promise(resolve => {
ReactDOM.render(reactElement, domElement);
if (forceHydrate && ReactDOMFeatureFlags.useFiber) {
ReactDOM.hydrate(reactElement, domElement);
} else {
ReactDOM.render(reactElement, domElement);
}
// We can't use the callback for resolution because that will not catch
// errors. They're thrown.
resolve();
Expand Down Expand Up @@ -68,10 +72,10 @@ async function expectErrors(fn, count) {

// renders the reactElement into domElement, and expects a certain number of errors.
// returns a Promise that resolves when the render is complete.
function renderIntoDom(reactElement, domElement, errorCount = 0) {
function renderIntoDom(reactElement, domElement, forceHydrate, errorCount = 0) {
return expectErrors(async () => {
ExecutionEnvironment.canUseDOM = true;
await asyncReactDOMRender(reactElement, domElement);
await asyncReactDOMRender(reactElement, domElement, forceHydrate);
ExecutionEnvironment.canUseDOM = false;
return domElement.firstChild;
}, errorCount);
Expand Down Expand Up @@ -135,7 +139,7 @@ async function streamRender(reactElement, errorCount = 0) {

const clientCleanRender = (element, errorCount = 0) => {
const div = document.createElement('div');
return renderIntoDom(element, div, errorCount);
return renderIntoDom(element, div, false, errorCount);
};

const clientRenderOnServerString = async (element, errorCount = 0) => {
Expand All @@ -146,27 +150,23 @@ const clientRenderOnServerString = async (element, errorCount = 0) => {
domElement.innerHTML = markup;
let serverNode = domElement.firstChild;

const firstClientNode = await renderIntoDom(element, domElement, errorCount);
const firstClientNode = await renderIntoDom(
element,
domElement,
true,
errorCount,
);
let clientNode = firstClientNode;

// Make sure all top level nodes match up
while (serverNode || clientNode) {
expect(serverNode != null).toBe(true);
expect(clientNode != null).toBe(true);
expect(clientNode.nodeType).toBe(serverNode.nodeType);
if (clientNode.nodeType === TEXT_NODE_TYPE) {
// Text nodes are stateless so we can just compare values.
// This works around a current issue where hydration replaces top-level
// text node, but otherwise works.
// TODO: we can remove this branch if we add explicit hydration opt-in.
// https:/facebook/react/issues/10189
expect(serverNode.nodeValue).toBe(clientNode.nodeValue);
} else {
// Assert that the DOM element hasn't been replaced.
// Note that we cannot use expect(serverNode).toBe(clientNode) because
// of jest bug #1772.
expect(serverNode === clientNode).toBe(true);
}
// Assert that the DOM element hasn't been replaced.
// Note that we cannot use expect(serverNode).toBe(clientNode) because
// of jest bug #1772.
expect(serverNode === clientNode).toBe(true);
serverNode = serverNode.nextSibling;
clientNode = clientNode.nextSibling;
}
Expand All @@ -180,15 +180,15 @@ const clientRenderOnBadMarkup = async (element, errorCount = 0) => {
var domElement = document.createElement('div');
domElement.innerHTML =
'<div id="badIdWhichWillCauseMismatch" data-reactroot="" data-reactid="1"></div>';
await renderIntoDom(element, domElement, errorCount + 1);
await renderIntoDom(element, domElement, true, errorCount + 1);

// This gives us the resulting text content.
var hydratedTextContent = domElement.textContent;

// Next we render the element into a clean DOM node client side.
const cleanDomElement = document.createElement('div');
ExecutionEnvironment.canUseDOM = true;
await asyncReactDOMRender(element, cleanDomElement);
await asyncReactDOMRender(element, cleanDomElement, true);
ExecutionEnvironment.canUseDOM = false;
// This gives us the expected text content.
const cleanTextContent = cleanDomElement.textContent;
Expand Down Expand Up @@ -296,6 +296,7 @@ async function testMarkupMatch(serverElement, clientElement, shouldMatch) {
return renderIntoDom(
clientElement,
domElement.parentNode,
true,
shouldMatch ? 0 : 1,
);
}
Expand Down Expand Up @@ -1971,7 +1972,11 @@ describe('ReactDOMServerIntegration', () => {

resetModules();
// client render on top of the server markup.
const clientField = await renderIntoDom(element, field.parentNode);
const clientField = await renderIntoDom(
element,
field.parentNode,
true,
);
// verify that the input field was not replaced.
// Note that we cannot use expect(clientField).toBe(field) because
// of jest bug #1772
Expand Down Expand Up @@ -2330,6 +2335,7 @@ describe('ReactDOMServerIntegration', () => {
await asyncReactDOMRender(
<RefsComponent ref={e => (component = e)} />,
root,
true,
);
expect(component.refs.myDiv).toBe(root.firstChild);
});
Expand Down
14 changes: 12 additions & 2 deletions src/renderers/dom/shared/__tests__/ReactDOMTextComponent-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
var React;
var ReactDOM;
var ReactDOMServer;
var ReactDOMFeatureFlags;

// In standard React, TextComponent keeps track of different Text templates
// using comments. However, in React Fiber, those comments are not outputted due
Expand All @@ -29,6 +30,7 @@ describe('ReactDOMTextComponent', () => {
React = require('react');
ReactDOM = require('react-dom');
ReactDOMServer = require('react-dom/server');
ReactDOMFeatureFlags = require('ReactDOMFeatureFlags');
});

it('updates a mounted text component in place', () => {
Expand Down Expand Up @@ -117,15 +119,23 @@ describe('ReactDOMTextComponent', () => {
var reactEl = <div>{'foo'}{'bar'}{'baz'}</div>;
el.innerHTML = ReactDOMServer.renderToString(reactEl);

ReactDOM.render(reactEl, el);
if (ReactDOMFeatureFlags.useFiber) {
ReactDOM.hydrate(reactEl, el);
} else {
ReactDOM.render(reactEl, el);
}
expect(el.textContent).toBe('foobarbaz');

ReactDOM.unmountComponentAtNode(el);

reactEl = <div>{''}{''}{''}</div>;
el.innerHTML = ReactDOMServer.renderToString(reactEl);

ReactDOM.render(reactEl, el);
if (ReactDOMFeatureFlags.useFiber) {
ReactDOM.hydrate(reactEl, el);
} else {
ReactDOM.render(reactEl, el);
}
expect(el.textContent).toBe('');
});

Expand Down
Loading