Skip to content

Commit

Permalink
[added] Server-side rendering
Browse files Browse the repository at this point in the history
This commit adds two functions:

1. Router.renderRoutesToStaticMarkup(routes, path, callback)
2. Router.renderRoutesToString(routes, path, callback)

These methods are the equivalents to React's renderComponentTo*
methods, except they are designed specially to work with <Routes>
components.

This commit obsoletes remix-run#181. Many thanks to @karlmikko and others
in that thread for getting the conversation going around how this
should all work.
  • Loading branch information
mjackson committed Oct 10, 2014
1 parent 0cf3d56 commit 1b1a62b
Show file tree
Hide file tree
Showing 4 changed files with 264 additions and 0 deletions.
3 changes: 3 additions & 0 deletions modules/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
108 changes: 108 additions & 0 deletions modules/utils/ServerRendering.js
Original file line number Diff line number Diff line change
@@ -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 <Routes> 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: <NotFoundRoute> 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 <Routes> 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
};
152 changes: 152 additions & 0 deletions modules/utils/__tests__/ServerRendering-test.js
Original file line number Diff line number Diff line change
@@ -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(/^<b data-reactid="[\.a-z0-9]+">Hello mjackson!<\/b>$/);
});
});

describe('an embedded <Link> 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(/^<a href="\/home\/mjackson" class="active" data-reactid="[\.a-z0-9]+">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(/^<b data-reactid="[\.a-z0-9]+" data-react-checksum="\d+">Hello mjackson!<\/b>$/);
});
});

});
1 change: 1 addition & 0 deletions tests.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down

0 comments on commit 1b1a62b

Please sign in to comment.