├── .editorconfig ├── .gitignore ├── .npmignore ├── .npmrc ├── LICENSE ├── README.md ├── jest.config.js ├── package-lock.json ├── package.json ├── rollup.config.js ├── src ├── __tests__ │ └── wait-for.spec.ts └── wait-for.ts ├── tsconfig.json └── tslint.json /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | indent_style = space 6 | indent_size = 2 7 | end_of_line = lf 8 | charset = utf-8 9 | trim_trailing_whitespace = true 10 | insert_final_newline = true 11 | 12 | [*.md] 13 | trim_trailing_whitespace = false 14 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea/ 2 | node_modules/ 3 | .rpt2_cache 4 | dist/ 5 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | .idea/ 2 | node_modules/ 3 | .rpt2_cache 4 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | save-exact = true -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2018, Filidor Wiese 2 | 3 | Permission to use, copy, modify, and/or distribute this software for any 4 | purpose with or without fee is hereby granted, provided that the above 5 | copyright notice and this permission notice appear in all copies. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES 8 | WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF 9 | MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR 10 | ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES 11 | WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN 12 | ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF 13 | OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. 14 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # redux-wait-for-ssr 2 | Redux middleware which provides an action that returns a promise that either: 3 | * resolves when specified actions have occurred 4 | * rejects when a given timeout has passed (default: 10s) 5 | * optionally rejects when a given error action has occurred 6 | 7 | ### Use case: 8 | When using Redux on the server-side (for SEO and performance purposes), you'll very likely want to prefetch some data to prepopulate the state when rendering the initial html markup of the requested page. A typical pattern for this is to dispatch the needed api calls from a static `fetchData` (or `getInitialProps`) method on the page component, which is first called on the server-side, and possibly again in `componentDidMount` for soft route changes. 9 | 10 | Roughly, this pattern looks like: 11 | 12 | ```js 13 | class PageComponent extends React.Component { 14 | static fetchData ({ dispatch }) { 15 | dispatch(actions.FETCH_CONTENT) 16 | } 17 | 18 | componentDidMount () { 19 | if (!this.props.contentLoaded) { 20 | this.props.dispatch(actions.FETCH_CONTENT) 21 | } 22 | } 23 | } 24 | ``` 25 | 26 | However that doesn't yet solve waiting for the api call to actually complete. This library helps with that by offering a Redux action that you can **async/await** in the `fetchData` method so that the server-side will wait for the asynchronous action to complete, before entering the render() method. 27 | 28 | ### API signature: 29 | 30 | `waitFor(actions, timeout, errorAction)` 31 | 32 | 33 | | Parameter | Type | Optional | Meaning | 34 | | ---------------- | ---------------- | ---------------- | ---------------- | 35 | | actions | array of strings | no | to specify Redux action(s) which have to occur before the promise is resolved, conceptually similar to `Promise.all()`. | 36 | | timeout | number | yes | auto-rejects after timeout, defaults to `10000` milliseconds | 37 | | errorAction | string | yes | a Redux action to immediately reject on | 38 | 39 | Returns a promise 40 | 41 | ### Example usage: 42 | 43 | ```js 44 | import { waitFor } from 'redux-wait-for-ssr' 45 | 46 | class PageComponent extends React.Component { 47 | static async fetchData ({ dispatch }) { 48 | 49 | dispatch(actions.FETCH_CONTENT) 50 | 51 | await dispatch(waitFor([actions.FETCH_CONTENT_RESOLVED])) // <- multiple actions allowed! 52 | } 53 | 54 | componentDidMount () { 55 | if (!this.props.contentLoaded) { 56 | this.props.dispatch(actions.FETCH_CONTENT) 57 | } 58 | } 59 | } 60 | ``` 61 | Note: 62 | 63 | * It doesn't really matter which other middleware you're using, thunks, sagas or epics, as long as you dispatch a new action after the side-effect has completed, you can "wait for it". 64 | * If you're a Next.js user, see usage below! 65 | 66 | ### Error Handling: 67 | 68 | In order to prevent hanging promises on the server-side, the promise is auto-rejected after a set timeout of 10 seconds. 69 | You can change this with the `timeout` parameter: `waitFor([actions], 1000)`. 70 | 71 | With the `errorActoin` parameter you can specify an error action that would immediately reject the promise if it occurs. 72 | 73 | Using a **try/catch** block you could handle these rejections gracefully: 74 | 75 | ```js 76 | import { waitFor } from 'redux-wait-for-ssr' 77 | 78 | class PageComponent extends React.Component { 79 | static async fetchData ({ dispatch }) { 80 | 81 | dispatch(actions.FETCH_CONTENT) 82 | 83 | try { 84 | await dispatch(waitFor([actions.FETCH_CONTENT_RESOLVED], 1000, actions.FETCH_CONTENT_REJECTED)) // <- multiple actions allowed! 85 | } catch (e) { 86 | // handle error gracefully, for example return a 404 header 87 | } 88 | } 89 | 90 | componentDidMount () { 91 | if (!this.props.contentLoaded) { 92 | this.props.dispatch(actions.FETCH_CONTENT) 93 | } 94 | } 95 | } 96 | ``` 97 | 98 | ### Installation: 99 | 1. Download 100 | ``` 101 | npm install redux-wait-for-ssr --save 102 | ``` 103 | 104 | 2. Apply middleware when creating the store: 105 | 106 | ```js 107 | import createWaitForMiddleware from 'redux-wait-for-ssr' 108 | 109 | function makeStore(initialState) { 110 | let enhancer = compose( 111 | // ...other middleware 112 | applyMiddleware(createWaitForMiddleware().middleware), 113 | // ...even more middleware 114 | ) 115 | return createStore(rootReducer, initialState, enhancer) 116 | } 117 | ``` 118 | 119 | 3. Make sure the static method is called on the server-side. How entirely depends on your setup, if you have no clue at this point, I suggest you look at [Next.js](https://github.com/zeit/next.js/) which simplifies SSR for React and is pretty awesome :metal: 120 | 121 | ### Next.js usage: 122 | With Next.js you get SSR out-of-the-box. After you've implemented Redux and applied the `redux-wait-for-ssr` middleware, you could use it as follows: 123 | 124 | ```js 125 | class IndexPage extends React.PureComponent { 126 | static async getInitialProps({reduxStore}) { 127 | const currentState = reduxStore.getState() 128 | 129 | // Prevents re-fetching of data 130 | const isContentLoaded = selectors.isContentLoaded(currentState) 131 | if (!isContentLoaded) { 132 | reduxStore.dispatch(actions.FETCH_CONTENT) 133 | await reduxStore.dispatch(waitFor([actions.FETCH_CONTENT_RESOLVED])) 134 | } 135 | 136 | return {} // Still useable to return whatever you want as pageProps 137 | } 138 | } 139 | ``` 140 | 141 | Since `getInitialProps` is re-used for soft url changes as well, the above is sufficient to implement data fetching for both the client and server. The `selectors.isContentLoaded` Redux selector is something you need to implement yourself, it could be as simple as: 142 | 143 | ```js 144 | export const isContentLoaded(state: StoreState): boolean => { 145 | return state.content.isLoaded; 146 | } 147 | ``` 148 | 149 | And in the reducer you would set `state.content.isLoaded` to true when the `actions.FETCH_CONTENT_RESOLVED` event has occurred: 150 | 151 | ```js 152 | export function reducers(state: StoreState, action: Actions): StoreState { 153 | switch (action.type) { 154 | case constants.FETCH_CONTENT_RESOLVED: { 155 | state = { 156 | ...state, 157 | content: { 158 | data: action.response, 159 | isLoaded: true 160 | } 161 | } 162 | return state 163 | } 164 | } 165 | } 166 | ``` 167 | 168 | 169 | This way you can keep track of requests that have been resolved in the state. 170 | 171 | Note the above example is pure illustrative, your mileage may vary. 172 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | "roots": [ 3 | "/src" 4 | ], 5 | "transform": { 6 | "^.+\\.tsx?$": "ts-jest" 7 | }, 8 | "testRegex": "(/__tests__/.*|(\\.|/)(test|spec))\\.tsx?$", 9 | "moduleFileExtensions": [ 10 | "ts", 11 | "tsx", 12 | "js", 13 | "jsx", 14 | "json", 15 | "node" 16 | ] 17 | } 18 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "redux-wait-for-ssr", 3 | "version": "1.2.2", 4 | "description": "Redux middleware that waits for specified actions to have occurred", 5 | "main": "dist/wait-for.umd.js", 6 | "module": "dist/wait-for.esm.js", 7 | "types": "./dist/wait-for.d.ts", 8 | "scripts": { 9 | "build": "rollup -c", 10 | "test": "jest", 11 | "prepublishOnly": "jest && npm run build", 12 | "postversion": "git push --follow-tags", 13 | "test:watch": "jest --watch" 14 | }, 15 | "repository": { 16 | "type": "git", 17 | "url": "https://github.com/filidorwiese/redux-wait-for-ssr.git" 18 | }, 19 | "keywords": [ 20 | "redux", 21 | "middleware", 22 | "ssr", 23 | "actions", 24 | "promise" 25 | ], 26 | "author": "Filidor Wiese", 27 | "license": "ISC", 28 | "devDependencies": { 29 | "@types/jest": "23.3.9", 30 | "@types/redux": "3.6.0", 31 | "@types/redux-mock-store": "1.0.0", 32 | "jest": "24.8.0", 33 | "redux-mock-store": "1.5.3", 34 | "rollup": "0.66.6", 35 | "rollup-plugin-typescript2": "0.21.1", 36 | "ts-jest": "23.10.4", 37 | "typescript": "3.1.6" 38 | }, 39 | "dependencies": {} 40 | } 41 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import typescript from 'rollup-plugin-typescript2' 2 | 3 | export default [ 4 | { 5 | input: 'src/wait-for.ts', 6 | output: { 7 | file: 'dist/wait-for.umd.js', 8 | name: 'WaitFor', 9 | format: 'umd', 10 | exports: 'named' 11 | }, 12 | plugins: [ 13 | typescript() 14 | ] 15 | }, 16 | { 17 | input: 'src/wait-for.ts', 18 | output: { 19 | file: 'dist/wait-for.esm.js', 20 | name: 'WaitFor', 21 | format: 'esm' 22 | }, 23 | plugins: [ 24 | typescript() 25 | ] 26 | } 27 | ] 28 | -------------------------------------------------------------------------------- /src/__tests__/wait-for.spec.ts: -------------------------------------------------------------------------------- 1 | import configureStore from 'redux-mock-store' 2 | import createWaitForMiddleware, {waitFor} from '../wait-for' 3 | 4 | jest.setTimeout(1000) 5 | jest.useFakeTimers() 6 | 7 | console.warn = jest.fn() 8 | 9 | describe('Action', () => { 10 | it('should provide Redux waitFor action', () => { 11 | expect(waitFor(['action1', 'action2'])).toEqual({ 12 | actions: ['action1', 'action2'], 13 | type: 'WAIT_FOR_ACTIONS', 14 | timeout: 10000 15 | }) 16 | }) 17 | 18 | it('should accepts a string as single action', () => { 19 | expect(waitFor('action1')).toEqual({ 20 | actions: ['action1'], 21 | type: 'WAIT_FOR_ACTIONS', 22 | timeout: 10000 23 | }) 24 | }) 25 | }) 26 | 27 | describe('Middleware', () => { 28 | it('should return Redux middleware', () => { 29 | const waitForMiddleware = createWaitForMiddleware() 30 | expect(typeof waitForMiddleware.middleware).toBe('function') 31 | }) 32 | 33 | it('should wait for actions to occur', (done) => { 34 | const waitForMiddleware = createWaitForMiddleware() 35 | const middlewares = [waitForMiddleware.middleware] 36 | const mockStore = configureStore(middlewares) 37 | const store = mockStore({}) 38 | 39 | // Needs to be cast as any, since the middleware would usually intervene 40 | const promise = store.dispatch(waitFor(['action1', 'action2'])) as any 41 | expect(waitForMiddleware.promisesList.length).toBe(1) 42 | 43 | store.dispatch({ 44 | type: 'action1' 45 | }) 46 | store.dispatch({ 47 | type: 'action2' 48 | }) 49 | 50 | promise.then(() => { 51 | expect(waitForMiddleware.promisesList).toEqual([]) 52 | done() 53 | }) 54 | }) 55 | 56 | it('should reject promise if errorAction has occurred', (done) => { 57 | console.warn = jest.fn() 58 | const waitForMiddleware = createWaitForMiddleware() 59 | const middlewares = [waitForMiddleware.middleware] 60 | const mockStore = configureStore(middlewares) 61 | const store = mockStore({}) 62 | 63 | // Needs to be cast as any, since the middleware would usually intervene 64 | const promise = store.dispatch(waitFor(['action1', 'action2'], undefined, 'rejected-action')) as any 65 | 66 | store.dispatch({ 67 | type: 'action1' 68 | }) 69 | store.dispatch({ 70 | type: 'rejected-action' 71 | }) 72 | 73 | promise.catch(() => { 74 | const reason = 'Redux-wait-for-ssr: rejected because rejected-action occurred' 75 | expect(promise).rejects.toMatch(reason) 76 | expect(console.warn).toHaveBeenCalledWith(reason) 77 | expect(waitForMiddleware.promisesList).toEqual([]) 78 | done() 79 | }) 80 | }) 81 | 82 | it('should reject promise after timeout has passed', () => { 83 | const waitForMiddleware = createWaitForMiddleware() 84 | const middlewares = [waitForMiddleware.middleware] 85 | const mockStore = configureStore(middlewares) 86 | const store = mockStore({}) 87 | const promise = store.dispatch(waitFor(['action1', 'action2'], 2000)) 88 | 89 | jest.runAllTimers() 90 | 91 | const reason = 'Redux-wait-for-ssr: action1,action2 did not resolve within timeout of 2000ms' 92 | expect(promise).rejects.toMatch(reason) 93 | expect(console.warn).toHaveBeenCalledWith(reason) 94 | }) 95 | }) 96 | -------------------------------------------------------------------------------- /src/wait-for.ts: -------------------------------------------------------------------------------- 1 | import { AnyAction, Dispatch, Store } from 'redux' 2 | 3 | export const WAIT_FOR_ACTIONS = 'WAIT_FOR_ACTIONS' 4 | export type WAIT_FOR_ACTIONS = typeof WAIT_FOR_ACTIONS 5 | 6 | export type ActionType = string 7 | 8 | export type ActionTypes = ActionType[] 9 | 10 | export interface WaitForPromise { 11 | deferred: Deferred 12 | actions: ActionTypes 13 | timeout: any, 14 | errorAction: ActionType 15 | } 16 | 17 | export interface WaitFor { 18 | type: WAIT_FOR_ACTIONS, 19 | actions: ActionType | ActionTypes, 20 | timeout: number, 21 | errorAction?: ActionType 22 | } 23 | 24 | export function waitFor( 25 | actions: ActionType | ActionTypes, 26 | timeout: number = 10000, 27 | errorAction?: ActionType 28 | ): WaitFor { 29 | return { 30 | type: WAIT_FOR_ACTIONS, 31 | actions: Array.isArray(actions) ? actions : [actions], 32 | timeout, 33 | errorAction 34 | } 35 | } 36 | 37 | export class Deferred { 38 | public promise: Promise 39 | public reject: (reason: string) => void 40 | public resolve: () => void 41 | 42 | constructor() { 43 | this.promise = new Promise((resolve, reject) => { 44 | this.reject = (reason: string) => { 45 | if (typeof console !== 'undefined') { console.warn(reason) } 46 | reject(reason) 47 | } 48 | this.resolve = resolve 49 | }) 50 | } 51 | } 52 | 53 | export default () => { 54 | const promisesList: WaitForPromise[] = [] 55 | 56 | const removePromiseFromList = (index: number) => { 57 | clearTimeout(promisesList[index].timeout) 58 | promisesList.splice(index, 1) 59 | } 60 | 61 | const middleware = (_: Store) => (next: Dispatch) => (action: AnyAction): Promise | undefined => { 62 | // Loop promises to see if current action fullfills it 63 | for (let ii = 0; ii < promisesList.length; ii++) { 64 | promisesList[ii].actions = promisesList[ii].actions.filter((a) => a !== action.type) 65 | 66 | // Reject if the error action occurred 67 | if (promisesList[ii] && promisesList[ii].errorAction && action.type === promisesList[ii].errorAction) { 68 | promisesList[ii].deferred.reject(`Redux-wait-for-ssr: rejected because ${action.type} occurred`) 69 | removePromiseFromList(ii) 70 | } 71 | 72 | // No more actions? Resolve 73 | if (promisesList[ii] && !promisesList[ii].actions.length) { 74 | promisesList[ii].deferred.resolve() 75 | removePromiseFromList(ii) 76 | } 77 | 78 | } 79 | 80 | next(action) 81 | 82 | // Create waitFor promise 83 | if (action.type === WAIT_FOR_ACTIONS) { 84 | const deferred = new Deferred() 85 | 86 | const timeoutFn = setTimeout(() => { 87 | deferred.reject(`Redux-wait-for-ssr: ${action.actions} did not resolve within timeout of ${action.timeout}ms`) 88 | }, action.timeout) 89 | 90 | const waitingFor = { 91 | deferred, 92 | actions: action.actions, 93 | timeout: timeoutFn, 94 | errorAction: action.errorAction 95 | } 96 | 97 | promisesList.push(waitingFor) 98 | 99 | return waitingFor.deferred.promise 100 | } 101 | } 102 | 103 | return { middleware, promisesList } 104 | } 105 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "declaration": true, 4 | "noImplicitAny": true, 5 | "strictNullChecks": true, 6 | "target": "es5", 7 | "lib": [ 8 | "dom", 9 | "es5", 10 | "es2015.promise" 11 | ] 12 | }, 13 | "include": [ 14 | "src" 15 | ] 16 | } 17 | -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "defaultSeverity": "error", 3 | "extends": [ 4 | "tslint:recommended" 5 | ], 6 | "jsRules": {}, 7 | "rules": { 8 | "semicolon": [ true, "never" ], 9 | "indent": [ true, "spaces", 2 ], 10 | "quotemark": [ true, "single" ], 11 | "trailing-comma": [true, {"multiline": "never", "singleline": "never"}], 12 | "object-literal-sort-keys": false, 13 | "interface-name" : [true, "never-prefix"], 14 | "no-console": [false, "log", "warn"] 15 | }, 16 | "rulesDirectory": [] 17 | } 18 | --------------------------------------------------------------------------------