diff --git a/modules/index.js b/modules/index.js index 62f052329a..84e7079692 100644 --- a/modules/index.js +++ b/modules/index.js @@ -8,3 +8,6 @@ exports.Routes = require('./components/Routes'); exports.ActiveState = require('./mixins/ActiveState'); exports.CurrentPath = require('./mixins/CurrentPath'); exports.Navigation = require('./mixins/Navigation'); + +exports.renderRoutesToString = require('./utils/ServerRendering').renderRoutesToString; +exports.renderRoutesToStaticMarkup = require('./utils/ServerRendering').renderRoutesToStaticMarkup; diff --git a/modules/utils/ServerRendering.js b/modules/utils/ServerRendering.js new file mode 100644 index 0000000000..fbe110b1c4 --- /dev/null +++ b/modules/utils/ServerRendering.js @@ -0,0 +1,108 @@ +var ReactDescriptor = require('react/lib/ReactDescriptor'); +var ReactInstanceHandles = require('react/lib/ReactInstanceHandles'); +var ReactMarkupChecksum = require('react/lib/ReactMarkupChecksum'); +var ReactServerRenderingTransaction = require('react/lib/ReactServerRenderingTransaction'); + +var cloneWithProps = require('react/lib/cloneWithProps'); +var copyProperties = require('react/lib/copyProperties'); +var instantiateReactComponent = require('react/lib/instantiateReactComponent'); +var invariant = require('react/lib/invariant'); + +function cloneRoutesForServerRendering(routes) { + return cloneWithProps(routes, { + location: 'none', + scrollBehavior: 'none' + }); +} + +function mergeStateIntoInitialProps(state, props) { + copyProperties(props, { + initialPath: state.path, + initialMatches: state.matches, + initialActiveRoutes: state.activeRoutes, + initialActiveParams: state.activeParams, + initialActiveQuery: state.activeQuery + }); +} + +/** + * Renders a component to a string of HTML at the given URL + * path and calls callback(error, abortReason, html) when finished. + * + * If there was an error during the transition, it is passed to the + * callback. Otherwise, if the transition was aborted for some reason, + * it is given in the abortReason argument (with the exception of + * internal redirects which are transparently handled for you). + * + * TODO: should be handled specially so servers know + * to use a 404 status code. + */ +function renderRoutesToString(routes, path, callback) { + invariant( + ReactDescriptor.isValidDescriptor(routes), + 'You must pass a valid ReactComponent to renderRoutesToString' + ); + + var component = instantiateReactComponent( + cloneRoutesForServerRendering(routes) + ); + + component.dispatch(path, function (error, abortReason, nextState) { + if (error || abortReason) + return callback(error, abortReason); + + mergeStateIntoInitialProps(nextState, component.props); + + var transaction; + try { + var id = ReactInstanceHandles.createReactRootID(); + transaction = ReactServerRenderingTransaction.getPooled(false); + + transaction.perform(function() { + var markup = component.mountComponent(id, transaction, 0); + callback(null, null, ReactMarkupChecksum.addChecksumToMarkup(markup)); + }, null); + } finally { + ReactServerRenderingTransaction.release(transaction); + } + }); +} + +/** + * Renders a component to static markup at the given URL + * path and calls callback(error, abortReason, markup) when finished. + */ +function renderRoutesToStaticMarkup(routes, path, callback) { + invariant( + ReactDescriptor.isValidDescriptor(routes), + 'You must pass a valid ReactComponent to renderRoutesToStaticMarkup' + ); + + var component = instantiateReactComponent( + cloneRoutesForServerRendering(routes) + ); + + component.dispatch(path, function (error, abortReason, nextState) { + if (error || abortReason) + return callback(error, abortReason); + + mergeStateIntoInitialProps(nextState, component.props); + + var transaction; + try { + var id = ReactInstanceHandles.createReactRootID(); + transaction = ReactServerRenderingTransaction.getPooled(false); + + transaction.perform(function() { + callback(null, null, component.mountComponent(id, transaction, 0)); + }, null); + } finally { + ReactServerRenderingTransaction.release(transaction); + } + }); +} + +module.exports = { + renderRoutesToString: renderRoutesToString, + renderRoutesToStaticMarkup: renderRoutesToStaticMarkup +}; diff --git a/modules/utils/__tests__/ServerRendering-test.js b/modules/utils/__tests__/ServerRendering-test.js new file mode 100644 index 0000000000..d3ed0709c4 --- /dev/null +++ b/modules/utils/__tests__/ServerRendering-test.js @@ -0,0 +1,152 @@ +var assert = require('assert'); +var expect = require('expect'); +var React = require('react'); +var Link = require('../../components/Link'); +var Routes = require('../../components/Routes'); +var Route = require('../../components/Route'); +var ServerRendering = require('../ServerRendering'); + +describe('ServerRendering', function () { + + describe('renderRoutesToMarkup', function () { + describe('a very simple case', function () { + var Home = React.createClass({ + render: function () { + return React.DOM.b(null, 'Hello ' + this.props.params.username + '!'); + } + }); + + var output; + beforeEach(function (done) { + var routes = Routes(null, + Route({ path: '/home/:username', handler: Home }) + ); + + ServerRendering.renderRoutesToStaticMarkup(routes, '/home/mjackson', function (error, abortReason, markup) { + assert(error == null); + assert(abortReason == null); + output = markup; + done(); + }); + }); + + it('has the correct output', function () { + expect(output).toMatch(/^Hello mjackson!<\/b>$/); + }); + }); + + describe('an embedded to the current route', function () { + var Home = React.createClass({ + render: function () { + return Link({ to: 'home', params: { username: 'mjackson' } }, 'Hello ' + this.props.params.username + '!'); + } + }); + + var output; + beforeEach(function (done) { + var routes = Routes(null, + Route({ name: 'home', path: '/home/:username', handler: Home }) + ); + + ServerRendering.renderRoutesToStaticMarkup(routes, '/home/mjackson', function (error, abortReason, markup) { + assert(error == null); + assert(abortReason == null); + output = markup; + done(); + }); + }); + + it('has the correct output', function () { + expect(output).toMatch(/^Hello mjackson!<\/a>$/); + }); + }); + + describe('when the transition is aborted', function () { + var Home = React.createClass({ + statics: { + willTransitionTo: function (transition) { + transition.abort({ status: 403 }); + } + }, + render: function () { + return null; + } + }); + + var reason; + beforeEach(function (done) { + var routes = Routes(null, + Route({ name: 'home', path: '/home/:username', handler: Home }) + ); + + ServerRendering.renderRoutesToStaticMarkup(routes, '/home/mjackson', function (error, abortReason) { + assert(error == null); + reason = abortReason; + done(); + }); + }); + + it('gives the reason in the callback', function () { + assert(reason); + expect(reason.status).toEqual(403); + }); + }); + + describe('when there is an error performing the transition', function () { + var Home = React.createClass({ + statics: { + willTransitionTo: function (transition) { + throw 'boom!'; + } + }, + render: function () { + return null; + } + }); + + var error; + beforeEach(function (done) { + var routes = Routes(null, + Route({ name: 'home', path: '/home/:username', handler: Home }) + ); + + ServerRendering.renderRoutesToStaticMarkup(routes, '/home/mjackson', function (e, abortReason) { + assert(abortReason == null); + error = e; + done(); + }); + }); + + it('gives the reason in the callback', function () { + expect(error).toEqual('boom!'); + }); + }); + }); + + describe('renderRoutesToString', function () { + var Home = React.createClass({ + render: function () { + return React.DOM.b(null, 'Hello ' + this.props.params.username + '!'); + } + }); + + var output; + beforeEach(function (done) { + var routes = Routes(null, + Route({ path: '/home/:username', handler: Home }) + ); + + ServerRendering.renderRoutesToString(routes, '/home/mjackson', function (error, abortReason, string) { + assert(error == null); + assert(abortReason == null); + output = string; + done(); + }); + }); + + it('has the correct output', function () { + expect(output).toMatch(/^Hello mjackson!<\/b>$/); + }); + }); + +}); diff --git a/tests.js b/tests.js index f6073af225..a482e4c15c 100644 --- a/tests.js +++ b/tests.js @@ -13,6 +13,7 @@ require('./modules/mixins/__tests__/ScrollContext-test'); require('./modules/stores/__tests__/PathStore-test'); require('./modules/utils/__tests__/Path-test'); +require('./modules/utils/__tests__/ServerRendering-test'); var PathStore = require('./modules/stores/PathStore');