Skip to content

Commit 1b1a62b

Browse files
committed
[added] Server-side rendering
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 #181. Many thanks to @karlmikko and others in that thread for getting the conversation going around how this should all work.
1 parent 0cf3d56 commit 1b1a62b

File tree

4 files changed

+264
-0
lines changed

4 files changed

+264
-0
lines changed

modules/index.js

+3
Original file line numberDiff line numberDiff line change
@@ -8,3 +8,6 @@ exports.Routes = require('./components/Routes');
88
exports.ActiveState = require('./mixins/ActiveState');
99
exports.CurrentPath = require('./mixins/CurrentPath');
1010
exports.Navigation = require('./mixins/Navigation');
11+
12+
exports.renderRoutesToString = require('./utils/ServerRendering').renderRoutesToString;
13+
exports.renderRoutesToStaticMarkup = require('./utils/ServerRendering').renderRoutesToStaticMarkup;

modules/utils/ServerRendering.js

+108
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
var ReactDescriptor = require('react/lib/ReactDescriptor');
2+
var ReactInstanceHandles = require('react/lib/ReactInstanceHandles');
3+
var ReactMarkupChecksum = require('react/lib/ReactMarkupChecksum');
4+
var ReactServerRenderingTransaction = require('react/lib/ReactServerRenderingTransaction');
5+
6+
var cloneWithProps = require('react/lib/cloneWithProps');
7+
var copyProperties = require('react/lib/copyProperties');
8+
var instantiateReactComponent = require('react/lib/instantiateReactComponent');
9+
var invariant = require('react/lib/invariant');
10+
11+
function cloneRoutesForServerRendering(routes) {
12+
return cloneWithProps(routes, {
13+
location: 'none',
14+
scrollBehavior: 'none'
15+
});
16+
}
17+
18+
function mergeStateIntoInitialProps(state, props) {
19+
copyProperties(props, {
20+
initialPath: state.path,
21+
initialMatches: state.matches,
22+
initialActiveRoutes: state.activeRoutes,
23+
initialActiveParams: state.activeParams,
24+
initialActiveQuery: state.activeQuery
25+
});
26+
}
27+
28+
/**
29+
* Renders a <Routes> component to a string of HTML at the given URL
30+
* path and calls callback(error, abortReason, html) when finished.
31+
*
32+
* If there was an error during the transition, it is passed to the
33+
* callback. Otherwise, if the transition was aborted for some reason,
34+
* it is given in the abortReason argument (with the exception of
35+
* internal redirects which are transparently handled for you).
36+
*
37+
* TODO: <NotFoundRoute> should be handled specially so servers know
38+
* to use a 404 status code.
39+
*/
40+
function renderRoutesToString(routes, path, callback) {
41+
invariant(
42+
ReactDescriptor.isValidDescriptor(routes),
43+
'You must pass a valid ReactComponent to renderRoutesToString'
44+
);
45+
46+
var component = instantiateReactComponent(
47+
cloneRoutesForServerRendering(routes)
48+
);
49+
50+
component.dispatch(path, function (error, abortReason, nextState) {
51+
if (error || abortReason)
52+
return callback(error, abortReason);
53+
54+
mergeStateIntoInitialProps(nextState, component.props);
55+
56+
var transaction;
57+
try {
58+
var id = ReactInstanceHandles.createReactRootID();
59+
transaction = ReactServerRenderingTransaction.getPooled(false);
60+
61+
transaction.perform(function() {
62+
var markup = component.mountComponent(id, transaction, 0);
63+
callback(null, null, ReactMarkupChecksum.addChecksumToMarkup(markup));
64+
}, null);
65+
} finally {
66+
ReactServerRenderingTransaction.release(transaction);
67+
}
68+
});
69+
}
70+
71+
/**
72+
* Renders a <Routes> component to static markup at the given URL
73+
* path and calls callback(error, abortReason, markup) when finished.
74+
*/
75+
function renderRoutesToStaticMarkup(routes, path, callback) {
76+
invariant(
77+
ReactDescriptor.isValidDescriptor(routes),
78+
'You must pass a valid ReactComponent to renderRoutesToStaticMarkup'
79+
);
80+
81+
var component = instantiateReactComponent(
82+
cloneRoutesForServerRendering(routes)
83+
);
84+
85+
component.dispatch(path, function (error, abortReason, nextState) {
86+
if (error || abortReason)
87+
return callback(error, abortReason);
88+
89+
mergeStateIntoInitialProps(nextState, component.props);
90+
91+
var transaction;
92+
try {
93+
var id = ReactInstanceHandles.createReactRootID();
94+
transaction = ReactServerRenderingTransaction.getPooled(false);
95+
96+
transaction.perform(function() {
97+
callback(null, null, component.mountComponent(id, transaction, 0));
98+
}, null);
99+
} finally {
100+
ReactServerRenderingTransaction.release(transaction);
101+
}
102+
});
103+
}
104+
105+
module.exports = {
106+
renderRoutesToString: renderRoutesToString,
107+
renderRoutesToStaticMarkup: renderRoutesToStaticMarkup
108+
};
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
var assert = require('assert');
2+
var expect = require('expect');
3+
var React = require('react');
4+
var Link = require('../../components/Link');
5+
var Routes = require('../../components/Routes');
6+
var Route = require('../../components/Route');
7+
var ServerRendering = require('../ServerRendering');
8+
9+
describe('ServerRendering', function () {
10+
11+
describe('renderRoutesToMarkup', function () {
12+
describe('a very simple case', function () {
13+
var Home = React.createClass({
14+
render: function () {
15+
return React.DOM.b(null, 'Hello ' + this.props.params.username + '!');
16+
}
17+
});
18+
19+
var output;
20+
beforeEach(function (done) {
21+
var routes = Routes(null,
22+
Route({ path: '/home/:username', handler: Home })
23+
);
24+
25+
ServerRendering.renderRoutesToStaticMarkup(routes, '/home/mjackson', function (error, abortReason, markup) {
26+
assert(error == null);
27+
assert(abortReason == null);
28+
output = markup;
29+
done();
30+
});
31+
});
32+
33+
it('has the correct output', function () {
34+
expect(output).toMatch(/^<b data-reactid="[\.a-z0-9]+">Hello mjackson!<\/b>$/);
35+
});
36+
});
37+
38+
describe('an embedded <Link> to the current route', function () {
39+
var Home = React.createClass({
40+
render: function () {
41+
return Link({ to: 'home', params: { username: 'mjackson' } }, 'Hello ' + this.props.params.username + '!');
42+
}
43+
});
44+
45+
var output;
46+
beforeEach(function (done) {
47+
var routes = Routes(null,
48+
Route({ name: 'home', path: '/home/:username', handler: Home })
49+
);
50+
51+
ServerRendering.renderRoutesToStaticMarkup(routes, '/home/mjackson', function (error, abortReason, markup) {
52+
assert(error == null);
53+
assert(abortReason == null);
54+
output = markup;
55+
done();
56+
});
57+
});
58+
59+
it('has the correct output', function () {
60+
expect(output).toMatch(/^<a href="\/home\/mjackson" class="active" data-reactid="[\.a-z0-9]+">Hello mjackson!<\/a>$/);
61+
});
62+
});
63+
64+
describe('when the transition is aborted', function () {
65+
var Home = React.createClass({
66+
statics: {
67+
willTransitionTo: function (transition) {
68+
transition.abort({ status: 403 });
69+
}
70+
},
71+
render: function () {
72+
return null;
73+
}
74+
});
75+
76+
var reason;
77+
beforeEach(function (done) {
78+
var routes = Routes(null,
79+
Route({ name: 'home', path: '/home/:username', handler: Home })
80+
);
81+
82+
ServerRendering.renderRoutesToStaticMarkup(routes, '/home/mjackson', function (error, abortReason) {
83+
assert(error == null);
84+
reason = abortReason;
85+
done();
86+
});
87+
});
88+
89+
it('gives the reason in the callback', function () {
90+
assert(reason);
91+
expect(reason.status).toEqual(403);
92+
});
93+
});
94+
95+
describe('when there is an error performing the transition', function () {
96+
var Home = React.createClass({
97+
statics: {
98+
willTransitionTo: function (transition) {
99+
throw 'boom!';
100+
}
101+
},
102+
render: function () {
103+
return null;
104+
}
105+
});
106+
107+
var error;
108+
beforeEach(function (done) {
109+
var routes = Routes(null,
110+
Route({ name: 'home', path: '/home/:username', handler: Home })
111+
);
112+
113+
ServerRendering.renderRoutesToStaticMarkup(routes, '/home/mjackson', function (e, abortReason) {
114+
assert(abortReason == null);
115+
error = e;
116+
done();
117+
});
118+
});
119+
120+
it('gives the reason in the callback', function () {
121+
expect(error).toEqual('boom!');
122+
});
123+
});
124+
});
125+
126+
describe('renderRoutesToString', function () {
127+
var Home = React.createClass({
128+
render: function () {
129+
return React.DOM.b(null, 'Hello ' + this.props.params.username + '!');
130+
}
131+
});
132+
133+
var output;
134+
beforeEach(function (done) {
135+
var routes = Routes(null,
136+
Route({ path: '/home/:username', handler: Home })
137+
);
138+
139+
ServerRendering.renderRoutesToString(routes, '/home/mjackson', function (error, abortReason, string) {
140+
assert(error == null);
141+
assert(abortReason == null);
142+
output = string;
143+
done();
144+
});
145+
});
146+
147+
it('has the correct output', function () {
148+
expect(output).toMatch(/^<b data-reactid="[\.a-z0-9]+" data-react-checksum="\d+">Hello mjackson!<\/b>$/);
149+
});
150+
});
151+
152+
});

tests.js

+1
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ require('./modules/mixins/__tests__/ScrollContext-test');
1313
require('./modules/stores/__tests__/PathStore-test');
1414

1515
require('./modules/utils/__tests__/Path-test');
16+
require('./modules/utils/__tests__/ServerRendering-test');
1617

1718

1819
var PathStore = require('./modules/stores/PathStore');

0 commit comments

Comments
 (0)