Stabilize minimal server rendering API

This is a pretty killer feature and the API is simple. I know it's another API method on React, but I think it's
the only way.
This commit is contained in:
CommitSyncScript authored and Paul O’Shannessy committed 2013-06-10 16:17:26 -07:00
1 parent 7e7579e1ba
commit 37cde3d864
6 files changed
+340 -45

No files matched your search

+2
View File
@@ -22,6 +22,7 @@ var ReactCompositeComponent = require('ReactCompositeComponent');
var ReactComponent = require('ReactComponent');
var ReactDOM = require('ReactDOM');
var ReactMount = require('ReactMount');
var ReactServerRendering = require('ReactServerRendering');
var ReactDefaultInjection = require('ReactDefaultInjection');
@@ -37,6 +38,7 @@ var React = {
constructAndRenderComponent: ReactMount.constructAndRenderComponent,
constructAndRenderComponentByID: ReactMount.constructAndRenderComponentByID,
renderComponent: ReactMount.renderComponent,
renderComponentToString: ReactServerRendering.renderComponentToString,
unmountAndReleaseReactRootNode: ReactMount.unmountAndReleaseReactRootNode,
isValidComponent: ReactComponent.isValidComponent
};
+14 -3
View File
@@ -401,18 +401,20 @@ var ReactComponent = {
*
* @param {string} rootID DOM ID of the root node.
* @param {DOMElement} container DOM element to mount into.
* @param {boolean} shouldReuseMarkup If true, do not insert markup
* @final
* @internal
* @see {ReactMount.renderComponent}
*/
mountComponentIntoNode: function(rootID, container) {
mountComponentIntoNode: function(rootID, container, shouldReuseMarkup) {
var transaction = ReactComponent.ReactReconcileTransaction.getPooled();
transaction.perform(
this._mountComponentIntoNode,
this,
rootID,
container,
transaction
transaction,
shouldReuseMarkup
);
ReactComponent.ReactReconcileTransaction.release(transaction);
},
@@ -421,14 +423,23 @@ var ReactComponent = {
* @param {string} rootID DOM ID of the root node.
* @param {DOMElement} container DOM element to mount into.
* @param {ReactReconcileTransaction} transaction
* @param {boolean} shouldReuseMarkup If true, do not insert markup
* @final
* @private
*/
_mountComponentIntoNode: function(rootID, container, transaction) {
_mountComponentIntoNode: function(
rootID,
container,
transaction,
shouldReuseMarkup) {
var renderStart = Date.now();
var markup = this.mountComponent(rootID, transaction);
ReactMount.totalInstantiationTime += (Date.now() - renderStart);
if (shouldReuseMarkup) {
return;
}
var injectionStart = Date.now();
// Asynchronously inject markup by ensuring that the container is not in
// the document when settings its `innerHTML`.
+39 -23
View File
@@ -30,6 +30,26 @@ var SEPARATOR_LENGTH = SEPARATOR.length;
*/
var MAX_TREE_DEPTH = 100;
/**
* Size of the reactRoot ID space. We generate random numbers for React root
* IDs and if there's a collision the events and DOM update system will
* get confused. If we assume 100 React components per page, and a user
* loads 1 page per minute 24/7 for 50 years, with a mount point space of
* 9,999,999 the likelihood of never having a collision is 99.997%.
*/
var GLOBAL_MOUNT_POINT_MAX = 9999999;
/**
* Creates a DOM ID prefix to use when mounting React components.
*
* @param {number} index A unique integer
* @return {string} React root ID.
* @internal
*/
function getReactRootIDString(index) {
return '.reactRoot[' + index + ']';
}
/**
* Checks if a character in the supplied ID is a separator or the end.
*
@@ -55,18 +75,6 @@ function isValidID(id) {
);
}
/**
* True if the supplied `node` is rendered by React.
*
* @param {DOMEventTarget} node DOM Element to check.
* @return {boolean} True if the DOM Element appears to be rendered by React.
* @private
*/
function isRenderedByReact(node) {
var id = getDOMNodeID(node);
return id ? id.charAt(0) === SEPARATOR : false;
}
/**
* Gets the parent ID of the supplied React DOM ID, `id`.
*
@@ -137,6 +145,24 @@ var ReactInstanceHandles = {
separator: SEPARATOR,
createReactRootID: function() {
return getReactRootIDString(
Math.ceil(Math.random() * GLOBAL_MOUNT_POINT_MAX)
);
},
/**
* True if the supplied `node` is rendered by React.
*
* @param {DOMEventTarget} node DOM Element to check.
* @return {boolean} True if the DOM Element appears to be rendered by React.
* @private
*/
isRenderedByReact: function(node) {
var id = getDOMNodeID(node);
return id ? id.charAt(0) === SEPARATOR : false;
},
/**
* Traverses up the ancestors of the supplied node to find a node that is a
* DOM representation of a React component.
@@ -148,7 +174,7 @@ var ReactInstanceHandles = {
getFirstReactDOM: function(node) {
var current = node;
while (current && current.parentNode !== current) {
if (isRenderedByReact(current)) {
if (ReactInstanceHandles.isRenderedByReact(current)) {
return current;
}
current = current.parentNode;
@@ -213,16 +239,6 @@ var ReactInstanceHandles = {
);
return longestCommonID;
},
/**
* Creates a DOM ID to use when mounting React components.
*
* @param {number} mountPointCount The count of React renders so far.
* @return {string} React root ID.
* @internal
*/
getReactRootID: function(mountPointCount) {
return '.reactRoot[' + mountPointCount + ']';
},
/**
* Gets the DOM ID of the React component that is the root of the tree that
+79 -19
View File
@@ -24,20 +24,27 @@ var ReactEventTopLevelCallback = require('ReactEventTopLevelCallback');
var $ = require('$');
var globalMountPointCounter = 0;
/** Mapping from reactRoot DOM ID to React component instance. */
var instanceByReactRootID = {};
/** Mapping from reactRoot DOM ID to `container` nodes. */
var containersByReactRootID = {};
/**
* @param {DOMElement} container DOM element that may contain a React component
* @return {?DOMElement} DOM element that may have the reactRoot ID, or null.
*/
function getReactRootElementInContainer(container) {
return container.firstChild;
}
/**
* @param {DOMElement} container DOM element that may contain a React component.
* @return {?string} A "reactRoot" ID, if a React component is rendered.
*/
function getReactRootID(container) {
return container.firstChild && container.firstChild.id;
var rootElement = getReactRootElementInContainer(container);
return rootElement && rootElement.id;
}
/**
@@ -92,6 +99,54 @@ var ReactMount = {
);
},
/**
* Take a component that's already mounted into the DOM and replace its props
* @param {ReactComponent} prevComponent component instance already in the DOM
* @param {ReactComponent} nextComponent component instance to render
* @param {DOMElement} container container to render into
*/
_updateRootComponent: function(prevComponent, nextComponent, container) {
var nextProps = nextComponent.props;
ReactMount.scrollMonitor(container, function() {
prevComponent.replaceProps(nextProps);
});
return prevComponent;
},
/**
* Register a component into the instance map and start the events system.
* @param {ReactComponent} nextComponent component instance to render
* @param {DOMElement} container container to render into
* @return {string} reactRoot ID prefix
*/
_registerComponent: function(nextComponent, container) {
ReactMount.prepareTopLevelEvents(ReactEventTopLevelCallback);
var reactRootID = ReactMount.registerContainer(container);
instanceByReactRootID[reactRootID] = nextComponent;
return reactRootID;
},
/**
* Render a new component into the DOM.
* @param {ReactComponent} nextComponent component instance to render
* @param {DOMElement} container container to render into
* @param {boolean} shouldReuseMarkup if we should skip the markup insertion
* @return {ReactComponent} nextComponent
*/
_renderNewRootComponent: function(
nextComponent,
container,
shouldReuseMarkup) {
var reactRootID = ReactMount._registerComponent(nextComponent, container);
nextComponent.mountComponentIntoNode(
reactRootID,
container,
shouldReuseMarkup
);
return nextComponent;
},
/**
* Renders a React component into the DOM in the supplied `container`.
*
@@ -104,25 +159,32 @@ var ReactMount = {
* @return {ReactComponent} Component instance rendered in `container`.
*/
renderComponent: function(nextComponent, container) {
var prevComponent = instanceByReactRootID[getReactRootID(container)];
if (prevComponent) {
if (prevComponent.constructor === nextComponent.constructor) {
var nextProps = nextComponent.props;
ReactMount.scrollMonitor(container, function() {
prevComponent.replaceProps(nextProps);
});
return prevComponent;
var registeredComponent = instanceByReactRootID[getReactRootID(container)];
if (registeredComponent) {
if (registeredComponent.constructor === nextComponent.constructor) {
return ReactMount._updateRootComponent(
registeredComponent,
nextComponent,
container
);
} else {
ReactMount.unmountAndReleaseReactRootNode(container);
}
}
ReactMount.prepareTopLevelEvents(ReactEventTopLevelCallback);
var reactRootElement = getReactRootElementInContainer(container);
var containerHasReactMarkup =
reactRootElement &&
ReactInstanceHandles.isRenderedByReact(reactRootElement);
var reactRootID = ReactMount.registerContainer(container);
instanceByReactRootID[reactRootID] = nextComponent;
nextComponent.mountComponentIntoNode(reactRootID, container);
return nextComponent;
var shouldReuseMarkup = containerHasReactMarkup && !registeredComponent;
return ReactMount._renderNewRootComponent(
nextComponent,
container,
shouldReuseMarkup
);
},
/**
@@ -167,9 +229,7 @@ var ReactMount = {
}
if (!reactRootID) {
// No valid "reactRoot" ID found, create one.
reactRootID = ReactInstanceHandles.getReactRootID(
globalMountPointCounter++
);
reactRootID = ReactInstanceHandles.createReactRootID();
}
containersByReactRootID[reactRootID] = container;
return reactRootID;
+31
View File
@@ -0,0 +1,31 @@
/**
* @typechecks
* @providesModule ReactServerRendering
*/
"use strict";
var ReactReconcileTransaction = require('ReactReconcileTransaction');
var ReactInstanceHandles = require('ReactInstanceHandles');
/**
* @param {object} component
* @param {function} callback
*/
function renderComponentToString(component, callback) {
// We use a callback API to keep the API async in case in the future we ever
// need it, but in reality this is a synchronous operation.
var id = ReactInstanceHandles.createReactRootID();
var transaction = ReactReconcileTransaction.getPooled();
transaction.reinitializeTransaction();
try {
transaction.perform(function() {
callback(component.mountComponent(id, transaction));
}, null);
} finally {
ReactReconcileTransaction.release(transaction);
}
}
module.exports = {
renderComponentToString: renderComponentToString
};
@@ -0,0 +1,175 @@
/**
* @jsx React.DOM
* @emails react-core
*/
"use strict";
require('mock-modules')
.dontMock('ExecutionEnvironment')
.dontMock('React')
.dontMock('ReactServerRendering')
.dontMock('ReactTestUtils');
var React;
var ReactTestUtils;
var ReactServerRendering;
var ExecutionEnvironment;
describe('ReactServerRendering', function() {
beforeEach(function() {
require('mock-modules').dumpCache();
React = require('React');
ReactTestUtils = require('ReactTestUtils');
ExecutionEnvironment = require('ExecutionEnvironment');
ExecutionEnvironment.canUseDOM = false;
ReactServerRendering = require('ReactServerRendering');
});
it('should generate simple markup', function() {
var response;
ReactServerRendering.renderComponentToString(
<span>hello world</span>,
function(response_) {
response = response_;
}
);
expect(response).toMatch(
'<span id="[^"]+">hello world</span>'
);
});
it('should render composite components', function() {
var Parent = React.createClass({
render: function() {
return <div><Child name="child" /></div>;
}
});
var Child = React.createClass({
render: function() {
return <span>My name is {this.props.name}</span>;
}
});
var response;
ReactServerRendering.renderComponentToString(
<Parent />,
function(response_) {
response = response_;
}
);
expect(response).toMatch(
'<div id="[^"]+"><span id="[^"]+"><span id="[^"]+">My name is </span>' +
'<span id="[^"]+">child</span></span></div>'
);
});
it('should only execute certain lifecycle methods', function() {
var lifecycle = [];
var TestComponent = React.createClass({
componentWillMount: function() {
lifecycle.push('componentWillMount');
},
componentDidMount: function() {
lifecycle.push('componentDidMount');
},
getInitialState: function() {
lifecycle.push('getInitialState');
return {name: 'TestComponent'};
},
render: function() {
lifecycle.push('render');
return <span>Component name: {this.state.name}</span>;
},
componentWillUpdate: function() {
lifecycle.push('componentWillUpdate');
},
componentDidUpdate: function() {
lifecycle.push('componentDidUpdate');
},
shouldComponentUpdate: function() {
lifecycle.push('shouldComponentUpdate');
},
componentWillReceiveProps: function() {
lifecycle.push('componentWillReceiveProps');
},
componentWillUnmount: function() {
lifecycle.push('componentWillUnmount');
}
});
var response;
ReactServerRendering.renderComponentToString(
<TestComponent />,
function (_response) {
response = _response;
}
);
expect(response).toMatch(
'<span id="[^"]+"><span id="[^"]+">Component name: </span>' +
'<span id="[^"]+">TestComponent</span></span>'
);
expect(lifecycle).toEqual(
['getInitialState', 'componentWillMount', 'render']
);
});
it('should have the correct mounting behavior', function() {
// This test is testing client-side behavior.
ExecutionEnvironment.canUseDOM = true;
var mountCount = 0;
var numClicks = 0;
var TestComponent = React.createClass({
componentWillMount: function() {
mountCount++;
},
click: function() {
numClicks++;
},
render: function() {
return (
<span ref="span" onClick={this.click}>Name: {this.props.name}</span>
);
},
});
var element = document.createElement('div');
React.renderComponent(<TestComponent />, element);
var lastMarkup = element.innerHTML;
// Exercise the update path. Markup should not change,
// but some lifecycle methods should be run again.
React.renderComponent(<TestComponent name="x" />, element);
expect(mountCount).toEqual(1);
// Unmount and remount. We should get another mount event and
// we should get different markup, as the IDs are unique each time.
React.unmountAndReleaseReactRootNode(element);
expect(element.innerHTML).toEqual('');
React.renderComponent(<TestComponent name="x" />, element);
expect(mountCount).toEqual(2);
expect(element.innerHTML).not.toEqual(lastMarkup);
// Now kill the node and render it on top of the old markup, as if
// we used server rendering. We should mount again, but the markup should be
// unchanged.
lastMarkup = element.innerHTML;
React.unmountAndReleaseReactRootNode(element);
expect(element.innerHTML).toEqual('');
element.innerHTML = lastMarkup;
// NOTE: we pass a different name here. This is to ensure that the markup
// being generated is not replaced.
var instance = React.renderComponent(<TestComponent name="y" />, element);
expect(mountCount).toEqual(3);
expect(element.innerHTML).toEqual(lastMarkup);
// Ensure the events system works
expect(numClicks).toEqual(0);
ReactTestUtils.Simulate.click(instance.refs.span.getDOMNode());
expect(numClicks).toEqual(1);
});
});