├── .gitignore
├── .npmignore
├── LICENSE
├── README.md
├── example
├── README.md
├── actions
│ └── index.js
├── client.js
├── components
│ ├── Explore.js
│ ├── List.js
│ ├── Repo.js
│ └── User.js
├── containers
│ ├── App.js
│ ├── RepoPage.js
│ ├── Root.js
│ └── UserPage.js
├── index.html
├── index.js
├── middleware
│ └── api.js
├── package.json
├── reducers
│ ├── index.js
│ └── paginate.js
├── server.js
└── store
│ └── index.js
├── package.json
└── src
└── index.js
/.gitignore:
--------------------------------------------------------------------------------
1 | lib-cov
2 | *.seed
3 | *.log
4 | *.csv
5 | *.dat
6 | *.out
7 | *.pid
8 | *.gz
9 | pids
10 | logs
11 | results
12 | npm-debug.log
13 | node_modules
14 | /lib
15 |
--------------------------------------------------------------------------------
/.npmignore:
--------------------------------------------------------------------------------
1 | lib-cov
2 | *.seed
3 | *.log
4 | *.csv
5 | *.dat
6 | *.out
7 | *.pid
8 | *.gz
9 | pids
10 | logs
11 | results
12 | npm-debug.log
13 | node_modules
14 | /src
15 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | Copyright (c) 2015 Forbes Lindesay
2 |
3 | Permission is hereby granted, free of charge, to any person obtaining a copy
4 | of this software and associated documentation files (the "Software"), to deal
5 | in the Software without restriction, including without limitation the rights
6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
7 | copies of the Software, and to permit persons to whom the Software is
8 | furnished to do so, subject to the following conditions:
9 |
10 | The above copyright notice and this permission notice shall be included in
11 | all copies or substantial portions of the Software.
12 |
13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
19 | THE SOFTWARE.
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # redux-wait
2 |
3 | A helper to let you wait for redux actions to be processed in a universal app.
4 |
5 | [](https://travis-ci.org/ForbesLindesay/redux-wait)
6 | [](https://david-dm.org/ForbesLindesay/redux-wait)
7 | [](https://www.npmjs.org/package/redux-wait)
8 |
9 | ## Installation
10 |
11 | npm install redux-wait
12 |
13 |
14 | ## Usage
15 |
16 | ### 1. Replace `applyMiddleware` with `redux-await`
17 |
18 |
19 | ```diff
20 | - var applyMiddleware = require('redux').applyMiddleware;
21 | + var applyMiddleware = require('redux-wait');
22 | ```
23 |
24 | This will add an extra method to your store called `store.renderToString`.
25 |
26 | ### 2. Ensure all middleware returns a promise (or runs synchronously)
27 |
28 | If you're not using any custom middleware and you're not using redux-thunk, you can skip this step.
29 |
30 | If you are writing asynchronous middleware, you need to make sure your middleware returns a promise
31 | that is only resolved once it has finished processing the action. [redux-promise](https://github.com/acdlite/redux-promise) is a perfect example of how to do this.
32 |
33 | If you are using redux-thunk, you can only use it synchronously, or it will break server rendering.
34 |
35 | Good:
36 |
37 | ```js
38 | // action creator
39 | function loadUser(login) {
40 | return (dispatch, getState) => {
41 | const user = getState().entities.users[login];
42 | if (user) {
43 | return null;
44 | }
45 |
46 | return dispatch(fetchUser(login));
47 | };
48 | }
49 | ```
50 |
51 | Bad:
52 |
53 | ```js
54 | // action creator
55 | function loadUser(login) {
56 | return (dispatch, getState) => {
57 | const user = getState().entities.users[login];
58 | if (user) {
59 | return null;
60 | }
61 |
62 | $.getJson('/user/' + login, function (data) {
63 | // The server won't wait for this action :(
64 | dispatch({type: 'LOADED_USER', user: data});
65 | });
66 | };
67 | }
68 | ```
69 |
70 | ### 3. Ensure that actions to load data are not fired if the data is alreay loading
71 |
72 | Good:
73 |
74 | ```js
75 | componentWillMount() {
76 | if (!this.props.isLoading && !this.props.user) {
77 | this.props.dispatch(loadUser());
78 | }
79 | }
80 | ```
81 |
82 | Bad:
83 |
84 | ```js
85 | componentWillMount() {
86 | if (!this.props.user) {
87 | this.props.dispatch(loadUser());
88 | }
89 | }
90 | ```
91 |
92 | ### 4. Render the page in your server
93 |
94 | ```js
95 | // N.B. `createStore` is the result of using redux-wait instead of Redux.applyMiddleware
96 | let store = createStore();
97 | let element = ;
98 | store.renderToString(React, element).done(function (html) {
99 | res.send(
100 | indexHtml.replace(
101 | '{{content}}',
102 | html
103 | ).replace(
104 | '{{state}}',
105 | stringify(store.getState())
106 | )
107 | );
108 | }, next);
109 | ```
110 |
111 | ## License
112 |
113 | MIT
114 |
--------------------------------------------------------------------------------
/example/README.md:
--------------------------------------------------------------------------------
1 |
2 |
3 | ### 1. Ensure "store" is passed in as a property to the root component
4 |
5 | `history` is also passed in as a property, this was already the case so is not marked as a diff.
6 |
7 | **N.B.** We are also including an INITIAL_STATE variable here, which we will populate later.
8 |
9 | /containers/Root.js
10 |
11 | ```diff
12 | import React, { Component } from 'react';
13 | import { Provider } from 'react-redux';
14 | import { Router, Route } from 'react-router';
15 | - import configureStore from '../store/configureStore';
16 | import App from './App';
17 | import UserPage from './UserPage';
18 | import RepoPage from './RepoPage';
19 |
20 | - const store = configureStore();
21 |
22 | export default class Root extends Component {
23 | render() {
24 | return (
25 |
26 | -
27 | +
28 | {() =>
29 |
30 |
31 |
33 |
35 |
36 |
37 | }
38 |
39 |
40 | );
41 | }
42 | }
43 | ```
44 |
45 | /client.js
46 |
47 | ```diff
48 | import 'babel-core/polyfill';
49 | import React from 'react';
50 | import BrowserHistory from 'react-router/lib/BrowserHistory';
51 | import Root from './containers/Root';
52 | + import createStore from './store';
53 |
54 | + const store = createStore(INITIAL_STATE);
55 |
56 | React.render(
57 | - ,
58 | + ,
59 | document.getElementById('root')
60 | );
61 | ```
62 |
63 | ### 2. Ensure in-flight requests are not duplicated
64 |
65 | The code already ensured that when data was already loaded, it was not re-requested. Unfortunately,
66 | it made a request for data on every `componentWillMount` call, even if the data had already been requested
67 | (but not loaded). This change keeps track of currently in-progress requests and skips the action if a request
68 | is already in flight.
69 |
70 | **N.B.** This asynchronous middleware returns a promise that is picked up by redux-wait.
71 |
72 | /middleware/api.js
73 |
74 | ```diff
75 | import { Schema, arrayOf, normalize } from 'normalizr';
76 | import { camelizeKeys } from 'humps';
77 | import 'isomorphic-fetch';
78 |
79 | + let inFlightRequests = {};
80 |
81 | ...
82 |
83 |
84 | /**
85 | * A Redux middleware that interprets actions with CALL_API info specified.
86 | * Performs the call and promises when such actions are dispatched.
87 | */
88 | export default store => next => action => {
89 | const callAPI = action[CALL_API];
90 | if (typeof callAPI === 'undefined') {
91 | return next(action);
92 | }
93 |
94 | let { endpoint } = callAPI;
95 | const { schema, types, bailout } = callAPI;
96 |
97 | if (typeof endpoint === 'function') {
98 | endpoint = endpoint(store.getState());
99 | }
100 |
101 | ...
102 |
103 | + if (inFlightRequests[endpoint]) {
104 | + return Promise.resolve();
105 | + }
106 | + inFlightRequests[endpoint] = true;
107 |
108 | const [requestType, successType, failureType] = types;
109 | next(actionWith({ type: requestType }));
110 |
111 | return callApi(endpoint, schema).then(
112 | response => {
113 | next(actionWith({
114 | response,
115 | type: successType
116 | }))
117 | + delete inFlightRequests[endpoint];
118 | },
119 | error => {
120 | next(actionWith({
121 | type: failureType,
122 | error: error.message || 'Something bad happened'
123 | }))
124 | + delete inFlightRequests[endpoint];
125 | + // on the server side, respond with 500 when an error happens
126 | + throw new Error(error.message || 'Something bad happened');
127 | }
128 | );
129 | };
130 | ```
131 |
132 | ### 3. Add method to store to support async rendering
133 |
134 | Replacing the build in `applyMiddleware` from `redux` with the `applyMiddleware` from `redux-wait` adds an additional `.renderToString` method to the store, which we will make use of later.
135 |
136 | **N.B.** if you use redux-thunk instead of promises for async it will break server side rendering. It can be used for conditional actions though (which is what it is used for in this project).
137 |
138 | /store/index.js
139 |
140 | ```diff
141 | - import { createStore, combineReducers, applyMiddleware } from 'redux';
142 | + import { createStore, combineReducers } from 'redux';
143 | + import applyMiddleware from 'redux-wait';
144 | import thunkMiddleware from 'redux-thunk';
145 | import apiMiddleware from '../middleware/api';
146 | import loggerMiddleware from 'redux-logger';
147 | import * as reducers from '../reducers';
148 |
149 | const reducer = combineReducers(reducers);
150 | const createStoreWithMiddleware = applyMiddleware(
151 | thunkMiddleware,
152 | apiMiddleware,
153 | loggerMiddleware
154 | )(createStore);
155 |
156 | /**
157 | * Creates a preconfigured store for this example.
158 | */
159 | export default function configureStore(initialState) {
160 | return createStoreWithMiddleware(reducer, initialState);
161 | }
162 | ```
163 |
164 | ### 4. Add placeholders for "content" and "initial state" in the HTML template
165 |
166 | /index.html
167 |
168 | ```diff
169 |
170 |
171 | Redux real-world example
172 |
173 |
174 | -
175 | + {{content}}
176 | +
177 |
178 |
179 |
180 | ```
181 |
182 | ### 5. Render the app server side
183 |
184 | `store.renderToString` is added by `redux-wait`. It returns a promise for the string representation of
185 | an element, but waits until there are no more pending actions before resolving.
186 |
187 | /server.js
188 |
189 | ```diff
190 | 'use strict';
191 |
192 | + import fs from 'fs';
193 | import express from 'express';
194 | import browserify from 'browserify-middleware';
195 | import React from 'react';
196 | + import stringify from 'js-stringify';
197 | + import Root from './containers/Root';
198 | + import MemoryHistory from 'react-router/lib/MemoryHistory';
199 | + import createStore from './store';
200 |
201 | + const store = createStore();
202 |
203 | const app = express();
204 |
205 | + const indexHtml = fs.readFileSync(__dirname + '/index.html', 'utf8');
206 |
207 | app.get('/static/bundle.js', browserify(
208 | __dirname + '/client.js',
209 | { transform: [require('babelify')] }
210 | ));
211 | app.use(function (req, res, next) {
212 | if (req.path === '/favicon.ico') return next();
213 | - res.sendFile(__dirname + '/index.html');
214 | + let store = createStore();
215 | + let element = ;
216 | + store.renderToString(React, element).done(function (html) {
217 | + res.send(indexHtml.replace(/\{\{([a-z]*)\}\}/g, function (_, name) {
218 | + if (name === 'content') return html;
219 | + if (name === 'state') return stringify(store.getState());
220 | + return _;
221 | + }));
222 | + }, next);
223 | });
224 | app.listen(3000, function (err) {
225 | if (err) {
226 | console.log(err);
227 | }
228 |
229 | console.log('Listening at localhost:3000');
230 | });
231 | ```
232 |
--------------------------------------------------------------------------------
/example/actions/index.js:
--------------------------------------------------------------------------------
1 | import { CALL_API, Schemas } from '../middleware/api';
2 |
3 | export const USER_REQUEST = 'USER_REQUEST';
4 | export const USER_SUCCESS = 'USER_SUCCESS';
5 | export const USER_FAILURE = 'USER_FAILURE';
6 | /**
7 | * Fetches a single user from Github API.
8 | * Relies on the custom API middleware defined in ../middleware/api.js.
9 | */
10 | function fetchUser(login) {
11 | return {
12 | [CALL_API]: {
13 | types: [USER_REQUEST, USER_SUCCESS, USER_FAILURE],
14 | endpoint: `users/${login}`,
15 | schema: Schemas.USER
16 | }
17 | };
18 | }
19 | /**
20 | * Fetches a single user from Github API unless it is cached.
21 | * Relies on Redux Thunk middleware.
22 | */
23 | export function loadUser(login, requiredFields = []) {
24 | return (dispatch, getState) => {
25 | const user = getState().entities.users[login];
26 | if (user && requiredFields.every(key => user.hasOwnProperty(key))) {
27 | return null;
28 | }
29 |
30 | return dispatch(fetchUser(login));
31 | };
32 | }
33 |
34 | export const REPO_REQUEST = 'REPO_REQUEST';
35 | export const REPO_SUCCESS = 'REPO_SUCCESS';
36 | export const REPO_FAILURE = 'REPO_FAILURE';
37 | /**
38 | * Fetches a single repository from Github API.
39 | * Relies on the custom API middleware defined in ../middleware/api.js.
40 | */
41 | function fetchRepo(fullName) {
42 | return {
43 | [CALL_API]: {
44 | types: [REPO_REQUEST, REPO_SUCCESS, REPO_FAILURE],
45 | endpoint: `repos/${fullName}`,
46 | schema: Schemas.REPO
47 | }
48 | };
49 | }
50 | /**
51 | * Fetches a single repository from Github API unless it is cached.
52 | * Relies on Redux Thunk middleware.
53 | */
54 | export function loadRepo(fullName, requiredFields = []) {
55 | return (dispatch, getState) => {
56 | const repo = getState().entities.repos[fullName];
57 | if (repo && requiredFields.every(key => repo.hasOwnProperty(key))) {
58 | return null;
59 | }
60 |
61 | return dispatch(fetchRepo(fullName));
62 | };
63 | }
64 |
65 | export const STARRED_REQUEST = 'STARRED_REQUEST';
66 | export const STARRED_SUCCESS = 'STARRED_SUCCESS';
67 | export const STARRED_FAILURE = 'STARRED_FAILURE';
68 | /**
69 | * Fetches a page of starred repos by a particular user.
70 | * Relies on the custom API middleware defined in ../middleware/api.js.
71 | */
72 | function fetchStarred(login, nextPageUrl) {
73 | return {
74 | login,
75 | [CALL_API]: {
76 | types: [STARRED_REQUEST, STARRED_SUCCESS, STARRED_FAILURE],
77 | endpoint: nextPageUrl,
78 | schema: Schemas.REPO_ARRAY
79 | }
80 | };
81 | }
82 | /**
83 | * Fetches a page of starred repos by a particular user.
84 | * Bails out if page is cached and user didn’t specifically request next page.
85 | * Relies on Redux Thunk middleware.
86 | */
87 | export function loadStarred(login, nextPage) {
88 | return (dispatch, getState) => {
89 | const {
90 | nextPageUrl = `users/${login}/starred`,
91 | pageCount = 0
92 | } = getState().pagination.starredByUser[login] || {};
93 |
94 | if (pageCount > 0 && !nextPage) {
95 | return null;
96 | }
97 |
98 | return dispatch(fetchStarred(login, nextPageUrl));
99 | };
100 | }
101 |
102 |
103 | export const STARGAZERS_REQUEST = 'STARGAZERS_REQUEST';
104 | export const STARGAZERS_SUCCESS = 'STARGAZERS_SUCCESS';
105 | export const STARGAZERS_FAILURE = 'STARGAZERS_FAILURE';
106 | /**
107 | * Fetches a page of stargazers for a particular repo.
108 | * Relies on the custom API middleware defined in ../middleware/api.js.
109 | */
110 | function fetchStargazers(fullName, nextPageUrl) {
111 | return {
112 | fullName,
113 | [CALL_API]: {
114 | types: [STARGAZERS_REQUEST, STARGAZERS_SUCCESS, STARGAZERS_FAILURE],
115 | endpoint: nextPageUrl,
116 | schema: Schemas.USER_ARRAY
117 | }
118 | };
119 | }
120 | /**
121 | * Fetches a page of stargazers for a particular repo.
122 | * Bails out if page is cached and user didn’t specifically request next page.
123 | * Relies on Redux Thunk middleware.
124 | */
125 | export function loadStargazers(fullName, nextPage) {
126 | return (dispatch, getState) => {
127 | const {
128 | nextPageUrl = `repos/${fullName}/stargazers`,
129 | pageCount = 0
130 | } = getState().pagination.stargazersByRepo[fullName] || {};
131 |
132 | if (pageCount > 0 && !nextPage) {
133 | return null;
134 | }
135 |
136 | return dispatch(fetchStargazers(fullName, nextPageUrl));
137 | };
138 | }
139 |
140 | export const RESET_ERROR_MESSAGE = 'RESET_ERROR_MESSAGE';
141 | /**
142 | * Resets the currently visible error message.
143 | */
144 | export function resetErrorMessage() {
145 | return {
146 | type: RESET_ERROR_MESSAGE
147 | };
148 | }
149 |
--------------------------------------------------------------------------------
/example/client.js:
--------------------------------------------------------------------------------
1 | import 'babel-core/polyfill';
2 | import React from 'react';
3 | import BrowserHistory from 'react-router/lib/BrowserHistory';
4 | import Root from './containers/Root';
5 | import createStore from './store';
6 |
7 | const store = createStore(INITIAL_STATE);
8 |
9 | React.render(
10 | ,
11 | document.getElementById('root')
12 | );
13 |
--------------------------------------------------------------------------------
/example/components/Explore.js:
--------------------------------------------------------------------------------
1 | import React, { Component, PropTypes, findDOMNode } from 'react';
2 |
3 | const GITHUB_REPO = 'https://github.com/gaearon/redux';
4 |
5 | export default class Explore extends Component {
6 | constructor(props) {
7 | super(props);
8 | this.handleKeyUp = this.handleKeyUp.bind(this);
9 | this.handleGoClick = this.handleGoClick.bind(this);
10 | }
11 |
12 | getInputValue() {
13 | return findDOMNode(this.refs.input).value;
14 | }
15 |
16 | setInputValue(val) {
17 | // Generally mutating DOM is a bad idea in React components,
18 | // but doing this for a single uncontrolled field is less fuss
19 | // than making it controlled and maintaining a state for it.
20 | findDOMNode(this.refs.input).value = val;
21 | }
22 |
23 | componentWillReceiveProps(nextProps) {
24 | if (nextProps.value !== this.props.value) {
25 | this.setInputValue(nextProps.value);
26 | }
27 | }
28 |
29 | render() {
30 | return (
31 |
32 |
Type a username or repo full name and hit 'Go':
33 |
37 |
38 | Go!
39 |
40 |
41 | Code on Github .
42 |
43 |
44 | );
45 | }
46 |
47 | handleKeyUp(e) {
48 | if (e.keyCode === 13) {
49 | this.handleGoClick();
50 | }
51 | }
52 |
53 | handleGoClick() {
54 | this.props.onChange(this.getInputValue())
55 | }
56 | }
57 |
58 | Explore.propTypes = {
59 | value: PropTypes.string.isRequired,
60 | onChange: PropTypes.func.isRequired
61 | };
62 |
--------------------------------------------------------------------------------
/example/components/List.js:
--------------------------------------------------------------------------------
1 | import React, { Component, PropTypes } from 'react';
2 |
3 | export default class List extends Component {
4 | render() {
5 | const {
6 | isFetching, nextPageUrl, pageCount,
7 | items, renderItem, loadingLabel
8 | } = this.props;
9 |
10 | const isEmpty = items.length === 0;
11 | if (isEmpty && isFetching) {
12 | return {loadingLabel} ;
13 | }
14 |
15 | const isLastPage = !nextPageUrl;
16 | if (isEmpty && isLastPage) {
17 | return Nothing here! ;
18 | }
19 |
20 | return (
21 |
22 | {items.map(renderItem)}
23 | {pageCount > 0 && !isLastPage && this.renderLoadMore()}
24 |
25 | );
26 | }
27 |
28 | renderLoadMore() {
29 | const { isFetching, onLoadMoreClick } = this.props;
30 | return (
31 |
34 | {isFetching ? 'Loading...' : 'Load More'}
35 |
36 | );
37 | }
38 | }
39 |
40 | List.propTypes = {
41 | loadingLabel: PropTypes.string.isRequired,
42 | isFetching: PropTypes.bool.isRequired,
43 | onLoadMoreClick: PropTypes.func.isRequired,
44 | nextPageUrl: PropTypes.string
45 | };
46 |
47 | List.defaultProps = {
48 | isFetching: true,
49 | loadingLabel: 'Loading...'
50 | };
51 |
--------------------------------------------------------------------------------
/example/components/Repo.js:
--------------------------------------------------------------------------------
1 | import React, { Component, PropTypes } from 'react';
2 | import { Link } from 'react-router';
3 |
4 | export default class Repo extends Component {
5 |
6 | render() {
7 | const { repo, owner } = this.props;
8 | const { login } = owner;
9 | const { name, description } = repo;
10 |
11 | return (
12 |
13 |
14 |
15 | {name}
16 |
17 | {' by '}
18 |
19 | {login}
20 |
21 |
22 | {description &&
23 |
{description}
24 | }
25 |
26 | );
27 | }
28 | }
29 |
30 | Repo.propTypes = {
31 | repo: PropTypes.shape({
32 | name: PropTypes.string.isRequired,
33 | description: PropTypes.string
34 | }).isRequired,
35 | owner: PropTypes.shape({
36 | login: PropTypes.string.isRequired
37 | }).isRequired
38 | };
39 |
--------------------------------------------------------------------------------
/example/components/User.js:
--------------------------------------------------------------------------------
1 | import React, { Component, PropTypes } from 'react';
2 | import { Link } from 'react-router';
3 |
4 | export default class User extends Component {
5 | render() {
6 | const { login, avatarUrl, name } = this.props.user;
7 |
8 | return (
9 |
10 |
11 |
12 |
13 | {login} {name && ({name}) }
14 |
15 |
16 |
17 | );
18 | }
19 | }
20 |
21 | User.propTypes = {
22 | user: PropTypes.shape({
23 | login: PropTypes.string.isRequired,
24 | avatarUrl: PropTypes.string.isRequired,
25 | name: PropTypes.string
26 | }).isRequired
27 | };
28 |
--------------------------------------------------------------------------------
/example/containers/App.js:
--------------------------------------------------------------------------------
1 | import React, { Component, PropTypes } from 'react';
2 | import { connect } from 'react-redux';
3 | import Explore from '../components/Explore';
4 | import { resetErrorMessage } from '../actions';
5 |
6 | class App extends Component {
7 | constructor(props) {
8 | super(props);
9 | this.handleChange = this.handleChange.bind(this);
10 | this.handleDismissClick = this.handleDismissClick.bind(this);
11 | }
12 |
13 | render() {
14 | // Injected by React Router
15 | const { location, children } = this.props;
16 | const { pathname } = location;
17 | const value = pathname.substring(1);
18 |
19 | return (
20 |
21 |
23 |
24 | {this.renderErrorMessage()}
25 | {children}
26 |
27 | );
28 | }
29 |
30 | renderErrorMessage() {
31 | const { errorMessage } = this.props;
32 | if (!errorMessage) {
33 | return null;
34 | }
35 |
36 | return (
37 |
38 | {errorMessage}
39 | {' '}
40 | (
42 | Dismiss
43 | )
44 |
45 | );
46 | }
47 |
48 | handleDismissClick(e) {
49 | this.props.resetErrorMessage();
50 | e.preventDefault();
51 | }
52 |
53 | handleChange(nextValue) {
54 | // Available thanks to contextTypes below
55 | const { router } = this.context;
56 | router.transitionTo(`/${nextValue}`);
57 | }
58 | }
59 |
60 | App.propTypes = {
61 | errorMessage: PropTypes.string,
62 | location: PropTypes.shape({
63 | pathname: PropTypes.string.isRequired
64 | }),
65 | params: PropTypes.shape({
66 | userLogin: PropTypes.string,
67 | repoName: PropTypes.string
68 | }).isRequired
69 | };
70 |
71 | App.contextTypes = {
72 | router: PropTypes.object.isRequired
73 | };
74 |
75 | function mapStateToProps(state) {
76 | return {
77 | errorMessage: state.errorMessage
78 | };
79 | }
80 |
81 | export default connect(
82 | mapStateToProps,
83 | { resetErrorMessage }
84 | )(App);
85 |
--------------------------------------------------------------------------------
/example/containers/RepoPage.js:
--------------------------------------------------------------------------------
1 | import React, { Component, PropTypes } from 'react';
2 | import { connect } from 'react-redux';
3 | import { loadRepo, loadStargazers } from '../actions';
4 | import Repo from '../components/Repo';
5 | import User from '../components/User';
6 | import List from '../components/List';
7 |
8 | function loadData(props) {
9 | const { fullName } = props;
10 | props.loadRepo(fullName, ['description']);
11 | props.loadStargazers(fullName);
12 | }
13 |
14 | class RepoPage extends Component {
15 | constructor(props) {
16 | super(props);
17 | this.renderUser = this.renderUser.bind(this);
18 | this.handleLoadMoreClick = this.handleLoadMoreClick.bind(this);
19 | }
20 |
21 | componentWillMount() {
22 | loadData(this.props);
23 | }
24 |
25 | componentWillReceiveProps(nextProps) {
26 | if (nextProps.fullName !== this.props.fullName) {
27 | loadData(nextProps);
28 | }
29 | }
30 |
31 | render() {
32 | const { repo, owner, name } = this.props;
33 | if (!repo || !owner) {
34 | return Loading {name} details... ;
35 | }
36 |
37 | const { stargazers, stargazersPagination } = this.props;
38 | return (
39 |
40 |
42 |
43 |
48 |
49 | );
50 | }
51 |
52 | renderUser(user) {
53 | return (
54 |
56 | );
57 | }
58 |
59 | handleLoadMoreClick() {
60 | this.props.loadStargazers(this.props.fullName, true);
61 | }
62 | }
63 |
64 | RepoPage.propTypes = {
65 | repo: PropTypes.object,
66 | fullName: PropTypes.string.isRequired,
67 | name: PropTypes.string.isRequired,
68 | stargazers: PropTypes.array.isRequired,
69 | stargazersPagination: PropTypes.object,
70 | loadRepo: PropTypes.func.isRequired,
71 | loadStargazers: PropTypes.func.isRequired
72 | };
73 |
74 | function mapStateToProps(state) {
75 | return {
76 | entities: state.entities,
77 | stargazersByRepo: state.pagination.stargazersByRepo
78 | };
79 | }
80 |
81 | function mergeProps(stateProps, dispatchProps, ownProps) {
82 | const { entities, stargazersByRepo } = stateProps;
83 | const { login, name } = ownProps.params;
84 |
85 | const fullName = `${login}/${name}`;
86 | const repo = entities.repos[fullName];
87 | const owner = entities.users[login];
88 |
89 | const stargazersPagination = stargazersByRepo[fullName] || { ids: [] };
90 | const stargazers = stargazersPagination.ids.map(id => entities.users[id]);
91 |
92 | return Object.assign({}, dispatchProps, {
93 | fullName,
94 | name,
95 | repo,
96 | owner,
97 | stargazers,
98 | stargazersPagination
99 | });
100 | }
101 |
102 | export default connect(
103 | mapStateToProps,
104 | { loadRepo, loadStargazers },
105 | mergeProps
106 | )(RepoPage);
107 |
--------------------------------------------------------------------------------
/example/containers/Root.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react';
2 | import { Provider } from 'react-redux';
3 | import { Router, Route } from 'react-router';
4 | import App from './App';
5 | import UserPage from './UserPage';
6 | import RepoPage from './RepoPage';
7 |
8 | export default class Root extends Component {
9 | render() {
10 | return (
11 |
12 |
13 | {() =>
14 |
15 |
16 |
18 |
20 |
21 |
22 | }
23 |
24 |
25 | );
26 | }
27 | }
28 |
--------------------------------------------------------------------------------
/example/containers/UserPage.js:
--------------------------------------------------------------------------------
1 | import React, { Component, PropTypes } from 'react';
2 | import { connect } from 'react-redux';
3 | import { loadUser, loadStarred } from '../actions';
4 | import User from '../components/User';
5 | import Repo from '../components/Repo';
6 | import List from '../components/List';
7 | import zip from 'lodash/array/zip';
8 |
9 | function loadData(props) {
10 | const { login } = props;
11 | props.loadUser(login, ['name']);
12 | props.loadStarred(login);
13 | }
14 |
15 | class UserPage extends Component {
16 | constructor(props) {
17 | super(props);
18 | this.renderRepo = this.renderRepo.bind(this);
19 | this.handleLoadMoreClick = this.handleLoadMoreClick.bind(this);
20 | }
21 |
22 | componentWillMount() {
23 | loadData(this.props);
24 | }
25 |
26 | componentWillReceiveProps(nextProps) {
27 | if (nextProps.login !== this.props.login) {
28 | loadData(nextProps);
29 | }
30 | }
31 |
32 | render() {
33 | const { user, login } = this.props;
34 | if (!user) {
35 | return Loading {login}’s profile... ;
36 | }
37 |
38 | const { starredRepos, starredRepoOwners, starredPagination } = this.props;
39 | return (
40 |
41 |
42 |
43 |
48 |
49 | );
50 | }
51 |
52 | renderRepo([repo, owner]) {
53 | return (
54 |
57 | );
58 | }
59 |
60 | handleLoadMoreClick() {
61 | this.props.loadStarred(this.props.login, true);
62 | }
63 | }
64 |
65 | UserPage.propTypes = {
66 | login: PropTypes.string.isRequired,
67 | user: PropTypes.object,
68 | starredPagination: PropTypes.object,
69 | starredRepos: PropTypes.array.isRequired,
70 | starredRepoOwners: PropTypes.array.isRequired,
71 | loadUser: PropTypes.func.isRequired,
72 | loadStarred: PropTypes.func.isRequired
73 | };
74 |
75 | function mapStateToProps(state) {
76 | return {
77 | entities: state.entities,
78 | starredByUser: state.pagination.starredByUser
79 | };
80 | }
81 |
82 | function mergeProps(stateProps, dispatchProps, ownProps) {
83 | const { entities, starredByUser } = stateProps;
84 | const { login } = ownProps.params;
85 |
86 | const user = entities.users[login];
87 | const starredPagination = starredByUser[login] || { ids: [] };
88 | const starredRepos = starredPagination.ids.map(id => entities.repos[id]);
89 | const starredRepoOwners = starredRepos.map(repo => entities.users[repo.owner]);
90 |
91 | return Object.assign({}, dispatchProps, {
92 | login,
93 | user,
94 | starredPagination,
95 | starredRepos,
96 | starredRepoOwners
97 | });
98 | }
99 |
100 | export default connect(
101 | mapStateToProps,
102 | { loadUser, loadStarred },
103 | mergeProps
104 | )(UserPage);
105 |
--------------------------------------------------------------------------------
/example/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 | Redux real-world example
4 |
5 |
6 | {{content}}
7 |
8 |
9 |
10 |
11 |
--------------------------------------------------------------------------------
/example/index.js:
--------------------------------------------------------------------------------
1 | require('babel/register');
2 | require('./server.js');
3 |
--------------------------------------------------------------------------------
/example/middleware/api.js:
--------------------------------------------------------------------------------
1 | import { Schema, arrayOf, normalize } from 'normalizr';
2 | import { camelizeKeys } from 'humps';
3 | import 'isomorphic-fetch';
4 |
5 | let inFlightRequests = {};
6 |
7 | /**
8 | * Extracts the next page URL from Github API response.
9 | */
10 | function getNextPageUrl(response) {
11 | const link = response.headers.get('link');
12 | if (!link) {
13 | return null;
14 | }
15 |
16 | const nextLink = link.split(',').filter(s => s.indexOf('rel="next"') > -1)[0];
17 | if (!nextLink) {
18 | return null;
19 | }
20 |
21 | return nextLink.split(';')[0].slice(1, -1);
22 | }
23 |
24 | const API_ROOT = 'https://api.github.com/';
25 |
26 | /**
27 | * Fetches an API response and normalizes the result JSON according to schema.
28 | * This makes every API response have the same shape, regardless of how nested it was.
29 | */
30 | function callApi(endpoint, schema) {
31 | if (endpoint.indexOf(API_ROOT) === -1) {
32 | endpoint = API_ROOT + endpoint;
33 | }
34 |
35 | return fetch(endpoint)
36 | .then(response =>
37 | response.json().then(json => ({ json, response}))
38 | ).then(({ json, response }) => {
39 | if (!response.ok) {
40 | return Promise.reject(json);
41 | }
42 |
43 | const camelizedJson = camelizeKeys(json);
44 | const nextPageUrl = getNextPageUrl(response) || undefined;
45 |
46 | return Object.assign({},
47 | normalize(camelizedJson, schema),
48 | { nextPageUrl }
49 | );
50 | });
51 | }
52 |
53 | // We use this Normalizr schemas to transform API responses from a nested form
54 | // to a flat form where repos and users are placed in `entities`, and nested
55 | // JSON objects are replaced with their IDs. This is very convenient for
56 | // consumption by reducers, because we can easily build a normalized tree
57 | // and keep it updated as we fetch more data.
58 |
59 | // Read more about Normalizr: https://github.com/gaearon/normalizr
60 |
61 | const userSchema = new Schema('users', {
62 | idAttribute: 'login'
63 | });
64 |
65 | const repoSchema = new Schema('repos', {
66 | idAttribute: 'fullName'
67 | });
68 |
69 | repoSchema.define({
70 | owner: userSchema
71 | });
72 |
73 | /**
74 | * Schemas for Github API responses.
75 | */
76 | export const Schemas = {
77 | USER: userSchema,
78 | USER_ARRAY: arrayOf(userSchema),
79 | REPO: repoSchema,
80 | REPO_ARRAY: arrayOf(repoSchema)
81 | };
82 |
83 | /**
84 | * Action key that carries API call info interpreted by this Redux middleware.
85 | */
86 | export const CALL_API = Symbol('Call API');
87 |
88 | /**
89 | * A Redux middleware that interprets actions with CALL_API info specified.
90 | * Performs the call and promises when such actions are dispatched.
91 | */
92 | export default store => next => action => {
93 | const callAPI = action[CALL_API];
94 | if (typeof callAPI === 'undefined') {
95 | return next(action);
96 | }
97 |
98 | let { endpoint } = callAPI;
99 | const { schema, types, bailout } = callAPI;
100 |
101 | if (typeof endpoint === 'function') {
102 | endpoint = endpoint(store.getState());
103 | }
104 |
105 | if (typeof endpoint !== 'string') {
106 | throw new Error('Specify a string endpoint URL.');
107 | }
108 | if (!schema) {
109 | throw new Error('Specify one of the exported Schemas.');
110 | }
111 | if (!Array.isArray(types) || types.length !== 3) {
112 | throw new Error('Expected an array of three action types.');
113 | }
114 | if (!types.every(type => typeof type === 'string')) {
115 | throw new Error('Expected action types to be strings.');
116 | }
117 | if (typeof bailout !== 'undefined' && typeof bailout !== 'function') {
118 | throw new Error('Expected bailout to either be undefined or a function.');
119 | }
120 |
121 | if (bailout && bailout(store.getState())) {
122 | return Promise.resolve();
123 | }
124 |
125 | function actionWith(data) {
126 | const finalAction = Object.assign({}, action, data);
127 | delete finalAction[CALL_API];
128 | return finalAction;
129 | }
130 |
131 | if (inFlightRequests[endpoint]) {
132 | return Promise.resolve();
133 | }
134 | inFlightRequests[endpoint] = true;
135 |
136 | const [requestType, successType, failureType] = types;
137 | next(actionWith({ type: requestType }));
138 |
139 | return callApi(endpoint, schema).then(
140 | response => {
141 | next(actionWith({
142 | response,
143 | type: successType
144 | }))
145 | delete inFlightRequests[endpoint];
146 | },
147 | error => {
148 | next(actionWith({
149 | type: failureType,
150 | error: error.message || 'Something bad happened'
151 | }))
152 | delete inFlightRequests[endpoint];
153 | // on the server side, respond with 500 when an error happens
154 | throw new Error(error.message || 'Something bad happened');
155 | }
156 | );
157 | };
158 |
--------------------------------------------------------------------------------
/example/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "redux-real-world-example",
3 | "version": "0.0.0",
4 | "description": "Redux real-world example",
5 | "scripts": {
6 | "start": "node index.js"
7 | },
8 | "repository": {
9 | "type": "git",
10 | "url": "https://github.com/rackt/redux.git"
11 | },
12 | "license": "MIT",
13 | "bugs": {
14 | "url": "https://github.com/rackt/redux/issues"
15 | },
16 | "homepage": "http://rackt.github.io/redux",
17 | "dependencies": {
18 | "babelify": "^6.1.3",
19 | "browserify-middleware": "^7.0.0",
20 | "express": "^4.13.3",
21 | "humps": "^0.6.0",
22 | "isomorphic-fetch": "^2.1.1",
23 | "js-stringify": "^1.0.1",
24 | "lodash": "^3.10.1",
25 | "normalizr": "^0.1.3",
26 | "react": "^0.13.3",
27 | "react-redux": "^0.8.0",
28 | "react-router": "^1.0.0-beta3",
29 | "redux": "^1.0.0-rc",
30 | "redux-logger": "0.0.3",
31 | "redux-thunk": "^0.1.0"
32 | }
33 | }
34 |
--------------------------------------------------------------------------------
/example/reducers/index.js:
--------------------------------------------------------------------------------
1 | import * as ActionTypes from '../actions';
2 | import merge from 'lodash/object/merge';
3 | import paginate from './paginate';
4 | import { combineReducers } from 'redux';
5 |
6 | /**
7 | * Updates an entity cache in response to any action with response.entities.
8 | */
9 | export function entities(state = { users: {}, repos: {} }, action) {
10 | if (action.response && action.response.entities) {
11 | return merge({}, state, action.response.entities);
12 | }
13 |
14 | return state;
15 | }
16 |
17 | /**
18 | * Updates error message to notify about the failed fetches.
19 | */
20 | export function errorMessage(state = null, action) {
21 | const { type, error } = action;
22 |
23 | if (type === ActionTypes.RESET_ERROR_MESSAGE) {
24 | return null;
25 | } else if (error) {
26 | return action.error;
27 | }
28 |
29 | return state;
30 | }
31 |
32 | /**
33 | * Updates the pagination data for different actions.
34 | */
35 | export const pagination = combineReducers({
36 | starredByUser: paginate({
37 | mapActionToKey: action => action.login,
38 | types: [
39 | ActionTypes.STARRED_REQUEST,
40 | ActionTypes.STARRED_SUCCESS,
41 | ActionTypes.STARRED_FAILURE
42 | ]
43 | }),
44 | stargazersByRepo: paginate({
45 | mapActionToKey: action => action.fullName,
46 | types: [
47 | ActionTypes.STARGAZERS_REQUEST,
48 | ActionTypes.STARGAZERS_SUCCESS,
49 | ActionTypes.STARGAZERS_FAILURE
50 | ]
51 | })
52 | });
53 |
--------------------------------------------------------------------------------
/example/reducers/paginate.js:
--------------------------------------------------------------------------------
1 | import merge from 'lodash/object/merge';
2 | import union from 'lodash/array/union';
3 |
4 | /**
5 | * Creates a reducer managing pagination, given the action types to handle,
6 | * and a function telling how to extract the key from an action.
7 | */
8 | export default function paginate({ types, mapActionToKey }) {
9 | if (!Array.isArray(types) || types.length !== 3) {
10 | throw new Error('Expected types to be an array of three elements.');
11 | }
12 | if (!types.every(t => typeof t === 'string')) {
13 | throw new Error('Expected types to be strings.');
14 | }
15 | if (typeof mapActionToKey !== 'function') {
16 | throw new Error('Expected mapActionToKey to be a function.');
17 | }
18 |
19 | const [requestType, successType, failureType] = types;
20 |
21 | function updatePagination(state = {
22 | isFetching: false,
23 | nextPageUrl: undefined,
24 | pageCount: 0,
25 | ids: []
26 | }, action) {
27 | switch (action.type) {
28 | case requestType:
29 | return merge({}, state, {
30 | isFetching: true
31 | });
32 | case successType:
33 | return merge({}, state, {
34 | isFetching: false,
35 | ids: union(state.ids, action.response.result),
36 | nextPageUrl: action.response.nextPageUrl,
37 | pageCount: state.pageCount + 1
38 | });
39 | case failureType:
40 | return merge({}, state, {
41 | isFetching: false
42 | });
43 | default:
44 | return state;
45 | }
46 | }
47 |
48 | return function updatePaginationByKey(state = {}, action) {
49 | switch (action.type) {
50 | case requestType:
51 | case successType:
52 | case failureType:
53 | const key = mapActionToKey(action);
54 | if (typeof key !== 'string') {
55 | throw new Error('Expected key to be a string.');
56 | }
57 | return merge({}, state, {
58 | [key]: updatePagination(state[key], action)
59 | });
60 | default:
61 | return state;
62 | }
63 | };
64 | }
65 |
--------------------------------------------------------------------------------
/example/server.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | import fs from 'fs';
4 | import express from 'express';
5 | import browserify from 'browserify-middleware';
6 | import React from 'react';
7 | import stringify from 'js-stringify';
8 | import Root from './containers/Root';
9 | import MemoryHistory from 'react-router/lib/MemoryHistory';
10 | import createStore from './store';
11 |
12 | const store = createStore();
13 |
14 | const app = express();
15 |
16 | const indexHtml = fs.readFileSync(__dirname + '/index.html', 'utf8');
17 |
18 | app.get('/static/bundle.js', browserify(
19 | __dirname + '/client.js',
20 | { transform: [require('babelify')] }
21 | ));
22 | app.use(function (req, res, next) {
23 | if (req.path === '/favicon.ico') return next();
24 | let store = createStore();
25 | let element = ;
26 | store.renderToString(React, element).done(function (html) {
27 | res.send(indexHtml.replace(/\{\{([a-z]*)\}\}/g, function (_, name) {
28 | if (name === 'content') return html;
29 | if (name === 'state') return stringify(store.getState());
30 | return _;
31 | }));
32 | }, next);
33 | });
34 | app.listen(3000, function (err) {
35 | if (err) {
36 | console.log(err);
37 | }
38 |
39 | console.log('Listening at localhost:3000');
40 | });
41 |
--------------------------------------------------------------------------------
/example/store/index.js:
--------------------------------------------------------------------------------
1 | import { createStore, combineReducers } from 'redux';
2 | import applyMiddleware from '../../';
3 | import thunkMiddleware from 'redux-thunk';
4 | import apiMiddleware from '../middleware/api';
5 | import loggerMiddleware from 'redux-logger';
6 | import * as reducers from '../reducers';
7 |
8 | function basicLoggerMiddleware({ getState }) {
9 | return (next) => (action) => {
10 | const time = new Date();
11 | const message = `action ${action.type} @ ${time.getHours()}:${time.getMinutes()}:${time.getSeconds()}`;
12 |
13 | console.log(message);
14 |
15 | return next(action);
16 | };
17 | }
18 | const reducer = combineReducers(reducers);
19 | const createStoreWithMiddleware = applyMiddleware(
20 | thunkMiddleware,
21 | apiMiddleware,
22 | typeof console !== 'undefined' && console && console.group ? loggerMiddleware : basicLoggerMiddleware
23 | )(createStore);
24 |
25 | /**
26 | * Creates a preconfigured store for this example.
27 | */
28 | export default function configureStore(initialState) {
29 | return createStoreWithMiddleware(reducer, initialState);
30 | }
31 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "redux-wait",
3 | "version": "1.0.0",
4 | "main": "./lib/index.js",
5 | "description": "A helper to let you wait for redux actions to be processed in a universal app",
6 | "keywords": [],
7 | "dependencies": {
8 | "is-promise": "^2.0.0",
9 | "promise": "^7.0.4"
10 | },
11 | "devDependencies": {
12 | "babel": "^5.8.21"
13 | },
14 | "scripts": {
15 | "prepublish": "npm run build",
16 | "build": "babel src --out-dir lib",
17 | "test": "mocha -R spec"
18 | },
19 | "repository": {
20 | "type": "git",
21 | "url": "https://github.com/ForbesLindesay/redux-wait.git"
22 | },
23 | "author": "ForbesLindesay",
24 | "license": "MIT"
25 | }
26 |
--------------------------------------------------------------------------------
/src/index.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | import Promise from 'promise';
4 | import isPromise from 'is-promise';
5 |
6 | function noop() {}
7 | export default function reduxWait(...middlewares) {
8 | return function (next) {
9 | return function (reducer, initialState) {
10 | var store = next(reducer, initialState);
11 | var dispatch = store.dispatch;
12 | var chain = [];
13 |
14 | var pending = 0, onSuccess, onFailure;
15 | function handleWatingOnMiddleware(middleware) {
16 | return action => {
17 | let result = middleware(action);
18 | if (isPromise(result)) {
19 | pending++;
20 | Promise.resolve(result).done(function () {
21 | pending--;
22 | if (pending === 0 && onSuccess) onSuccess();
23 | }, function (err) {
24 | if (onFailure) onFailure(err);
25 | else throw err;
26 | });
27 | }
28 | return result;
29 | }
30 | }
31 | var middlewareAPI = {
32 | getState: store.getState,
33 | dispatch: (action) => dispatch(action)
34 | };
35 | chain = middlewares.map(
36 | middleware => middleware(middlewareAPI)
37 | ).map(
38 | middleware => next => handleWatingOnMiddleware(middleware(next))
39 | );
40 | dispatch = compose(...chain, store.dispatch);
41 |
42 | function renderToString(React, element) {
43 | return new Promise(function (resolve, reject) {
44 | let html = '', resolved = false;
45 | let dirty = false, inProgress = false;
46 | onFailure = (err) => {
47 | resolved = true;
48 | reject(err);
49 | };
50 | onSuccess = () => {
51 | resolved = true;
52 | resolve(html)
53 | };
54 | function render() {
55 | if (resolved) return;
56 | dirty = true;
57 | if (inProgress) return;
58 | inProgress = true;
59 | while (dirty && !resolved) {
60 | dirty = false;
61 | html = React.renderToString(element);
62 | }
63 | inProgress = false;
64 | }
65 | store.subscribe(render);
66 | render();
67 | if (pending === 0) onSuccess();
68 | });
69 | }
70 | return {
71 | ...store,
72 | dispatch,
73 | renderToString
74 | };
75 | };
76 | };
77 | }
78 |
79 | function compose(...funcs) {
80 | return funcs.reduceRight((composed, f) => f(composed));
81 | }
82 |
--------------------------------------------------------------------------------