diff --git a/src/core/React.js b/src/core/React.js index c8f5346330..46a4c51b46 100644 --- a/src/core/React.js +++ b/src/core/React.js @@ -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 }; diff --git a/src/core/ReactComponent.js b/src/core/ReactComponent.js index 4b0fad589b..fe704dfbbf 100644 --- a/src/core/ReactComponent.js +++ b/src/core/ReactComponent.js @@ -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`. diff --git a/src/core/ReactInstanceHandles.js b/src/core/ReactInstanceHandles.js index 7528cb7fb0..fbe56b1084 100644 --- a/src/core/ReactInstanceHandles.js +++ b/src/core/ReactInstanceHandles.js @@ -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 diff --git a/src/core/ReactMount.js b/src/core/ReactMount.js index 65fb3eb8c2..f26239eae6 100644 --- a/src/core/ReactMount.js +++ b/src/core/ReactMount.js @@ -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; diff --git a/src/environment/ReactServerRendering.js b/src/environment/ReactServerRendering.js new file mode 100644 index 0000000000..cbecdad4d2 --- /dev/null +++ b/src/environment/ReactServerRendering.js @@ -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 +}; diff --git a/src/environment/__tests__/ReactServerRendering-test.js b/src/environment/__tests__/ReactServerRendering-test.js new file mode 100644 index 0000000000..d16ad8f5dd --- /dev/null +++ b/src/environment/__tests__/ReactServerRendering-test.js @@ -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( + hello world, + function(response_) { + response = response_; + } + ); + expect(response).toMatch( + 'hello world' + ); + }); + + it('should render composite components', function() { + var Parent = React.createClass({ + render: function() { + return
; + } + }); + var Child = React.createClass({ + render: function() { + return My name is {this.props.name}; + } + }); + var response; + ReactServerRendering.renderComponentToString( + , + function(response_) { + response = response_; + } + ); + expect(response).toMatch( + '
My name is ' + + 'child
' + ); + }); + + 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 Component name: {this.state.name}; + }, + 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( + , + function (_response) { + response = _response; + } + ); + + expect(response).toMatch( + 'Component name: ' + + 'TestComponent' + ); + 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 ( + Name: {this.props.name} + ); + }, + }); + + var element = document.createElement('div'); + React.renderComponent(, element); + + var lastMarkup = element.innerHTML; + + // Exercise the update path. Markup should not change, + // but some lifecycle methods should be run again. + React.renderComponent(, 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(, 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(, 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); + }); +});