diff --git a/.jshintrc b/.jshintrc index 48ddab3..b045d22 100644 --- a/.jshintrc +++ b/.jshintrc @@ -4,6 +4,7 @@ "asi": true, "expr": true, "globalstrict": true, + "eqnull": true, "globals": { "window": false, "Buffer": false, diff --git a/Makefile b/Makefile index 146e73a..11a71e4 100644 --- a/Makefile +++ b/Makefile @@ -12,7 +12,7 @@ lint: test: test-unit test-server test-unit: - @mocha -R spec -b tests/matchRoutes.js + @mocha -R spec -b tests/matchRoutes.js tests/getContentMeta.js test-server: @mocha -R spec -b tests/server.js diff --git a/lib/Router.js b/lib/Router.js index be16599..0debcce 100644 --- a/lib/Router.js +++ b/lib/Router.js @@ -1,7 +1,6 @@ "use strict"; var React = require('react'); -var merge = require('react/lib/merge'); var Preloaded = require('react-async/lib/Preloaded'); var RoutingEnvironmentMixin = require('./RoutingEnvironmentMixin'); var matchRoutes = require('./matchRoutes'); diff --git a/lib/RoutingEnvironmentMixin.js b/lib/RoutingEnvironmentMixin.js index 86e9eb6..aa92e24 100644 --- a/lib/RoutingEnvironmentMixin.js +++ b/lib/RoutingEnvironmentMixin.js @@ -3,7 +3,6 @@ var React = require('react'); var merge = require('react/lib/merge'); var Environment = require('./environment'); -var invariant = require('react/lib/invariant'); /** * Mixin which makes router bound to an environment. This is the glue that binds @@ -28,7 +27,8 @@ var RoutingEnvironmentMixin = { hash: React.PropTypes.bool, onBeforeNavigation: React.PropTypes.func, - onNavigation: React.PropTypes.func + onNavigation: React.PropTypes.func, + onDispatch: React.PropTypes.func }, childContextTypes: { @@ -166,6 +166,17 @@ var RoutingEnvironmentMixin = { return join(this.getPrefix(), href); }, + componentWillMount: function() { + if (this.props.onDispatch) { + var path = this.getPath(); + this.props.onDispatch(path, { + initial: true, + isPopState: false, + match: this.match(path) + }); + } + }, + componentDidMount: function() { if (this.isControlled()) { return; @@ -226,9 +237,14 @@ EnvironmentListener.prototype.onBeforeNavigation = function(path, navigation) { } EnvironmentListener.prototype.onNavigation = function(path, navigation) { + var result; if (this.router.props.onNavigation) { - return this.router.props.onNavigation(path, navigation); + result = this.router.props.onNavigation(path, navigation); + } + if (this.router.props.onDispatch) { + this.router.props.onDispatch(path, navigation); } + return result; } module.exports = RoutingEnvironmentMixin; diff --git a/lib/contrib/connect-middleware.js b/lib/contrib/connect-middleware.js new file mode 100644 index 0000000..8b38bf9 --- /dev/null +++ b/lib/contrib/connect-middleware.js @@ -0,0 +1,86 @@ +"use strict"; + +var parseUrl = require('url').parse; +var React = require('react'); +var ReactAsync = require('react-async'); +var merge = require('react/lib/merge'); +var invariant = require('react/lib/invariant'); +var getContentMeta = require('../getContentMeta'); + + +/** + * A connect middleware for server-side rendering of React Router components. + * Typically, these are instances of Router, but the only requirements are that + * the component accepts "path" on "onDispatch" props. + */ +function reactRouter(Router, options) { + invariant( + React.isValidClass(Router), + 'Argument must be a React component class but got: %s', Router + ); + var meta = getContentMeta(options && options.doctype, + options && options.contentType); + var props = options && options.props || {}; + return function(req, res, next) { + var pathname = parseUrl(req.url).pathname; + if (pathname == null) { + next(new Error('Invalid URL: ' + req.url)); + return; + } + + // This callback is meant to be called multiple times with whatever + // information is available. It will aggregate the information and send a + // response or emit an error as soon as is possible. + var memo = {}; + var callback = function(err, statusCode, markup) { + if (memo.complete) { + return; // Don't take action more than once. + } + if (err != null) { + // If we errored, we're done. + memo.complete = true; + next(err); + return; + } + + // Remember the status code and markup (since we may not get them at the + // same time). + if (statusCode != null) { + memo.statusCode = statusCode; + } + if (markup != null) { + memo.markup = markup; + } + + // Wait for the render to complete and onDispatch to be invoked. + if (memo.markup == null || memo.statusCode == null) { + return; + } + memo.complete = true; + res.statusCode = memo.statusCode; + res.setHeader('Content-Type', meta.contentType); + res.end('' + meta.doctype + memo.markup); + }; + + try { + var app = Router(merge(props, { + path: pathname, + onDispatch: function(path, navigation) { + var statusCode = (navigation && navigation.match && + navigation.match.isNotFound ? 404 : 200); + callback(null, statusCode); + } + })); + ReactAsync.renderComponentToStringWithAsyncState( + app, + function(err, markup) { + callback(err, null, markup); + } + ); + } catch (err) { + next(err); + } + }; +} + +module.exports = reactRouter; diff --git a/lib/getContentMeta.js b/lib/getContentMeta.js new file mode 100644 index 0000000..92e3687 --- /dev/null +++ b/lib/getContentMeta.js @@ -0,0 +1,68 @@ +"use strict"; + +var doctypes = { + 'text/html': { + '': ['default', '5', 'html', 'html5'], + '': ['4', 'html4'] + }, + 'application/xhtml+xml': { + '': ['strict'], + '': ['transitional'], + '': ['frameset'], + '': ['1.1'], + '': ['basic'], + '': ['mobile'] + }, + 'image/svg+xml': { + '': ['svg'] // https://jwatt.org/svg/authoring/#doctype-declaration + } +}; + + +function getContentMeta(doctype, contentType) { + var ct, dt, doctypesToShortcuts, shortcuts; + + if (doctype == null && contentType == null) { + doctype = 'default'; + } + + // Look up the content type + if (doctype) { + for (ct in doctypes) { + doctypesToShortcuts = doctypes[ct]; + if (!doctypesToShortcuts) { continue; } + + for (dt in doctypesToShortcuts) { + shortcuts = doctypesToShortcuts[dt]; + if (!shortcuts) { continue; } + if (String(doctype).toLowerCase() === dt.toLowerCase() || shortcuts.indexOf(String(doctype).toLowerCase()) > -1) { + return {doctype: dt, contentType: contentType == null ? ct: contentType}; + } + } + } + } + + if (contentType == null) { + throw new Error('Could not find matching content type for doctype: ' + doctype); + } + + // Look up the doctype. + for (ct in doctypes) { + if (contentType.toLowerCase() === ct.toLowerCase()) { + doctypesToShortcuts = doctypes[ct]; + if (!doctypesToShortcuts) { continue; } + for (dt in doctypesToShortcuts) { + return {doctype: dt, contentType: contentType}; + } + } + } + + if (doctype == null) { + throw new Error('Could not find matching doctype type for content type: ' + contentType); + } + + return {doctype: doctype, contentType: contentType}; +} + + +module.exports = getContentMeta; diff --git a/tests/browser.js b/tests/browser.js index 8e6b37a..b49338e 100644 --- a/tests/browser.js +++ b/tests/browser.js @@ -74,7 +74,8 @@ describe('Routing', function() { Router.Locations({ ref: 'router', className: 'App', onNavigation: this.props.navigationHandler, - onBeforeNavigation: this.props.beforeNavigationHandler + onBeforeNavigation: this.props.beforeNavigationHandler, + onDispatch: this.props.dispatchHandler }, Router.Location({ path: '/__zuul', @@ -184,7 +185,7 @@ describe('Routing', function() { }); describe('Navigation lifecycle callbacks', function () { - it('calls onBeforeNaviation and onNavigation', function(done) { + it('calls onBeforeNaviation, onNavigation, and onDispatch', function(done) { assertRendered('mainpage'); var called = []; app.setProps({ @@ -193,12 +194,16 @@ describe('Routing', function() { }, navigationHandler: function () { called.push('onNavigation'); + }, + dispatchHandler: function (path) { + called.push(path) } }); router.navigate('/__zuul/hello', function () { - assert.equal(called.length, 2); + assert.equal(called.length, 3); assert.equal(called[0], '/__zuul/hello'); assert.equal(called[1], 'onNavigation'); + assert.equal(called[2], '/__zuul/hello'); done(); }); }); diff --git a/tests/getContentMeta.js b/tests/getContentMeta.js new file mode 100644 index 0000000..2e3f510 --- /dev/null +++ b/tests/getContentMeta.js @@ -0,0 +1,109 @@ +var assert = require('assert'); +var getContentMeta = require('../lib/getContentMeta'); + +describe('getContentMeta', function() { + + it('finds doctype by content type', function() { + assert.strictEqual( + getContentMeta(null, 'text/html').doctype, + '' + ); + assert.strictEqual( + getContentMeta(null, 'application/xhtml+xml').doctype, + '' + ); + assert.strictEqual( + getContentMeta(null, 'image/svg+xml').doctype, + '' + ); + }); + + it('finds content type by full doctype', function() { + assert.strictEqual( + getContentMeta( + '' + ).contentType, + 'text/html' + ); + }); + + it("doesn't find a match for an empty doctype", function() { // Even though it matches SVG + assert.throws( + function() { getContentMeta(''); }, + /Could not find matching content type/ + ); + }); + + it("doesn't find a match for an nonsense doctype", function() { + assert.throws( + function() { getContentMeta('missingno'); }, + /Could not find matching content type/ + ); + }); + + it("doesn't find a match for an nonsense content type", function() { + assert.throws( + function() { getContentMeta(null, 'missingno'); }, + /Could not find matching doctype/ + ); + }); + + it('finds content type by doctype nickname', function() { + assert.strictEqual( + getContentMeta(5).contentType, + 'text/html' + ); + assert.strictEqual( + getContentMeta('html5').contentType, + 'text/html' + ); + assert.strictEqual( + getContentMeta('svg').contentType, + 'image/svg+xml' + ); + }); + + it('finds actual doctype by doctype nickname', function() { + assert.strictEqual( + getContentMeta(4).doctype, + '' + ); + assert.strictEqual( + getContentMeta('svg').doctype, + '' + ); + assert.strictEqual( + getContentMeta(4, 'text/html').doctype, + '' + ); + assert.deepEqual( + getContentMeta('strict', 'text/html'), + { + doctype: '', + contentType: 'text/html' + } + ); + }); + + it('defaults to html5', function() { + assert.deepEqual( + getContentMeta(), + {doctype: '', contentType: 'text/html'} + ); + }); + + it('defaults to html5', function() { + assert.deepEqual( + getContentMeta(), + {doctype: '', contentType: 'text/html'} + ); + }); + + it('passes through explicit values', function() { + assert.deepEqual( + getContentMeta('hello', 'goodbye'), + {doctype: 'hello', contentType: 'goodbye'} + ); + }); + +}); diff --git a/tests/server.js b/tests/server.js index fa016ff..8d1f781 100644 --- a/tests/server.js +++ b/tests/server.js @@ -4,10 +4,26 @@ var Router = require('../index'); describe('react-router-component (on server)', function() { + var dispatchCount; + var dispatchPath; + + beforeEach(function() { + dispatchCount = 0; + dispatchPath = null; + }); + var App = React.createClass({ + handleDispatch: function(path) { + dispatchCount += 1; + dispatchPath = path; + }, + render: function() { - return Router.Locations({className: 'App', path: this.props.path}, + return Router.Locations({ + className: 'App', + path: this.props.path, + onDispatch: this.handleDispatch}, Router.Location({ path: '/', handler: function(props) { return React.DOM.div(null, 'mainpage'); } @@ -41,6 +57,16 @@ describe('react-router-component (on server)', function() { assert(markup.match(/not_found/)); }); + it('invokes the onDispatch callback with the initial path', function() { + React.renderComponentToString(App({path: '/x/hello'})); + assert.equal(dispatchPath, '/x/hello'); + }); + + it('invokes the onDispatch callback once for the initial path', function() { + React.renderComponentToString(App()); + assert.equal(dispatchCount, 1); + }); + describe('pages router', function() { var App = React.createClass({