diff --git a/package.json b/package.json index f2e49b008..ce8f29661 100644 --- a/package.json +++ b/package.json @@ -88,7 +88,6 @@ "compression": "^1.6.0", "express": "^4.13.3", "express-session": "^1.12.1", - "history": "1.17.0", "file-loader": "^0.8.5", "http-proxy": "^1.12.0", "invariant": "^2.2.0", @@ -99,19 +98,19 @@ "multireducer": "^2.0.0", "piping": "^0.3.0", "pretty-error": "^1.2.0", - "query-string": "^3.0.0", "react": "^0.14.2", "react-bootstrap": "^0.28.1", "react-dom": "^0.14.1", "react-helmet": "^2.2.0", "react-inline-css": "^2.0.0", "react-redux": "^4.0.0", - "react-router": "1.0.3", - "react-router-bootstrap": "^0.19.3", + "react-router": "2.0.0", + "react-router-bootstrap": "^0.20.1", + "react-router-redux": "^3.0.0", "redux": "^3.0.4", + "redux-async-connect": "^0.1.13", "redux-form": "^3.0.12", - "redux-router": "1.0.0-beta5", - "scroll-behavior": "^0.3.0", + "scroll-behavior": "^0.3.2", "serialize-javascript": "^1.1.2", "serve-favicon": "^2.3.0", "socket.io": "^1.3.7", diff --git a/src/client.js b/src/client.js index 948d9fc6f..73f621426 100644 --- a/src/client.js +++ b/src/client.js @@ -4,25 +4,20 @@ import 'babel/polyfill'; import React from 'react'; import ReactDOM from 'react-dom'; -import createHistory from 'history/lib/createBrowserHistory'; -import useScroll from 'scroll-behavior/lib/useStandardScroll'; import createStore from './redux/create'; import ApiClient from './helpers/ApiClient'; import io from 'socket.io-client'; import {Provider} from 'react-redux'; -import {reduxReactRouter, ReduxRouter} from 'redux-router'; +import { Router, browserHistory } from 'react-router'; +import { ReduxAsyncConnect } from 'redux-async-connect'; +import useScroll from 'scroll-behavior/lib/useStandardScroll'; import getRoutes from './routes'; -import makeRouteHooksSafe from './helpers/makeRouteHooksSafe'; const client = new ApiClient(); - -// Three different types of scroll behavior available. -// Documented here: https://github.com/rackt/scroll-behavior -const scrollableHistory = useScroll(createHistory); - +const history = useScroll(() => browserHistory)(); const dest = document.getElementById('content'); -const store = createStore(reduxReactRouter, makeRouteHooksSafe(getRoutes), scrollableHistory, client, window.__data); +const store = createStore(history, client, window.__data); function initSocket() { const socket = io('', {path: '/ws'}); @@ -40,7 +35,11 @@ function initSocket() { global.socket = initSocket(); const component = ( - + + + } history={history}> + {getRoutes(store)} + ); ReactDOM.render( diff --git a/src/components/__tests__/InfoBar-test.js b/src/components/__tests__/InfoBar-test.js index 6652d6f01..22d458046 100644 --- a/src/components/__tests__/InfoBar-test.js +++ b/src/components/__tests__/InfoBar-test.js @@ -4,8 +4,7 @@ import {renderIntoDocument} from 'react-addons-test-utils'; import { expect} from 'chai'; import { InfoBar } from 'components'; import { Provider } from 'react-redux'; -import {reduxReactRouter} from 'redux-router'; -import createHistory from 'history/lib/createMemoryHistory'; +import { browserHistory } from 'react-router'; import createStore from 'redux/create'; import ApiClient from 'helpers/ApiClient'; const client = new ApiClient(); @@ -22,8 +21,7 @@ describe('InfoBar', () => { } } }; - - const store = createStore(reduxReactRouter, null, createHistory, client, mockStore); + const store = createStore(browserHistory, client, mockStore); const renderer = renderIntoDocument( diff --git a/src/containers/App/App.js b/src/containers/App/App.js index 5222ef2f7..ef117f62c 100644 --- a/src/containers/App/App.js +++ b/src/containers/App/App.js @@ -9,25 +9,12 @@ import Helmet from 'react-helmet'; import { isLoaded as isInfoLoaded, load as loadInfo } from 'redux/modules/info'; import { isLoaded as isAuthLoaded, load as loadAuth, logout } from 'redux/modules/auth'; import { InfoBar } from 'components'; -import { pushState } from 'redux-router'; -import connectData from 'helpers/connectData'; +import { routeActions } from 'react-router-redux'; import config from '../../config'; -function fetchData(getState, dispatch) { - const promises = []; - if (!isInfoLoaded(getState())) { - promises.push(dispatch(loadInfo())); - } - if (!isAuthLoaded(getState())) { - promises.push(dispatch(loadAuth())); - } - return Promise.all(promises); -} - -@connectData(fetchData) @connect( state => ({user: state.auth.user}), - {logout, pushState}) + {logout, pushState: routeActions.push}) export default class App extends Component { static propTypes = { children: PropTypes.object.isRequired, @@ -43,17 +30,31 @@ export default class App extends Component { componentWillReceiveProps(nextProps) { if (!this.props.user && nextProps.user) { // login - this.props.pushState(null, '/loginSuccess'); + this.props.pushState('/loginSuccess'); } else if (this.props.user && !nextProps.user) { // logout - this.props.pushState(null, '/'); + this.props.pushState('/'); + } + } + + static reduxAsyncConnect(params, store) { + const {dispatch, getState} = store; + const promises = []; + + if (!isInfoLoaded(getState())) { + promises.push(dispatch(loadInfo())); + } + if (!isAuthLoaded(getState())) { + promises.push(dispatch(loadAuth())); } + + return Promise.all(promises); } handleLogout = (event) => { event.preventDefault(); this.props.logout(); - } + }; render() { const {user} = this.props; diff --git a/src/containers/Widgets/Widgets.js b/src/containers/Widgets/Widgets.js index 001e3a758..e02c0e84c 100644 --- a/src/containers/Widgets/Widgets.js +++ b/src/containers/Widgets/Widgets.js @@ -4,16 +4,8 @@ import {connect} from 'react-redux'; import * as widgetActions from 'redux/modules/widgets'; import {isLoaded, load as loadWidgets} from 'redux/modules/widgets'; import {initializeWithKey} from 'redux-form'; -import connectData from 'helpers/connectData'; import { WidgetForm } from 'components'; -function fetchDataDeferred(getState, dispatch) { - if (!isLoaded(getState())) { - return dispatch(loadWidgets()); - } -} - -@connectData(null, fetchDataDeferred) @connect( state => ({ widgets: state.widgets.data, @@ -31,6 +23,13 @@ export default class Widgets extends Component { editing: PropTypes.object.isRequired, load: PropTypes.func.isRequired, editStart: PropTypes.func.isRequired + }; + + static reduxAsyncConnect(params, store) { + const {dispatch, getState} = store; + if (!isLoaded(getState())) { + return dispatch(loadWidgets()); + } } render() { diff --git a/src/helpers/Html.js b/src/helpers/Html.js index 9e415a994..ebf5ba436 100644 --- a/src/helpers/Html.js +++ b/src/helpers/Html.js @@ -17,7 +17,7 @@ export default class Html extends Component { assets: PropTypes.object, component: PropTypes.node, store: PropTypes.object - } + }; render() { const {assets, component, store} = this.props; diff --git a/src/helpers/__tests__/connectData-test.js b/src/helpers/__tests__/connectData-test.js deleted file mode 100644 index d61d73bf4..000000000 --- a/src/helpers/__tests__/connectData-test.js +++ /dev/null @@ -1,29 +0,0 @@ -import { expect } from 'chai'; -import React from 'react'; -import { div } from 'react-dom'; -import connectData from '../connectData'; - -describe('connectData', () => { - let fetchData; - let fetchDataDeferred; - let WrappedComponent; - let DataComponent; - - beforeEach(() => { - fetchData = 'fetchDataFunction'; - fetchDataDeferred = 'fetchDataDeferredFunction'; - - WrappedComponent = () => -
; - - DataComponent = connectData(fetchData, fetchDataDeferred)(WrappedComponent); - }); - - it('should set fetchData as a static property of the final component', () => { - expect(DataComponent.fetchData).to.equal(fetchData); - }); - - it('should set fetchDataDeferred as a static property of the final component', () => { - expect(DataComponent.fetchDataDeferred).to.equal(fetchDataDeferred); - }); -}); diff --git a/src/helpers/__tests__/getDataDependencies-test.js b/src/helpers/__tests__/getDataDependencies-test.js deleted file mode 100644 index fb2a066f1..000000000 --- a/src/helpers/__tests__/getDataDependencies-test.js +++ /dev/null @@ -1,64 +0,0 @@ -import { expect } from 'chai'; -import React from 'react'; -import { div } from 'react-dom'; -import getDataDependencies from '../getDataDependencies'; - -describe('getDataDependencies', () => { - let getState; - let dispatch; - let location; - let params; - let CompWithFetchData; - let CompWithNoData; - let CompWithFetchDataDeferred; - const NullComponent = null; - - beforeEach(() => { - getState = 'getState'; - dispatch = 'dispatch'; - location = 'location'; - params = 'params'; - - CompWithNoData = () => -
; - - CompWithFetchData = () => -
; - - CompWithFetchData.fetchData = (_getState, _dispatch, _location, _params) => { - return `fetchData ${_getState} ${_dispatch} ${_location} ${_params}`; - }; - CompWithFetchDataDeferred = () => -
; - - CompWithFetchDataDeferred.fetchDataDeferred = (_getState, _dispatch, _location, _params) => { - return `fetchDataDeferred ${_getState} ${_dispatch} ${_location} ${_params}`; - }; - }); - - it('should get fetchDatas', () => { - const deps = getDataDependencies([ - NullComponent, - CompWithFetchData, - CompWithNoData, - CompWithFetchDataDeferred - ], getState, dispatch, location, params); - - expect(deps).to.deep.equal([ - 'fetchData getState dispatch location params' - ]); - }); - - it('should get fetchDataDeferreds', () => { - const deps = getDataDependencies([ - NullComponent, - CompWithFetchData, - CompWithNoData, - CompWithFetchDataDeferred - ], getState, dispatch, location, params, true); - - expect(deps).to.deep.equal([ - 'fetchDataDeferred getState dispatch location params' - ]); - }); -}); diff --git a/src/helpers/__tests__/getStatusFromRoutes-test.js b/src/helpers/__tests__/getStatusFromRoutes-test.js deleted file mode 100644 index db41083e6..000000000 --- a/src/helpers/__tests__/getStatusFromRoutes-test.js +++ /dev/null @@ -1,52 +0,0 @@ -import { expect } from 'chai'; -import getStatusFromRoutes from '../getStatusFromRoutes'; - -describe('getStatusFromRoutes', () => { - it('should return null when no routes have status code', () => { - const status = getStatusFromRoutes([ - {}, {} - ]); - - expect(status).to.equal(null); - }); - - it('should return the only status code', () => { - const status = getStatusFromRoutes([ - {status: 404} - ]); - - expect(status).to.equal(404); - }); - - it('should return the only status code when other routes have none', () => { - const status = getStatusFromRoutes([ - {status: 404}, {}, {} - ]); - - expect(status).to.equal(404); - }); - - it('should return the last status code when later routes have none', () => { - const status = getStatusFromRoutes([ - {status: 200}, {status: 404}, {} - ]); - - expect(status).to.equal(404); - }); - - it('should return the last status code when previous routes have one', () => { - const status = getStatusFromRoutes([ - {status: 200}, {}, {status: 404} - ]); - - expect(status).to.equal(404); - }); - - it('should return the last status code', () => { - const status = getStatusFromRoutes([ - {}, {}, {status: 404} - ]); - - expect(status).to.equal(404); - }); -}); diff --git a/src/helpers/__tests__/makeRouteHooksSafe-test.js b/src/helpers/__tests__/makeRouteHooksSafe-test.js deleted file mode 100644 index 9c49dd7e9..000000000 --- a/src/helpers/__tests__/makeRouteHooksSafe-test.js +++ /dev/null @@ -1,110 +0,0 @@ -import { expect } from 'chai'; -import React from 'react'; -import { IndexRoute, Route } from 'react-router'; -import makeRouteHooksSafe from '../makeRouteHooksSafe'; - - -describe('makeRouteHooksSafe', () => { - it('should work with JSX routes', () => { - const onEnter = () => { - throw new Error('Shouldn\'t call onEnter'); - }; - - const getRoutes = makeRouteHooksSafe(() => { - return ( - - - - - - - - - ); - }); - - const routes = getRoutes(null); - - expect(routes[0].indexRoute.onEnter).to.not.throw(Error); - expect(routes[0].childRoutes[1].onEnter).to.not.throw(Error); - expect(routes[0].childRoutes[1].childRoutes[1].onEnter).to.not.throw(Error); - }); - - it('should work with JS routes', () => { - const onEnter = () => { - throw new Error('Shouldn\'t call onEnter'); - }; - - const getRoutes = makeRouteHooksSafe(() => { - return { - path: '/', - indexRoute: { - onEnter: onEnter - }, - onEnter: onEnter, - childRoutes: [ - {path: '1'}, - { - onEnter: onEnter, - childRoutes: [ - {path: '2'}, - {path: '3'} - ], - } - ] - }; - }); - - const routes = getRoutes(null); - - expect(routes[0].indexRoute.onEnter).to.not.throw(Error); - expect(routes[0].onEnter).to.not.throw(Error); - expect(routes[0].childRoutes[1].onEnter).to.not.throw(Error); - }); - - it('should call onEnter if store is initialised', (done) => { - const store = { - getState: () => {} - }; - - const getRoutes = makeRouteHooksSafe(() => { - return { - onEnter: () => { - done(); - } - }; - }); - - const routes = getRoutes(store); - - routes[0].onEnter(); - }); - - it('should call callback', (done) => { - const getRoutes = makeRouteHooksSafe(() => { - return { - onEnter: (nextState, replaceState, cb) => {} // eslint-disable-line no-unused-vars - }; - }); - - const routes = getRoutes(null); - - routes[0].onEnter(null, null, done); - }); - - it('should not call callback', () => { - const callback = () => { - throw new Error('Should not be called'); - }; - - const getRoutes = makeRouteHooksSafe(() => { - return { - onEnter: (nextState, replaceState) => {} // eslint-disable-line no-unused-vars - }; - }); - - const routes = getRoutes(null); - - routes[0].onEnter(null, null, callback); - }); -}); diff --git a/src/helpers/connectData.js b/src/helpers/connectData.js deleted file mode 100644 index a67510292..000000000 --- a/src/helpers/connectData.js +++ /dev/null @@ -1,23 +0,0 @@ -import React, { Component } from 'react'; - -/* - Note: - When this decorator is used, it MUST be the first (outermost) decorator. - Otherwise, we cannot find and call the fetchData and fetchDataDeffered methods. -*/ - -export default function connectData(fetchData, fetchDataDeferred) { - return function wrapWithFetchData(WrappedComponent) { - class ConnectData extends Component { - - static fetchData = fetchData; - static fetchDataDeferred = fetchDataDeferred; - - render() { - return ; - } - } - - return ConnectData; - }; -} diff --git a/src/helpers/getDataDependencies.js b/src/helpers/getDataDependencies.js deleted file mode 100644 index 9559c51f3..000000000 --- a/src/helpers/getDataDependencies.js +++ /dev/null @@ -1,18 +0,0 @@ -/** - * 1. Skip holes in route component chain and - * only consider components that implement - * fetchData or fetchDataDeferred - * - * 2. Pull out fetch data methods - * - * 3. Call fetch data methods and gather promises - */ -export default (components, getState, dispatch, location, params, deferred) => { - const methodName = deferred ? 'fetchDataDeferred' : 'fetchData'; - - return components - .filter((component) => component && component[methodName]) // 1 - .map((component) => component[methodName]) // 2 - .map(fetchData => - fetchData(getState, dispatch, location, params)); // 3 -}; diff --git a/src/helpers/getStatusFromRoutes.js b/src/helpers/getStatusFromRoutes.js deleted file mode 100644 index af952a2a8..000000000 --- a/src/helpers/getStatusFromRoutes.js +++ /dev/null @@ -1,9 +0,0 @@ -/** - * Return the status code from the last matched route with a status property. - * - * @param matchedRoutes - * @returns {Number|null} - */ -export default (matchedRoutes) => { - return matchedRoutes.reduce((prev, cur) => cur.status || prev, null); -}; diff --git a/src/helpers/makeRouteHooksSafe.js b/src/helpers/makeRouteHooksSafe.js deleted file mode 100644 index 7c9f8c539..000000000 --- a/src/helpers/makeRouteHooksSafe.js +++ /dev/null @@ -1,43 +0,0 @@ -import { createRoutes } from 'react-router/lib/RouteUtils'; - -// Wrap the hooks so they don't fire if they're called before -// the store is initialised. This only happens when doing the first -// client render of a route that has an onEnter hook -function makeHooksSafe(routes, store) { - if (Array.isArray(routes)) { - return routes.map((route) => makeHooksSafe(route, store)); - } - - const onEnter = routes.onEnter; - - if (onEnter) { - routes.onEnter = function safeOnEnter(...args) { - try { - store.getState(); - } catch (err) { - if (onEnter.length === 3) { - args[2](); - } - - // There's no store yet so ignore the hook - return; - } - - onEnter.apply(null, args); - }; - } - - if (routes.childRoutes) { - makeHooksSafe(routes.childRoutes, store); - } - - if (routes.indexRoute) { - makeHooksSafe(routes.indexRoute, store); - } - - return routes; -} - -export default function makeRouteHooksSafe(_getRoutes) { - return (store) => makeHooksSafe(createRoutes(_getRoutes(store)), store); -} diff --git a/src/redux/create.js b/src/redux/create.js index 6a08c9dbb..0f52727c7 100644 --- a/src/redux/create.js +++ b/src/redux/create.js @@ -1,9 +1,12 @@ import { createStore as _createStore, applyMiddleware, compose } from 'redux'; import createMiddleware from './middleware/clientMiddleware'; -import transitionMiddleware from './middleware/transitionMiddleware'; +import { syncHistory } from 'react-router-redux'; -export default function createStore(reduxReactRouter, getRoutes, createHistory, client, data) { - const middleware = [createMiddleware(client), transitionMiddleware]; +export default function createStore(history, client, data) { + // Sync dispatched route actions to the history + const reduxRouterMiddleware = syncHistory(history); + + const middleware = [createMiddleware(client), reduxRouterMiddleware]; let finalCreateStore; if (__DEVELOPMENT__ && __CLIENT__ && __DEVTOOLS__) { @@ -18,11 +21,11 @@ export default function createStore(reduxReactRouter, getRoutes, createHistory, finalCreateStore = applyMiddleware(...middleware)(_createStore); } - finalCreateStore = reduxReactRouter({ getRoutes, createHistory })(finalCreateStore); - const reducer = require('./modules/reducer'); const store = finalCreateStore(reducer, data); + reduxRouterMiddleware.listenForReplays(store); + if (__DEVELOPMENT__ && module.hot) { module.hot.accept('./modules/reducer', () => { store.replaceReducer(require('./modules/reducer')); diff --git a/src/redux/middleware/clientMiddleware.js b/src/redux/middleware/clientMiddleware.js index 7ca18f95e..c3403c166 100644 --- a/src/redux/middleware/clientMiddleware.js +++ b/src/redux/middleware/clientMiddleware.js @@ -12,13 +12,17 @@ export default function clientMiddleware(client) { const [REQUEST, SUCCESS, FAILURE] = types; next({...rest, type: REQUEST}); - return promise(client).then( + + const actionPromise = promise(client); + actionPromise.then( (result) => next({...rest, result, type: SUCCESS}), (error) => next({...rest, error, type: FAILURE}) ).catch((error)=> { console.error('MIDDLEWARE ERROR:', error); next({...rest, error, type: FAILURE}); }); + + return actionPromise; }; }; } diff --git a/src/redux/middleware/transitionMiddleware.js b/src/redux/middleware/transitionMiddleware.js deleted file mode 100644 index 5532f89e2..000000000 --- a/src/redux/middleware/transitionMiddleware.js +++ /dev/null @@ -1,44 +0,0 @@ -import {ROUTER_DID_CHANGE} from 'redux-router/lib/constants'; -import getDataDependencies from '../../helpers/getDataDependencies'; - -const locationsAreEqual = (locA, locB) => (locA.pathname === locB.pathname) && (locA.search === locB.search); - -export default ({getState, dispatch}) => next => action => { - if (action.type === ROUTER_DID_CHANGE) { - if (getState().router && locationsAreEqual(action.payload.location, getState().router.location)) { - return next(action); - } - - const {components, location, params} = action.payload; - const promise = new Promise((resolve) => { - const doTransition = () => { - next(action); - Promise.all(getDataDependencies(components, getState, dispatch, location, params, true)) - .then(resolve) - .catch(error => { - // TODO: You may want to handle errors for fetchDataDeferred here - console.warn('Warning: Error in fetchDataDeferred', error); - return resolve(); - }); - }; - - Promise.all(getDataDependencies(components, getState, dispatch, location, params)) - .then(doTransition) - .catch(error => { - // TODO: You may want to handle errors for fetchData here - console.warn('Warning: Error in fetchData', error); - return doTransition(); - }); - }); - - if (__SERVER__) { - // router state is null until ReduxRouter is created so we can use this to store - // our promise to let the server know when it can render - getState().router = promise; - } - - return promise; - } - - return next(action); -}; diff --git a/src/redux/modules/reducer.js b/src/redux/modules/reducer.js index 6efb9e7cb..d260a1632 100644 --- a/src/redux/modules/reducer.js +++ b/src/redux/modules/reducer.js @@ -1,6 +1,7 @@ import { combineReducers } from 'redux'; import multireducer from 'multireducer'; -import { routerStateReducer } from 'redux-router'; +import { routeReducer } from 'react-router-redux'; +import {reducer as reduxAsyncConnect} from 'redux-async-connect'; import auth from './auth'; import counter from './counter'; @@ -9,7 +10,8 @@ import info from './info'; import widgets from './widgets'; export default combineReducers({ - router: routerStateReducer, + routing: routeReducer, + reduxAsyncConnect, auth, form, multireducer: multireducer({ diff --git a/src/server.js b/src/server.js index c95a092ec..a806783f7 100644 --- a/src/server.js +++ b/src/server.js @@ -12,13 +12,11 @@ import Html from './helpers/Html'; import PrettyError from 'pretty-error'; import http from 'http'; -import {ReduxRouter} from 'redux-router'; -import createHistory from 'history/lib/createMemoryHistory'; -import {reduxReactRouter, match} from 'redux-router/server'; +import { match } from 'react-router'; +import { ReduxAsyncConnect, loadOnServer } from 'redux-async-connect'; +import createHistory from 'react-router/lib/createMemoryHistory'; import {Provider} from 'react-redux'; -import qs from 'query-string'; import getRoutes from './routes'; -import getStatusFromRoutes from './helpers/getStatusFromRoutes'; const targetUrl = 'http://' + config.apiHost + ':' + config.apiPort; const pretty = new PrettyError(); @@ -68,8 +66,9 @@ app.use((req, res) => { webpackIsomorphicTools.refresh(); } const client = new ApiClient(req); + const history = createHistory(req.originalUrl); - const store = createStore(reduxReactRouter, getRoutes, createHistory, client); + const store = createStore(history, client); function hydrateOnClient() { res.send('\n' + @@ -81,43 +80,32 @@ app.use((req, res) => { return; } - store.dispatch(match(req.originalUrl, (error, redirectLocation, routerState) => { + match({ history, routes: getRoutes(store), location: req.originalUrl }, (error, redirectLocation, renderProps) => { if (redirectLocation) { res.redirect(redirectLocation.pathname + redirectLocation.search); } else if (error) { console.error('ROUTER ERROR:', pretty.render(error)); res.status(500); hydrateOnClient(); - } else if (!routerState) { - res.status(500); - hydrateOnClient(); - } else { - // Workaround redux-router query string issue: - // https://github.com/rackt/redux-router/issues/106 - if (routerState.location.search && !routerState.location.query) { - routerState.location.query = qs.parse(routerState.location.search); - } - - store.getState().router.then(() => { + } else if (renderProps) { + loadOnServer(renderProps, store, {client}).then(() => { const component = ( - + ); - const status = getStatusFromRoutes(routerState.routes); - if (status) { - res.status(status); - } + res.status(200); + + global.navigator = {userAgent: req.headers['user-agent']}; + res.send('\n' + ReactDOM.renderToString()); - }).catch((err) => { - console.error('DATA FETCHING ERROR:', pretty.render(err)); - res.status(500); - hydrateOnClient(); }); + } else { + res.status(404).send('Not found'); } - })); + }); }); if (config.port) {