Skip to content

Commit

Permalink
Add ReactDOM.hydrate() as explicit SSR hydration API (#10339)
Browse files Browse the repository at this point in the history
* Add ReactDOM.hydrate()

* Deprecate ReactDOM.render() hydration in favor of ReactDOM.hydrate()

* Downgrade the warning level to console.warn()

* Warn when hydrate() is called with empty container
  • Loading branch information
gaearon authored Aug 1, 2017
1 parent a2ed7a6 commit f8062df
Show file tree
Hide file tree
Showing 10 changed files with 770 additions and 275 deletions.
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.',
);
}
}
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://github.com/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

0 comments on commit f8062df

Please sign in to comment.