diff --git a/README.md b/README.md index 2e886db4a..0bc03b10f 100644 --- a/README.md +++ b/README.md @@ -23,8 +23,8 @@ This is a starter boilerplate app I've put together using the following technolo * [Webpack Dev Middleware](http://webpack.github.io/docs/webpack-dev-middleware.html) * [Webpack Hot Middleware](https://github.com/glenjamin/webpack-hot-middleware) * [Redux](https://github.com/rackt/redux)'s futuristic [Flux](https://facebook.github.io/react/blog/2014/05/06/flux.html) implementation -* [Redux Dev Tools](https://github.com/rackt/redux-devtools) for next generation DX (developer experience). Watch [Dan Abramov's talk](https://www.youtube.com/watch?v=xsSnOQynTHs). -* [Redux Router](https://github.com/acdlite/redux-router) Keep your router state in your Redux store +* [Redux Dev Tools](https://github.com/gaearon/redux-devtools) for next generation DX (developer experience). Watch [Dan Abramov's talk](https://www.youtube.com/watch?v=xsSnOQynTHs). +* [React Router Redux](https://github.com/reactjs/react-router-redux) Redux/React Router bindings. * [ESLint](http://eslint.org) to maintain a consistent code style * [redux-form](https://github.com/erikras/redux-form) to manage form state in Redux * [lru-memoize](https://github.com/erikras/lru-memoize) to speed up form validation @@ -80,6 +80,8 @@ A demonstration of this app can be seen [running on heroku](https://react-redux. * [Exploring the Demo App](docs/ExploringTheDemoApp/ExploringTheDemoApp.md) is a guide that can be used before you install the kit. * [Installing the Kit](docs/InstallingTheKit/InstallingTheKit.md) guides you through installation and running the development server locally. +* [Adding Text to the Home Page](docs/AddingToHomePage/AddingToHomePage.md) guides you through adding "Hello, World!" to the home page. +* [Adding A Page](docs/AddingAPage/AddingAPage.md) guides you through adding a new page. * [React Tutorial - Converting Reflux to Redux](http://engineering.wework.com/process/2015/10/01/react-reflux-to-redux/), by Matt Star If you are the kind of person that learns best by following along a tutorial, I can recommend Matt Star's overview and examples. diff --git a/circle.yml b/circle.yml new file mode 100644 index 000000000..dc818fe12 --- /dev/null +++ b/circle.yml @@ -0,0 +1,17 @@ +machine: + node: + version: 4.0 + environment: + CONTINUOUS_INTEGRATION: true + +dependencies: + cache_directories: + - node_modules + override: + - npm prune && npm install + +test: + override: + - npm run lint + - npm test + - npm run test-node diff --git a/docs/AddingAPage/AddingAPage.md b/docs/AddingAPage/AddingAPage.md new file mode 100644 index 000000000..68e6077bf --- /dev/null +++ b/docs/AddingAPage/AddingAPage.md @@ -0,0 +1,93 @@ +# Adding A Hello Page + +This guide adds a `/hello` page to the sample application by +following the existing outline. + +## Using Ack on About + +Searching strings is one way to [grok](https://en.wikipedia.org/wiki/Grok) the structure +of the kit and sample application. You can use *grep* or [ack](http://beyondgrep.com) (`brew install ack`). +I use *ack* with this alias: + +![ick Alias](ick_alias.png) + +Looking with `ick about` and ignoring documentation, the word *about* appears in these files: + +![ick for About](ick_about.png) + +## Add the Hello page container + +A new page requires new page renderer. Copy the About page to a +new directory and trim out almost all of it: + +* `cd ./src/containers && mkdir ./src/Hello` because each container goes in its own + directory by convention. +* `cp About/About.js Hello/Hello.js` + +Edit `Hello/Hello.js` into this file: + +![New Hello.js](new_hello.png) + + + +## Edit three files to add Hello + +#### Add to `./src/containers/index.js` to include and export the React component: + +![Edit index.js](edit_index.png) + +#### Add to `./routes.js` to connect the `/hello` url path to the component: + +![Edit routes.js 1](edit_route1.png) +![Edit routes.js 2](edit_route2.png) + +#### Add to `./src/containers/App/App.js` to add "Hello" to the NavBar + +![Edit App.js](edit_app.png) + +And voila, the new 'Hello' page: + +![Show Hello](show_hello.png) + +# Take-away: Notice the trade-offs + +The task of adding a new page exemplifies two trade-offs in the kit: +**code versus convention** and the **cut and paste** style. + +Convention is a set of constraining rules that automatically trigger +routine configuration tasks. For example, WebPack automatically picked up the +new directory `./src/containers/Hello` without adding to any configuration files. + +On the other hand, routine code was added to `./src/containers/index.js` and +`./src/routes.js` to handle the new page. A convention could automatically +accomplish the same tasks at either compile or run time. The cost is new +constraints, such as requiring `Hello/Hello.js` to be renamed +`HelloPage/HelloPage.js`. + +Following a style in the code that has no automatic effects is just organic +growth, not convention. For example, developers reading `./src/containers/index.js` +must stop and figure out why all subdirectories except `DevTools` are exported. +(`DevTools`)[`./src/containers/DevTools/DevTools.js`](https://github.com/erikras/react-redux-universal-hot-example/blob/master/src/containers/DevTools/DevTools.js) +contains a single function which should be +[randomly](https://github.com/erikras/react-redux-universal-hot-example/issues/808) +moved to `./src/utils` or `./src/helpers`. Using a convention rule that all +containers must contain an exported React component would raise an error. +Organic growth leads to disorder in a project. + +Similarly, the **cut and paste** style of coding also degrades the project. +For example, In `App.js`, the new *NavItem* tag included a new value for the +*eventkey* property. The *eventkey* property is +[poorly](https://github.com/react-bootstrap/react-bootstrap/issues/320) +[understood](https://github.com/react-bootstrap/react-bootstrap/issues/432). +All *eventkey* fields in `App.js` are unused and can be removed. The +**cut and paste** style just compounds an +[old error](https://github.com/erikras/react-redux-universal-hot-example/commit/d67a79c1e7da5367dc8922019ca726e69d56bf0e) +and reinforces confusion. + +![Edit App revisted](edit_app2.png) + +The use of the **cut and paste** style raises well known issues in +maintenance, documentation, and code quality. It is not for use in +production code. + +Some choices about trade-offs are easier than others. \ No newline at end of file diff --git a/docs/AddingAPage/edit_app.png b/docs/AddingAPage/edit_app.png new file mode 100644 index 000000000..f1ba5f96c Binary files /dev/null and b/docs/AddingAPage/edit_app.png differ diff --git a/docs/AddingAPage/edit_app2.png b/docs/AddingAPage/edit_app2.png new file mode 100644 index 000000000..6ec8fd6ea Binary files /dev/null and b/docs/AddingAPage/edit_app2.png differ diff --git a/docs/AddingAPage/edit_index.png b/docs/AddingAPage/edit_index.png new file mode 100644 index 000000000..a2cab81f3 Binary files /dev/null and b/docs/AddingAPage/edit_index.png differ diff --git a/docs/AddingAPage/edit_route1.png b/docs/AddingAPage/edit_route1.png new file mode 100644 index 000000000..7b90b8101 Binary files /dev/null and b/docs/AddingAPage/edit_route1.png differ diff --git a/docs/AddingAPage/edit_route2.png b/docs/AddingAPage/edit_route2.png new file mode 100644 index 000000000..670c961d1 Binary files /dev/null and b/docs/AddingAPage/edit_route2.png differ diff --git a/docs/AddingAPage/ick_about.png b/docs/AddingAPage/ick_about.png new file mode 100644 index 000000000..714b89da0 Binary files /dev/null and b/docs/AddingAPage/ick_about.png differ diff --git a/docs/AddingAPage/ick_alias.png b/docs/AddingAPage/ick_alias.png new file mode 100644 index 000000000..79466ef8d Binary files /dev/null and b/docs/AddingAPage/ick_alias.png differ diff --git a/docs/AddingAPage/new_hello.png b/docs/AddingAPage/new_hello.png new file mode 100644 index 000000000..aef03703d Binary files /dev/null and b/docs/AddingAPage/new_hello.png differ diff --git a/docs/AddingAPage/show_hello.png b/docs/AddingAPage/show_hello.png new file mode 100644 index 000000000..ef0196309 Binary files /dev/null and b/docs/AddingAPage/show_hello.png differ diff --git a/docs/AddingToHomePage/AddingToHomePage.md b/docs/AddingToHomePage/AddingToHomePage.md new file mode 100644 index 000000000..3c197c68d --- /dev/null +++ b/docs/AddingToHomePage/AddingToHomePage.md @@ -0,0 +1,60 @@ +# Adding Hello, World as static text + +Printing *Hello, World!* is a traditional task. This guides you through adding the text "Hello, World!" to the +home page of the sample application. + +## Find the home page + +First, find the correct file to change by walking through the kit's directory tree: + +![Finding The Home Page 1](find_home1.png) + + +![Finding The Home Page 2](find_home2.png) + +![Finding The Home Page 3](find_home3.png) + +![Finding The Home Page 4](find_home4.png) + +So, the likely file is `src/containers/Home/Home.js`. + +## Start the server and open the browser + +Execute `npm run dev` and open http://localhost:3000: + +* `./package.json`, using [concurrently](https://www.npmjs.com/package/concurrently) +and [better-npm-run](https://www.npmjs.com/package/better-npm-run), runs + `./webpack/webpack-dev-server.js` on port 3001; runs `./bin/server.js` for HTTP on port 3000; + and runs `./bin/api.js` for the REST API on port 3030. + +* `./bin/server.js` calls `./src/server.js` and uses the [HMR plugin](http://andrewhfarmer.com/webpack-hmr-tutorial/) +for hot reloading, meaning the browser refreshes automatically when any file in `./src` is changed. + +* `./webpack/webpack-dev-server` does teh actual compilation with the +[webpack dev middleware package](https://github.com/webpack/webpack-dev-middleware) to provide a key feature found +in Glup: compilation without writing intermediate files to disk. Configuring webpack +[can be confusing](https://medium.com/@dtothefp/why-can-t-anyone-write-a-simple-webpack-tutorial-d0b075db35ed#.cle1vv5ql). + +* `./bin/api.js` calls `./api/api.js`. It receives incoming REST requests as JSON objects and responds with +other JSON objects. + +## Change the text + +Add the static text to (`src/containers/Home/Home.js`): + +![Add Hello Header to Home](add_home.png) + + +When you save the file to disk, the change to the `./src` directory is picked up by the +[piping](https://www.npmjs.com/package/piping) module, triggering the webpack-dev-server to rebuild +`./static/dist/[checksum].js`, and triggering a stub injected into the HTML file served to the browser to +reload. The rebuilding processes through webpack middleware and plugins that compile `*.sccs` files, +transpile JAX and ES6 (or ES7), and bundles together all the resources into one package in about 6 seconds. +That is, the browser will show "Hello, World!" on your web page in about 6 seconds: + +![Hello World rendered on home page](hello_rendered.png) + +## Conclusion + +You added **Hello, World!**. The process is [as clear as is the summer's sun](https://www.youtube.com/watch?v=EhGiSfv5FJk&t=3m23s). + diff --git a/docs/AddingToHomePage/add_home.png b/docs/AddingToHomePage/add_home.png new file mode 100644 index 000000000..53132ba9c Binary files /dev/null and b/docs/AddingToHomePage/add_home.png differ diff --git a/docs/AddingToHomePage/find_home1.png b/docs/AddingToHomePage/find_home1.png new file mode 100644 index 000000000..908b5fcc1 Binary files /dev/null and b/docs/AddingToHomePage/find_home1.png differ diff --git a/docs/AddingToHomePage/find_home2.png b/docs/AddingToHomePage/find_home2.png new file mode 100644 index 000000000..92f23bbcb Binary files /dev/null and b/docs/AddingToHomePage/find_home2.png differ diff --git a/docs/AddingToHomePage/find_home3.png b/docs/AddingToHomePage/find_home3.png new file mode 100644 index 000000000..909beb80a Binary files /dev/null and b/docs/AddingToHomePage/find_home3.png differ diff --git a/docs/AddingToHomePage/find_home4.png b/docs/AddingToHomePage/find_home4.png new file mode 100644 index 000000000..ad529d967 Binary files /dev/null and b/docs/AddingToHomePage/find_home4.png differ diff --git a/docs/AddingToHomePage/hello_rendered.png b/docs/AddingToHomePage/hello_rendered.png new file mode 100644 index 000000000..0917dd687 Binary files /dev/null and b/docs/AddingToHomePage/hello_rendered.png differ diff --git a/karma.conf.js b/karma.conf.js index 9134031cd..544c2ea9c 100644 --- a/karma.conf.js +++ b/karma.conf.js @@ -5,7 +5,7 @@ module.exports = function (config) { browsers: ['PhantomJS'], - singleRun: !!process.env.CONTINUOUS_INTEGRATION, + singleRun: !!process.env.CI, frameworks: [ 'mocha' ], diff --git a/package.json b/package.json index f4ee7bb23..ce8f29661 100644 --- a/package.json +++ b/package.json @@ -88,31 +88,29 @@ "compression": "^1.6.0", "express": "^4.13.3", "express-session": "^1.12.1", - "history": "1.17.0", "file-loader": "^0.8.5", - "hoist-non-react-statics": "^1.0.3", "http-proxy": "^1.12.0", "invariant": "^2.2.0", "less": "^2.5.3", "less-loader": "^2.2.1", "lru-memoize": "^1.0.0", "map-props": "^1.0.0", - "multireducer": "^1.0.2", + "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/CounterButton/CounterButton.js b/src/components/CounterButton/CounterButton.js index e9e5a8027..1f4d297cd 100644 --- a/src/components/CounterButton/CounterButton.js +++ b/src/components/CounterButton/CounterButton.js @@ -3,8 +3,9 @@ import {connectMultireducer} from 'multireducer'; import {increment} from 'redux/modules/counter'; @connectMultireducer( - state => ({count: state.count}), - {increment}) + (key, state) => ({count: state.multireducer[key].count}), + {increment} +) export default class CounterButton extends Component { static propTypes = { count: PropTypes.number, 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/config.js b/src/config.js index 4a9011e6e..2af69b427 100644 --- a/src/config.js +++ b/src/config.js @@ -30,9 +30,6 @@ module.exports = Object.assign({ {property: 'og:card', content: 'summary'}, {property: 'og:site', content: '@erikras'}, {property: 'og:creator', content: '@erikras'}, - {property: 'og:title', content: 'React Redux Example'}, - {property: 'og:description', content: 'All the modern best practices in one example.'}, - {property: 'og:image', content: 'https://react-redux.herokuapp.com/logo.jpg'}, {property: 'og:image:width', content: '200'}, {property: 'og:image:height', content: '200'} ] diff --git a/src/containers/App/App.js b/src/containers/App/App.js index d548da931..ef117f62c 100644 --- a/src/containers/App/App.js +++ b/src/containers/App/App.js @@ -2,30 +2,19 @@ import React, { Component, PropTypes } from 'react'; import { connect } from 'react-redux'; import { IndexLink } from 'react-router'; import { LinkContainer } from 'react-router-bootstrap'; -import { Navbar, Nav, NavItem } from 'react-bootstrap'; +import Navbar from 'react-bootstrap/lib/Navbar'; +import Nav from 'react-bootstrap/lib/Nav'; +import NavItem from 'react-bootstrap/lib/NavItem'; 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, @@ -41,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..c7fd98fa6 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,16 @@ 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())) { + const promise = dispatch(loadWidgets()); + if (__SERVER__) { + return promise; + } + } } 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 8865c0ae1..000000000 --- a/src/helpers/connectData.js +++ /dev/null @@ -1,23 +0,0 @@ -import React, { Component } from 'react'; -import hoistStatics from 'hoist-non-react-statics'; - -/* - 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 { - render() { - return ; - } - } - - ConnectData.fetchData = fetchData; - ConnectData.fetchDataDeferred = fetchDataDeferred; - - return hoistStatics(ConnectData, WrappedComponent); - }; -} 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) {