diff --git a/README.md b/README.md index 3772aff1..ffc37b46 100644 --- a/README.md +++ b/README.md @@ -45,6 +45,7 @@ export default { + ), Root: ({store, children}) => ( @@ -69,6 +70,10 @@ are currently not provided, so these dependencies are required. additional steps before the response * `routes`: the definition of your react-router routes that this plugin should match the request url against + * If you use a [catch-all route](https://github.com/ReactTraining/react-router/blob/c3cd9675bd8a31368f87da74ac588981cbd6eae7/upgrade-guides/v1.0.0.md#notfound-route) + to display an appropriate message when the route does not match, it should have a `displayName` of `NotFound`. This + will enable the status code to be passed to `respond` as `404`. Please note that the automatic mapping of the `name` + property should not be relied on because it can be mangled during minification and, therefore, not match in production. * `Root`: a react component that will wrap the mounted components that result from the matched route * `store`: a data store that will be passed as a prop to the `` component so that your component can inject it into the context through a provider component. diff --git a/example/components/not-found.js b/example/components/not-found.js new file mode 100644 index 00000000..4ca9dafb --- /dev/null +++ b/example/components/not-found.js @@ -0,0 +1,12 @@ +import React from 'react'; + +export default function NotFound() { + return ( +
+

404

+

Page Not Found

+
+ ); +} + +NotFound.displayName = 'NotFound'; diff --git a/example/manifest.js b/example/manifest.js index dff6b675..50399536 100644 --- a/example/manifest.js +++ b/example/manifest.js @@ -41,7 +41,7 @@ export default { { plugin: { register: '../src/route', - options: {respond, routes, Root, store: createStore(() => undefined)} + options: {respond, routes, Root, configureStore: () => createStore(() => undefined)} } } ] diff --git a/example/respond.js b/example/respond.js index fa8d326b..e5332a8a 100644 --- a/example/respond.js +++ b/example/respond.js @@ -1,6 +1,6 @@ -export default function respond(reply, {renderedContent}) { +export default function respond(reply, {renderedContent, status}) { reply.view('layout', { renderedContent, title: 'Example Title' - }); + }).code(status); } diff --git a/example/routes.js b/example/routes.js index cce15ac6..f254da3d 100644 --- a/example/routes.js +++ b/example/routes.js @@ -5,13 +5,13 @@ import Wrap from './components/wrap'; import Index from './components/index'; import Foo from './components/foo'; import Bar from './components/bar'; +import NotFound from './components/not-found'; -const routes = ( +export default ( + ); - -export default routes; diff --git a/package.json b/package.json index 630dd62f..5a5c6d70 100644 --- a/package.json +++ b/package.json @@ -94,5 +94,8 @@ "commitizen": { "path": "./node_modules/cz-conventional-changelog" } + }, + "dependencies": { + "http-status-codes": "1.0.6" } } diff --git a/src/data-fetcher.js b/src/data-fetcher.js index 89f00109..2eed3e77 100644 --- a/src/data-fetcher.js +++ b/src/data-fetcher.js @@ -1,9 +1,9 @@ import {trigger} from 'redial'; -export default function ({renderProps, store}) { +export default function ({renderProps, store, status}) { return trigger('fetch', renderProps.components, { params: renderProps.params, dispatch: store.dispatch, state: store.getState() - }).then(() => Promise.resolve(({renderProps}))).catch(e => Promise.reject(e)); + }).then(() => Promise.resolve(({renderProps, status}))).catch(e => Promise.reject(e)); } diff --git a/src/route-matcher.js b/src/route-matcher.js index a7525dbe..6eef8984 100644 --- a/src/route-matcher.js +++ b/src/route-matcher.js @@ -1,5 +1,12 @@ +import {OK, NOT_FOUND} from 'http-status-codes'; import {match, createMemoryHistory} from 'react-router'; +function determineStatusFrom(components) { + if (components.map(component => component.displayName).includes('NotFound')) return NOT_FOUND; + + return OK; +} + export default function matchRoute(url, routes) { return new Promise((resolve, reject) => { const history = createMemoryHistory(); @@ -9,7 +16,7 @@ export default function matchRoute(url, routes) { reject(err); } - resolve({redirectLocation, renderProps}); + resolve({redirectLocation, renderProps, status: determineStatusFrom(renderProps.components)}); }); }); } diff --git a/src/router-wrapper.js b/src/router-wrapper.js index 932d70de..e3d21dc6 100644 --- a/src/router-wrapper.js +++ b/src/router-wrapper.js @@ -7,9 +7,10 @@ import fetchData from './data-fetcher'; export default function renderThroughReactRouter(request, reply, {routes, respond, Root, store}) { return matchRoute(request.raw.req.url, routes) - .then(({renderProps}) => fetchData({renderProps, store})) - .then(({renderProps}) => respond(reply, { + .then(({renderProps, status}) => fetchData({renderProps, store, status})) + .then(({renderProps, status}) => respond(reply, { store, + status, renderedContent: renderToString( diff --git a/test/unit/data-fetcher-test.js b/test/unit/data-fetcher-test.js index 8d5edfec..374d8b9a 100644 --- a/test/unit/data-fetcher-test.js +++ b/test/unit/data-fetcher-test.js @@ -22,10 +22,11 @@ suite('data fetcher', () => { const state = any.simpleObject(); const getState = sinon.stub().returns(state); const renderProps = {...any.simpleObject(), components, params}; + const status = any.integer(); const store = {...any.simpleObject(), dispatch, getState}; redial.trigger.withArgs('fetch', components, {params, dispatch, state}).resolves(); - return assert.isFulfilled(fetchData({renderProps, store}), {renderProps}); + return assert.isFulfilled(fetchData({renderProps, store, status}), {renderProps, status}); }); test('that a redial rejection bubbles', () => { diff --git a/test/unit/route-matcher-test.js b/test/unit/route-matcher-test.js index 91381073..80a46f8a 100644 --- a/test/unit/route-matcher-test.js +++ b/test/unit/route-matcher-test.js @@ -1,3 +1,4 @@ +import {OK, NOT_FOUND} from 'http-status-codes'; import * as reactRouter from 'react-router'; import sinon from 'sinon'; import {assert} from 'chai'; @@ -7,6 +8,10 @@ import matchRoute from '../../src/route-matcher'; suite('route matcher', () => { let sandbox; const createLocation = sinon.stub(); + const routes = any.simpleObject(); + const redirectLocation = any.string(); + const renderProps = any.simpleObject(); + const url = any.string(); setup(() => { sandbox = sinon.sandbox.create(); @@ -21,15 +26,16 @@ suite('route matcher', () => { }); test('that renderProps and redirectLocation are returned when matching resolves', () => { - const url = any.string(); const location = any.string(); - const routes = any.simpleObject(); - const renderProps = any.simpleObject(); - const redirectLocation = any.string(); createLocation.withArgs(url).returns(location); - reactRouter.match.withArgs({location, routes}).yields(null, redirectLocation, renderProps); - - return assert.becomes(matchRoute(url, routes), {redirectLocation, renderProps}); + const renderPropWithComponents = {...renderProps, components: any.listOf(() => ({displayName: any.word()}))}; + reactRouter.match.withArgs({location, routes}).yields(null, redirectLocation, renderPropWithComponents); + + return assert.becomes(matchRoute(url, routes), { + redirectLocation, + renderProps: renderPropWithComponents, + status: OK + }); }); test('that a matching error results in a rejection', () => { @@ -38,4 +44,16 @@ suite('route matcher', () => { return assert.isRejected(matchRoute(), error); }); + + test('that the status code is returned as 404 when the catch-all route matches', () => { + const components = [{displayName: any.string()}, {displayName: 'NotFound'}, {displayName: any.string()}]; + const renderPropsWithComponents = {components}; + reactRouter.match.yields(null, redirectLocation, renderPropsWithComponents); + + return assert.becomes(matchRoute(url, routes), { + redirectLocation, + renderProps: renderPropsWithComponents, + status: NOT_FOUND + }); + }); }); diff --git a/test/unit/router-wrapper-test.js b/test/unit/router-wrapper-test.js index c439ef3d..25e793d7 100644 --- a/test/unit/router-wrapper-test.js +++ b/test/unit/router-wrapper-test.js @@ -31,20 +31,21 @@ suite('router-wrapper', () => { const request = {raw: {req: {url}}}; const reply = sinon.spy(); const renderProps = any.simpleObject(); + const status = any.integer(); const context = any.simpleObject(); const Root = any.simpleObject(); const store = any.simpleObject(); const rootComponent = any.simpleObject(); const renderedContent = any.string(); - routeMatcher.default.withArgs(url, routes).resolves({renderProps}); - dataFetcher.default.withArgs({renderProps, store}).resolves({renderProps}); + routeMatcher.default.withArgs(url, routes).resolves({renderProps, status}); + dataFetcher.default.withArgs({renderProps, store, status}).resolves({renderProps, status}); React.createElement.withArgs(RouterContext, sinon.match(renderProps)).returns(context); React.createElement.withArgs(Root, {request, store}).returns(rootComponent); domServer.renderToString.withArgs(rootComponent).returns(renderedContent); return renderThroughReactRouter(request, reply, {routes, respond, Root, store}).then(() => { assert.notCalled(reply); - assert.calledWith(respond, reply, {renderedContent, store}); + assert.calledWith(respond, reply, {renderedContent, store, status}); }); });