├── .eslintrc ├── .gitignore ├── .npmignore ├── .travis.yml ├── LICENSE ├── README.MD ├── __tests__ ├── .eslintrc └── redux-connect.spec.js ├── babel.config.js ├── docs └── API.MD ├── examples ├── api-redirect-err │ ├── .gitignore │ ├── README.md │ ├── package.json │ ├── public │ │ ├── favicon.ico │ │ ├── index.html │ │ └── manifest.json │ ├── server │ │ ├── api │ │ │ ├── endpoint.js │ │ │ └── index.js │ │ └── index.js │ ├── src │ │ ├── App.css │ │ ├── App.js │ │ ├── components │ │ │ ├── NotFound.js │ │ │ ├── Unwrapped.js │ │ │ ├── Wrapped.js │ │ │ ├── WrappedChildFirst.js │ │ │ └── WrappedChildSecond.js │ │ ├── containers │ │ │ └── Wrapped.js │ │ ├── helpers.js │ │ ├── index.css │ │ ├── index.js │ │ ├── routes.js │ │ ├── serverRender.js │ │ └── store │ │ │ ├── index.js │ │ │ └── reducers │ │ │ └── lunch.js │ └── yarn.lock └── basic │ ├── .gitignore │ ├── README.md │ ├── package.json │ ├── public │ ├── favicon.ico │ ├── index.html │ └── manifest.json │ ├── server │ └── index.js │ ├── src │ ├── App.css │ ├── App.js │ ├── components │ │ ├── Unwrapped.js │ │ ├── Wrapped.js │ │ └── WrappedChild.js │ ├── containers │ │ ├── Wrapped.js │ │ └── WrappedChild.js │ ├── helpers.js │ ├── index.css │ ├── index.js │ ├── routes.js │ ├── serverRender.js │ └── store.js │ └── yarn.lock ├── modules ├── components │ └── AsyncConnect.js ├── containers │ ├── AsyncConnect.js │ └── decorator.js ├── helpers │ ├── state.js │ └── utils.js ├── index.js └── store.js ├── package.json └── yarn.lock /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "airbnb", 3 | "parser": "babel-eslint", 4 | "env": { 5 | "browser": true, 6 | "node": true, 7 | "jest": true 8 | }, 9 | "rules": { 10 | "no-param-reassign": [2, {"props": false}], 11 | "prefer-arrow-callback": 0, 12 | "react/jsx-filename-extension": 0, 13 | "react/jsx-props-no-spreading": 0, 14 | "comma-dangle": ["error", { 15 | "arrays": "always-multiline", 16 | "objects": "always-multiline", 17 | "imports": "never", 18 | "exports": "never", 19 | "functions": "never" 20 | }] 21 | }, 22 | "settings": { 23 | "import/parser": "babel-eslint", 24 | "import/resolve": { 25 | "moduleDirectory": ["node_modules", "modules"] 26 | } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | lib 3 | es 4 | .idea 5 | npm-debug.log -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | .babelrc 2 | babel-present.js 3 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "10" 4 | - "8" 5 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016-2017 Vitaly Aminev 4 | Copyright (c) 2016 Rodion Salnik 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy 7 | of this software and associated documentation files (the "Software"), to deal 8 | in the Software without restriction, including without limitation the rights 9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the Software is 11 | furnished to do so, subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in all 14 | copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | SOFTWARE. -------------------------------------------------------------------------------- /README.MD: -------------------------------------------------------------------------------- 1 | ReduxConnect for React Router 2 | ============ 3 | [![npm version](https://img.shields.io/npm/v/redux-connect.svg?style=flat-square)](https://www.npmjs.com/package/redux-connect) 4 | [![Build Status](https://travis-ci.org/makeomatic/redux-connect.svg?branch=master)](https://travis-ci.org/makeomatic/redux-connect) 5 | 6 | 7 | How do you usually request data and store it to redux state? 8 | You create actions that do async jobs to load data, create reducer to save this data to redux state, 9 | then connect data to your component or container. 10 | 11 | Usually it's very similar routine tasks. 12 | 13 | Also, usually we want data to be preloaded. Especially if you're building universal app, 14 | or you just want pages to be solid, don't jump when data was loaded. 15 | 16 | This package consist of 2 parts: one part allows you to delay containers rendering until some async actions are happening. 17 | Another stores your data to redux state and connect your loaded data to your container. 18 | 19 | ## Notice 20 | 21 | This is a fork and refactor of [redux-async-connect](https://github.com/Rezonans/redux-async-connect) 22 | 23 | ## Installation & Usage 24 | 25 | Using [npm](https://www.npmjs.com/): 26 | 27 | `$ npm install redux-connect -S` 28 | 29 | ```js 30 | import { BrowserRouter } from "react-router-dom"; 31 | import { renderRoutes } from "react-router-config"; 32 | import { 33 | ReduxAsyncConnect, 34 | asyncConnect, 35 | reducer as reduxAsyncConnect 36 | } from "redux-connect"; 37 | import React from "react"; 38 | import { hydrate } from "react-dom"; 39 | import { createStore, combineReducers } from "redux"; 40 | import { Provider } from "react-redux"; 41 | 42 | const App = props => { 43 | const { route, asyncConnectKeyExample } = props; // access data from asyncConnect as props 44 | return ( 45 |
46 | {asyncConnectKeyExample && asyncConnectKeyExample.name} 47 | {renderRoutes(route.routes)} 48 |
49 | ); 50 | }; 51 | 52 | // Conenect App with asyncConnect 53 | const ConnectedApp = asyncConnect([ 54 | // API for ayncConnect decorator: https://github.com/makeomatic/redux-connect/blob/master/docs/API.MD#asyncconnect-decorator 55 | { 56 | key: "asyncConnectKeyExample", 57 | promise: ({ match: { params }, helpers }) => 58 | Promise.resolve({ 59 | id: 1, 60 | name: "value returned from promise for the key asyncConnectKeyExample" 61 | }) 62 | } 63 | ])(App); 64 | 65 | const ChildRoute = () =>
{"child component"}
; 66 | 67 | // config route 68 | const routes = [ 69 | { 70 | path: "/", 71 | component: ConnectedApp, 72 | routes: [ 73 | { 74 | path: "/child", 75 | exact: true, 76 | component: ChildRoute 77 | } 78 | ] 79 | } 80 | ]; 81 | 82 | // Config store 83 | const store = createStore( 84 | combineReducers({ reduxAsyncConnect }), // Connect redux async reducer 85 | window.__data 86 | ); 87 | window.store = store; 88 | 89 | // App Mount point 90 | hydrate( 91 | 92 | 93 | {/** Render `Router` with ReduxAsyncConnect middleware */} 94 | 95 | 96 | , 97 | document.getElementById("root") 98 | ); 99 | 100 | ``` 101 | 102 | ### Server 103 | 104 | ```js 105 | import { renderToString } from 'react-dom/server' 106 | import StaticRouter from 'react-router/StaticRouter' 107 | import { ReduxAsyncConnect, loadOnServer, reducer as reduxAsyncConnect } from 'redux-connect' 108 | import { parse as parseUrl } from 'url' 109 | import { Provider } from 'react-redux' 110 | import { createStore, combineReducers } from 'redux' 111 | import serialize from 'serialize-javascript' 112 | 113 | app.get('*', (req, res) => { 114 | const store = createStore(combineReducers({ reduxAsyncConnect })) 115 | const url = req.originalUrl || req.url 116 | const location = parseUrl(url) 117 | 118 | // 1. load data 119 | loadOnServer({ store, location, routes, helpers }) 120 | .then(() => { 121 | const context = {} 122 | 123 | // 2. use `ReduxAsyncConnect` to render component tree 124 | const appHTML = renderToString( 125 | 126 | 127 | 128 | 129 | 130 | ) 131 | 132 | // handle redirects 133 | if (context.url) { 134 | req.header('Location', context.url) 135 | return res.send(302) 136 | } 137 | 138 | // 3. render the Redux initial data into the server markup 139 | const html = createPage(appHTML, store) 140 | res.send(html) 141 | }) 142 | }) 143 | 144 | function createPage(html, store) { 145 | return ` 146 | 147 | 148 | 149 |
${html}
150 | 151 | 152 | 155 | 156 | 157 | ` 158 | } 159 | ``` 160 | 161 | ## [API](/docs/API.MD) 162 | 163 | ## Usage with `ImmutableJS` 164 | 165 | This lib can be used with ImmutableJS or any other immutability lib by providing methods that convert the state between mutable and immutable data. Along with those methods, there is also a special immutable reducer that needs to be used instead of the normal reducer. 166 | 167 | ```js 168 | import { setToImmutableStateFunc, setToMutableStateFunc, immutableReducer as reduxAsyncConnect } from 'redux-connect'; 169 | 170 | // Set the mutability/immutability functions 171 | setToImmutableStateFunc((mutableState) => Immutable.fromJS(mutableState)); 172 | setToMutableStateFunc((immutableState) => immutableState.toJS()); 173 | 174 | // Thats all, now just use redux-connect as normal 175 | export const rootReducer = combineReducers({ 176 | reduxAsyncConnect, 177 | ... 178 | }) 179 | ``` 180 | 181 | ## Comparing with other libraries 182 | 183 | There are some solutions of problem described above: 184 | 185 | - [**AsyncProps**](https://github.com/ryanflorence/async-props) 186 | It solves the same problem, but it doesn't work with redux state. Also it's significantly more complex inside, 187 | because it contains lots of logic to connect data to props. 188 | It uses callbacks against promises... 189 | - [**react-fetcher**](https://github.com/markdalgleish/react-fetcher) 190 | It's very simple library too. But it provides you only interface for decorating your components and methods 191 | to fetch data for them. It doesn't integrated with React Router or Redux. So, you need to write you custom logic 192 | to delay routing transition for example. 193 | - [**react-resolver**](https://github.com/ericclemmons/react-resolver) 194 | Works similar, but isn't integrated with redux. 195 | 196 | **Redux Connect** uses awesome [Redux](https://github.com/reactjs/redux) to keep all fetched data in state. 197 | This integration gives you agility: 198 | 199 | - you can react on fetching actions like data loading or load success in your own reducers 200 | - you can create own middleware to handle Redux Async Connect actions 201 | - you can connect to loaded data anywhere else, just using simple redux @connect 202 | - finally, you can debug and see your data using Redux Dev Tools 203 | 204 | Also it's integrated with [React Router](https://github.com/rackt/react-router) to prevent routing transition 205 | until data is loaded. 206 | 207 | ## Contributors 208 | - [Vitaly Aminev](https://makeomatic.ca) 209 | - [Gerhard Sletten](https://github.com/gerhardsletten) 210 | - [Todd Bluhm](https://github.com/toddbluhm) 211 | - [Eliseu Monar](https://github.com/eliseumds) 212 | - [Rui Araújo](https://github.com/ruiaraujo) 213 | - [Rodion Salnik](https://github.com/sars) 214 | - [Rezonans team](https://github.com/Rezonans) 215 | 216 | ## Collaboration 217 | You're welcome to PR, and we appreciate any questions or issues, please [open an issue](https://github.com/makeomatic/redux-connect/issues)! 218 | -------------------------------------------------------------------------------- /__tests__/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "rules": { 3 | "import/no-extraneous-dependencies": 0, 4 | "react/prop-types": 0, 5 | "new-cap": 0 6 | } 7 | } -------------------------------------------------------------------------------- /__tests__/redux-connect.spec.js: -------------------------------------------------------------------------------- 1 | import Enzyme, { mount, render } from 'enzyme'; 2 | import Adapter from 'enzyme-adapter-react-16'; 3 | import Promise from 'bluebird'; 4 | import React from 'react'; 5 | import { Provider, connect } from 'react-redux'; 6 | import { withRouter, StaticRouter, MemoryRouter } from 'react-router'; 7 | import { renderRoutes } from 'react-router-config'; 8 | import { createStore, combineReducers } from 'redux'; 9 | import { combineReducers as combineImmutableReducers } from 'redux-immutable'; 10 | import { spy } from 'sinon'; 11 | import Immutable from 'immutable'; 12 | import { setToImmutableStateFunc, setToMutableStateFunc } from '../modules/helpers/state'; 13 | 14 | // import module 15 | import { endGlobalLoad, beginGlobalLoad } from '../modules/store'; 16 | import AsyncConnectWithContext, { AsyncConnect } from '../modules/components/AsyncConnect'; 17 | import { 18 | asyncConnect, 19 | reducer as reduxAsyncConnect, 20 | immutableReducer, 21 | loadOnServer 22 | } from '../modules/index'; 23 | 24 | Enzyme.configure({ adapter: new Adapter() }); 25 | 26 | describe('', function suite() { 27 | // https://github.com/reduxjs/react-redux/issues/1373 28 | const originalConsoleError = console.error; 29 | 30 | beforeEach(() => { 31 | console.error = jest.fn((msg) => { 32 | if (msg.includes('Warning: useLayoutEffect does nothing on the server')) { 33 | return null; 34 | } 35 | 36 | return originalConsoleError(msg); 37 | }); 38 | }); 39 | 40 | afterEach(() => { 41 | console.error = originalConsoleError; 42 | }); 43 | 44 | const initialState = { 45 | reduxAsyncConnect: { loaded: false, loadState: {}, $$external: 'supported' }, 46 | }; 47 | 48 | const endGlobalLoadSpy = spy(endGlobalLoad); 49 | const beginGlobalLoadSpy = spy(beginGlobalLoad); 50 | 51 | const ReduxAsyncConnect = withRouter(connect(null, { 52 | beginGlobalLoad: beginGlobalLoadSpy, 53 | endGlobalLoad: endGlobalLoadSpy, 54 | })(AsyncConnectWithContext)); 55 | 56 | /* eslint-disable no-unused-vars */ 57 | const App = ({ 58 | // NOTE: use this as a reference of props passed to your component from router 59 | // these are the params that are passed from router 60 | history, 61 | location, 62 | params, 63 | route, 64 | router, 65 | routeParams, 66 | routes, 67 | externalState, 68 | remappedProp, 69 | staticContext, 70 | // our param 71 | lunch, 72 | // react-redux dispatch prop 73 | dispatch, 74 | ...rest 75 | }) =>
{lunch}
; 76 | 77 | const MultiAppA = ({ 78 | route, 79 | // our param 80 | breakfast, 81 | // react-redux dispatch prop 82 | ...rest 83 | }) => ( 84 |
85 |
{breakfast}
86 | {renderRoutes(route.routes)} 87 |
88 | ); 89 | 90 | const MultiAppB = ({ 91 | dinner, 92 | ...rest 93 | }) =>
{dinner}
; 94 | /* eslint-enable no-unused-vars */ 95 | 96 | const WrappedApp = asyncConnect([{ 97 | key: 'lunch', 98 | promise: () => Promise.resolve('sandwich'), 99 | }, { 100 | key: 'action', 101 | promise: ({ helpers }) => Promise.resolve(helpers.eat()), 102 | }], (state, ownProps) => ({ 103 | externalState: state.reduxAsyncConnect.$$external, 104 | remappedProp: ownProps.route.remap, 105 | }))(App); 106 | 107 | const WrappedAppA = asyncConnect([{ 108 | key: 'breakfast', 109 | promise: () => Promise.resolve('omelette'), 110 | }, { 111 | key: 'action', 112 | promise: ({ helpers }) => Promise.resolve(helpers.eat('breakfast')), 113 | }], state => ({ 114 | externalState: state.reduxAsyncConnect.$$external, 115 | }))(MultiAppA); 116 | 117 | const WrappedAppB = asyncConnect([{ 118 | key: 'dinner', 119 | promise: () => Promise.resolve('chicken'), 120 | }, { 121 | key: 'action', 122 | promise: ({ helpers }) => Promise.resolve(helpers.eat('dinner')), 123 | }], state => ({ 124 | externalState: state.reduxAsyncConnect.$$external, 125 | }))(MultiAppB); 126 | 127 | const UnwrappedApp = ({ route }) => ( 128 |
129 | {'Hi, I do not use @asyncConnect'} 130 | {renderRoutes(route.routes)} 131 |
132 | ); 133 | 134 | const reducers = combineReducers({ reduxAsyncConnect }); 135 | 136 | /* 137 | const routes = ( 138 | 139 | 140 | 141 | 142 | 143 | 144 | 145 | ); 146 | */ 147 | 148 | const routes = [ 149 | { 150 | path: '/', exact: true, component: WrappedApp, remap: 'on', 151 | }, 152 | { 153 | path: '/notconnected', component: UnwrappedApp, 154 | }, 155 | { 156 | path: '/multi', 157 | component: WrappedAppA, 158 | routes: [ 159 | { 160 | path: '/multi', 161 | exact: true, 162 | component: UnwrappedApp, 163 | routes: [{ component: WrappedAppB }], 164 | }, 165 | ], 166 | }, 167 | ]; 168 | 169 | // inter-test state 170 | let testState; 171 | 172 | it('properly fetches data on the server', function test() { 173 | const store = createStore(reducers); 174 | const eat = spy(() => 'yammi'); 175 | const helpers = { eat }; 176 | const location = { pathname: '/' }; 177 | 178 | return loadOnServer({ 179 | store, location, routes, helpers, 180 | }) 181 | .then(() => { 182 | const context = {}; 183 | 184 | const html = render(( 185 | 186 | 187 | 188 | 189 | 190 | )); 191 | 192 | if (context.url) { 193 | throw new Error('redirected'); 194 | } 195 | 196 | expect(html.text()).toContain('sandwich'); 197 | testState = store.getState(); 198 | expect(testState.reduxAsyncConnect.loaded).toBe(true); 199 | expect(testState.reduxAsyncConnect.lunch).toBe('sandwich'); 200 | expect(testState.reduxAsyncConnect.action).toBe('yammi'); 201 | expect(testState.reduxAsyncConnect.loadState.lunch.loading).toBe(false); 202 | expect(testState.reduxAsyncConnect.loadState.lunch.loaded).toBe(true); 203 | expect(testState.reduxAsyncConnect.loadState.lunch.error).toBe(null); 204 | expect(eat.calledOnce).toBe(true); 205 | 206 | // global loader spy 207 | expect(endGlobalLoadSpy.called).toBe(false); 208 | expect(beginGlobalLoadSpy.called).toBe(false); 209 | endGlobalLoadSpy.resetHistory(); 210 | beginGlobalLoadSpy.resetHistory(); 211 | }); 212 | }); 213 | 214 | it('properly picks data up from the server', function test() { 215 | const store = createStore(reducers, testState); 216 | const proto = AsyncConnect.prototype; 217 | const eat = spy(() => 'yammi'); 218 | 219 | spy(proto, 'loadAsyncData'); 220 | spy(proto, 'componentDidMount'); 221 | 222 | const wrapper = mount(( 223 | 224 | 225 | 226 | 227 | 228 | )); 229 | 230 | expect(proto.loadAsyncData.called).toBe(false); 231 | expect(proto.componentDidMount.calledOnce).toBe(true); 232 | expect(eat.called).toBe(false); 233 | 234 | expect(wrapper.find(App).length).toBe(1); 235 | expect(wrapper.find(App).prop('lunch')).toBe('sandwich'); 236 | 237 | // global loader spy 238 | expect(endGlobalLoadSpy.called).toBe(false); 239 | expect(beginGlobalLoadSpy.called).toBe(false); 240 | endGlobalLoadSpy.resetHistory(); 241 | beginGlobalLoadSpy.resetHistory(); 242 | 243 | proto.loadAsyncData.restore(); 244 | proto.componentDidMount.restore(); 245 | }); 246 | 247 | it('loads data on client side when it wasn\'t provided by server', function test() { 248 | const store = createStore(reducers); 249 | const eat = spy(() => 'yammi'); 250 | const proto = AsyncConnect.prototype; 251 | 252 | spy(proto, 'loadAsyncData'); 253 | spy(proto, 'componentDidMount'); 254 | 255 | mount(( 256 | 257 | 258 | 259 | 260 | 261 | )); 262 | 263 | expect(proto.loadAsyncData.calledOnce).toBe(true); 264 | expect(proto.componentDidMount.calledOnce).toBe(true); 265 | 266 | 267 | // global loader spy 268 | expect(beginGlobalLoadSpy.called).toBe(true); 269 | beginGlobalLoadSpy.resetHistory(); 270 | 271 | return proto.loadAsyncData.returnValues[0].then(() => { 272 | expect(endGlobalLoadSpy.called).toBe(true); 273 | endGlobalLoadSpy.resetHistory(); 274 | 275 | proto.loadAsyncData.restore(); 276 | proto.componentDidMount.restore(); 277 | }); 278 | }); 279 | 280 | it('supports extended connect signature', function test() { 281 | const store = createStore(reducers, initialState); 282 | const eat = spy(() => 'yammi'); 283 | const proto = AsyncConnect.prototype; 284 | 285 | spy(proto, 'loadAsyncData'); 286 | spy(proto, 'componentDidMount'); 287 | 288 | const wrapper = mount(( 289 | 290 | 291 | 292 | 293 | 294 | )); 295 | 296 | expect(proto.loadAsyncData.calledOnce).toBe(true); 297 | expect(proto.componentDidMount.calledOnce).toBe(true); 298 | 299 | // global loader spy 300 | expect(beginGlobalLoadSpy.called).toBe(true); 301 | beginGlobalLoadSpy.resetHistory(); 302 | 303 | return proto.loadAsyncData.returnValues[0].then(() => { 304 | expect(endGlobalLoadSpy.called).toBe(true); 305 | endGlobalLoadSpy.resetHistory(); 306 | 307 | // https://github.com/airbnb/enzyme/blob/master/docs/guides/migration-from-2-to-3.md#for-mount-updates-are-sometimes-required-when-they-werent-before 308 | wrapper.update(); 309 | 310 | expect(wrapper.find(App).length).toBe(1); 311 | expect(wrapper.find(App).prop('lunch')).toBe('sandwich'); 312 | expect(wrapper.find(App).prop('externalState')).toBe('supported'); 313 | expect(wrapper.find(App).prop('remappedProp')).toBe('on'); 314 | 315 | proto.loadAsyncData.restore(); 316 | proto.componentDidMount.restore(); 317 | }); 318 | }); 319 | 320 | it('renders even when no component is connected', function test() { 321 | const store = createStore(reducers); 322 | const eat = spy(() => 'yammi'); 323 | const location = { pathname: '/notconnected' }; 324 | const helpers = { eat }; 325 | 326 | return loadOnServer({ 327 | store, location, routes, helpers, 328 | }) 329 | .then(() => { 330 | const context = {}; 331 | 332 | const html = render(( 333 | 334 | 335 | 336 | 337 | 338 | )); 339 | 340 | if (context.url) { 341 | throw new Error('redirected'); 342 | } 343 | 344 | expect(html.text()).toContain('I do not use @asyncConnect'); 345 | testState = store.getState(); 346 | expect(testState.reduxAsyncConnect.loaded).toBe(true); 347 | expect(testState.reduxAsyncConnect.lunch).toBe(undefined); 348 | expect(eat.called).toBe(false); 349 | 350 | // global loader spy 351 | expect(endGlobalLoadSpy.called).toBe(false); 352 | expect(beginGlobalLoadSpy.called).toBe(false); 353 | endGlobalLoadSpy.resetHistory(); 354 | beginGlobalLoadSpy.resetHistory(); 355 | }); 356 | }); 357 | 358 | it('properly fetches data in the correct order given a nested routing structure', function test() { 359 | const store = createStore(reducers); 360 | const promiseOrder = []; 361 | const eat = spy((meal) => { 362 | promiseOrder.push(meal); 363 | return `yammi ${meal}`; 364 | }); 365 | const location = { pathname: '/multi' }; 366 | const helpers = { eat }; 367 | 368 | return loadOnServer({ 369 | store, routes, location, helpers, 370 | }) 371 | .then(() => { 372 | const context = {}; 373 | 374 | const html = render(( 375 | 376 | 377 | 378 | 379 | 380 | )); 381 | 382 | if (context.url) { 383 | throw new Error('redirected'); 384 | } 385 | 386 | expect(html.text()).toContain('omelette'); 387 | expect(html.text()).toContain('chicken'); 388 | testState = store.getState(); 389 | expect(testState.reduxAsyncConnect.loaded).toBe(true); 390 | expect(testState.reduxAsyncConnect.breakfast).toBe('omelette'); 391 | expect(testState.reduxAsyncConnect.dinner).toBe('chicken'); 392 | expect(testState.reduxAsyncConnect.action).toBe('yammi dinner'); 393 | expect(testState.reduxAsyncConnect.loadState.dinner.loading).toBe(false); 394 | expect(testState.reduxAsyncConnect.loadState.dinner.loaded).toBe(true); 395 | expect(testState.reduxAsyncConnect.loadState.dinner.error).toBe(null); 396 | expect(testState.reduxAsyncConnect.loadState.breakfast.loading).toBe(false); 397 | expect(testState.reduxAsyncConnect.loadState.breakfast.loaded).toBe(true); 398 | expect(testState.reduxAsyncConnect.loadState.breakfast.error).toBe(null); 399 | expect(eat.calledTwice).toBe(true); 400 | 401 | expect(promiseOrder).toEqual(['breakfast', 'dinner']); 402 | 403 | // global loader spy 404 | expect(endGlobalLoadSpy.called).toBe(false); 405 | expect(beginGlobalLoadSpy.called).toBe(false); 406 | endGlobalLoadSpy.resetHistory(); 407 | beginGlobalLoadSpy.resetHistory(); 408 | }); 409 | }); 410 | 411 | it('properly fetches data on the server when using immutable data structures', function test() { 412 | // We use a special reducer built for handling immutable js data 413 | const immutableReducers = combineImmutableReducers({ 414 | reduxAsyncConnect: immutableReducer, 415 | }); 416 | 417 | // We need to re-wrap the component so the mapStateToProps expects immutable js data 418 | const ImmutableWrappedApp = asyncConnect([{ 419 | key: 'lunch', 420 | promise: () => Promise.resolve('sandwich'), 421 | }, { 422 | key: 'action', 423 | promise: ({ helpers }) => Promise.resolve(helpers.eat()), 424 | }], (state, ownProps) => ({ 425 | externalState: state.getIn(['reduxAsyncConnect', '$$external']), // use immutablejs methods 426 | remappedProp: ownProps.route.remap, 427 | }))(App); 428 | 429 | // Custom routes using our custom immutable wrapped component 430 | /* 431 | const immutableRoutes = ( 432 | 433 | 434 | 435 | 436 | ); 437 | */ 438 | const immutableRoutes = [ 439 | { 440 | path: '/', exact: true, component: ImmutableWrappedApp, remap: 'on', 441 | }, 442 | { path: '/notconnected', component: UnwrappedApp }, 443 | ]; 444 | 445 | // Set the mutability/immutability functions 446 | setToImmutableStateFunc(mutableState => Immutable.fromJS(mutableState)); 447 | setToMutableStateFunc(immutableState => immutableState.toJS()); 448 | 449 | // Create the store with initial immutable data 450 | const store = createStore(immutableReducers, Immutable.Map({})); 451 | const eat = spy(() => 'yammi'); 452 | const location = { pathname: '/' }; 453 | const helpers = { eat }; 454 | 455 | // Use the custom immutable routes 456 | return loadOnServer({ 457 | store, location, routes: immutableRoutes, helpers, 458 | }) 459 | .then(() => { 460 | const context = {}; 461 | 462 | const html = render(( 463 | 464 | 465 | 466 | 467 | 468 | )); 469 | 470 | if (context.url) { 471 | throw new Error('redirected'); 472 | } 473 | 474 | expect(html.text()).toContain('sandwich'); 475 | testState = store.getState().toJS(); // convert to plain js for assertions 476 | expect(testState.reduxAsyncConnect.loaded).toBe(true); 477 | expect(testState.reduxAsyncConnect.lunch).toBe('sandwich'); 478 | expect(testState.reduxAsyncConnect.action).toBe('yammi'); 479 | expect(testState.reduxAsyncConnect.loadState.lunch.loading).toBe(false); 480 | expect(testState.reduxAsyncConnect.loadState.lunch.loaded).toBe(true); 481 | expect(testState.reduxAsyncConnect.loadState.lunch.error).toBe(null); 482 | expect(eat.calledOnce).toBe(true); 483 | 484 | // global loader spy 485 | expect(endGlobalLoadSpy.called).toBe(false); 486 | expect(beginGlobalLoadSpy.called).toBe(false); 487 | endGlobalLoadSpy.resetHistory(); 488 | beginGlobalLoadSpy.resetHistory(); 489 | }); 490 | }); 491 | }); 492 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = function babelConfig(api) { 2 | const isProd = api.cache(() => process.env.NODE_ENV === 'production'); 3 | const babelEnv = api.cache(() => process.env.BABEL_ENV); 4 | 5 | const plugins = [ 6 | '@babel/plugin-proposal-class-properties', 7 | '@babel/plugin-proposal-export-default-from', 8 | ]; 9 | 10 | if (isProd) { 11 | plugins.push( 12 | 'transform-react-remove-prop-types' 13 | ); 14 | } 15 | return { 16 | presets: [ 17 | ['@babel/preset-env', { 18 | loose: true, 19 | modules: babelEnv === 'es' ? false : 'commonjs', 20 | targets: { 21 | browsers: ['last 2 versions'], 22 | node: 'current', 23 | }, 24 | }], 25 | '@babel/preset-react', 26 | ], 27 | plugins, 28 | }; 29 | }; 30 | -------------------------------------------------------------------------------- /docs/API.MD: -------------------------------------------------------------------------------- 1 | API 2 | ============ 3 | 4 | ## ReduxAsyncConnect 5 | It allows to delay route transition until all promises returned by reduxAsyncConnect methods of components defined within corresponding route are resolved or rejected. 6 | 7 | #### Props 8 | ##### `routes` (required) 9 | Static route configuration (`Array`) 10 | 11 | ##### `filter` 12 | Function used to filter `asyncConnect` items in components matched by `machRoutes`. 13 | Has signature: `(item: { key?: string, promise: () => Promise | undefined | any }, component: React.Component) => boolean` 14 | 15 | ##### `render` 16 | Function that accepts props and used to render route components. 17 | Defaults to `({ routes }) => renderRoutes(routes)` 18 | 19 | ##### `helpers` 20 | Any helpers you may want pass to your reduxAsyncConnect static method. 21 | For example some fetching library. 22 | 23 | ## asyncConnect decorator 24 | 25 | ```js 26 | asyncConnect(AsyncProps: Array, mapStateToProps?, mapDispatchToProps?, mergeProps?, options?) 27 | ``` 28 | 29 | Signature now corresponds to [react-redux connect](https://github.com/reactjs/react-redux/blob/master/docs/api.md#connectmapstatetoprops-mapdispatchtoprops-mergeprops-options) 30 | 31 | This function is used to decorate your container components that are connected to the router. It should provide a `mapStateToProps` object as follows: 32 | 33 | ```js 34 | @asyncConnect([{ 35 | key: 'lunches', 36 | promise: ({ store: { dispatch, getState }, helpers }) => ( 37 | helpers.client.get('/lunches') 38 | ), 39 | }]) 40 | export default class Home extends Component { 41 | // ... 42 | } 43 | ``` 44 | 45 | `AsyncProps` is an array of objects with `key` and `promise` fields. 46 | 47 | The interface is similar to react-redux connect. The `key` field of each object will be used to connect data returned from from `promise` to both the redux state and the corresponding prop in the component. 48 | So in example above you'll have `this.props.lunches`. 49 | 50 | The `promise` field should be a function that accepts a single object as an option. 51 | 52 | Given that you are using `loadOnServer` as follows with the current version of react-router (v4.x at the time of writing), 53 | 54 | loadOnServer(params: { location: { pathname: string }, routes: Array, store? : { dispatch: (Action) => any, getState: () => any } , helpers?: any }) => Promise 55 | 56 | the option can include the following keys: 57 | 58 | * _**store**_ - Includes methods `dispatch` and `getState` 59 | * _**match**_ - `{ params: any, isExact: boolean, path: string, url: string }` the match object that also gets passed to `` render method 60 | * _**route**_ - a reference to the matched item from routes array 61 | * _**routes**_ - routes you have passed to `loadOnServer` 62 | * _**helpers**_ - Any helpers you have passed from `loadOnServer` 63 | * _**location**_ - location you have passed to `loadOnServer` 64 | 65 | The `promise` function can return: 66 | - _**undefined**_ - In this case we'll do nothing 67 | - _**promise**_ - In this case we'll store data from this promise on the appropriate key in the redux state and will ask ReduxAsyncConnect to delay rendering until it's resolved. 68 | - _**other value**_ - In this case we'll store this data to redux state on the appropriate key immediately 69 | 70 | ## reducer 71 | This reducer MUST be mounted to `reduxAsyncConnect` key in combineReducers. 72 | It uses to store information about global loading and all other data to redux store. 73 | 74 | ## redux state 75 | You'll have the following in your `reduxAsyncConnect` key in redux state: 76 | _(the [key] here is corresponding to mapStateToProps object's keys passed to asyncConnect decorator)_ 77 | 78 | - _**loaded**_ It's global loading identifier. Useful for page preloader 79 | - _**[key].loading**_ Identifies that promise resolving in progress 80 | - _**[key].loaded**_ Identifies that promise was resolved 81 | - _**[key].data**_ Data, returned from resolved promise 82 | - _**[key].error**_ Errors, returned from rejected promise 83 | 84 | ## redux actions 85 | There are some actions you can react on: 86 | - **LOAD** data loading for particular key is started 87 | - **LOAD_SUCCESS** data loading process successfully finished. You'll have data returned from promise 88 | - **LOAD_FAIL** data loading process was failed. You'll have error returned from promise 89 | - **CLEAR** data for particular key was cleared 90 | - **BEGIN_GLOBAL_LOAD** loading for all components began 91 | - **END_GLOBAL_LOAD** loading for all components finished 92 | -------------------------------------------------------------------------------- /examples/api-redirect-err/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/ignore-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | 6 | # testing 7 | /coverage 8 | 9 | # production 10 | /build 11 | /babel-src 12 | 13 | # misc 14 | .DS_Store 15 | .env.local 16 | .env.development.local 17 | .env.test.local 18 | .env.production.local 19 | 20 | npm-debug.log* 21 | yarn-debug.log* 22 | yarn-error.log* 23 | -------------------------------------------------------------------------------- /examples/api-redirect-err/README.md: -------------------------------------------------------------------------------- 1 | Advanced `redux-connect` routing example with SSR, Api endpoint, redirects, 404 error 2 | === 3 | 4 | To start application run: 5 | 6 | ````bash 7 | yarn 8 | yarn start 9 | ```` 10 | 11 | then open in browser [localhost:3000](http://localhost:3000) 12 | 13 | Root route redirects to `/wrapped/second`, any subroute of `/wrapped/*` not matching `/wrapped/first` or `/wrapped/second` redirects to `/wrapped/first`. 14 | Requests to non existing routes returns with 404 status code. -------------------------------------------------------------------------------- /examples/api-redirect-err/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "api-redirect-err", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "axios": "^0.17.1", 7 | "bluebird": "^3.5.1", 8 | "compression": "^1.7.1", 9 | "express": "^4.16.2", 10 | "ignore-styles": "^5.0.1", 11 | "morgan": "^1.9.0", 12 | "react": "^16.2.0", 13 | "react-dom": "^16.2.0", 14 | "react-redux": "^5.0.6", 15 | "react-router": "^4.2.0", 16 | "react-router-config": "^1.0.0-beta.4", 17 | "react-router-dom": "^4.2.2", 18 | "react-scripts": "1.0.17", 19 | "redux": "^3.7.2", 20 | "redux-actions": "^2.2.1", 21 | "redux-promise": "^0.5.3", 22 | "rimraf": "^2.6.2" 23 | }, 24 | "scripts": { 25 | "start": "NODE_ENV=production npm run build && node server/index.js", 26 | "start:client": "react-scripts start", 27 | "build:client": "react-scripts build", 28 | "build:server": "babel src --out-dir babel-src --copy-files", 29 | "build": "npm run build:client && npm run build:server", 30 | "test": "react-scripts test --env=jsdom", 31 | "eject": "react-scripts eject", 32 | "clean": "rimraf build babel-src" 33 | }, 34 | "devDependencies": { 35 | "babel-cli": "^6.26.0", 36 | "babel-preset-env": "^1.6.1" 37 | }, 38 | "babel": { 39 | "presets": [ 40 | "env", 41 | "react-app" 42 | ] 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /examples/api-redirect-err/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/makeomatic/redux-connect/6e4d865fa5fbb8db2a505cb177583f34fb9edd95/examples/api-redirect-err/public/favicon.ico -------------------------------------------------------------------------------- /examples/api-redirect-err/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | Redux-connect example with SSR 10 | 11 | 12 | 15 |
{{SSR}}
16 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /examples/api-redirect-err/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "React App", 3 | "name": "Create React App Sample", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | } 10 | ], 11 | "start_url": "./index.html", 12 | "display": "standalone", 13 | "theme_color": "#000000", 14 | "background_color": "#ffffff" 15 | } 16 | -------------------------------------------------------------------------------- /examples/api-redirect-err/server/api/endpoint.js: -------------------------------------------------------------------------------- 1 | const Promise = require('bluebird'); 2 | 3 | function endpoint(req, res) { 4 | return Promise 5 | .delay(200, { id: 1, value: 'Borsch' }) 6 | .then(val => res.json(val)); 7 | } 8 | 9 | module.exports = endpoint; 10 | -------------------------------------------------------------------------------- /examples/api-redirect-err/server/api/index.js: -------------------------------------------------------------------------------- 1 | const router = require('express').Router(); 2 | const endpoint = require('./endpoint'); 3 | 4 | router.get('/endpoint', endpoint); 5 | 6 | module.exports = router; 7 | -------------------------------------------------------------------------------- /examples/api-redirect-err/server/index.js: -------------------------------------------------------------------------------- 1 | const express = require('express'); 2 | const morgan = require('morgan'); 3 | const compression = require('compression'); 4 | const path = require('path'); 5 | const fs = require('fs'); 6 | const api = require('./api'); 7 | const ignoreStyles = require('ignore-styles').default; 8 | const serverRender = require('../babel-src/serverRender').default; 9 | 10 | ignoreStyles(); 11 | 12 | const PORT = process.env.PORT || 3000; 13 | const app = express(); 14 | 15 | app.use(compression()); 16 | 17 | app.use(morgan('combined')); 18 | 19 | app.use(express.static(path.resolve(__dirname, '..', 'build'), { index: false })); 20 | 21 | app.use('/api', api); 22 | 23 | app.use('/', function renderApp(req, res) { 24 | const filePath = path.resolve(__dirname, '..', 'build', 'index.html'); 25 | 26 | fs.readFile(filePath, { encoding: 'utf-8' }, function render(err, html) { 27 | if (err) { 28 | return res.status(404).end(); 29 | } 30 | 31 | return serverRender(req, res, html); 32 | }); 33 | }); 34 | 35 | app.listen(PORT, () => console.log(`App listening on port: ${PORT}`)); 36 | -------------------------------------------------------------------------------- /examples/api-redirect-err/src/App.css: -------------------------------------------------------------------------------- 1 | .App { 2 | text-align: center; 3 | } 4 | 5 | .App-logo { 6 | animation: App-logo-spin infinite 20s linear; 7 | height: 80px; 8 | } 9 | 10 | .App-header { 11 | background-color: #222; 12 | height: 150px; 13 | padding: 20px; 14 | color: white; 15 | } 16 | 17 | .App-title { 18 | font-size: 1.5em; 19 | } 20 | 21 | .App-intro { 22 | font-size: large; 23 | } 24 | 25 | @keyframes App-logo-spin { 26 | from { transform: rotate(0deg); } 27 | to { transform: rotate(360deg); } 28 | } 29 | -------------------------------------------------------------------------------- /examples/api-redirect-err/src/App.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Link from 'react-router-dom/Link'; 3 | import { renderRoutes } from 'react-router-config'; 4 | 5 | import './App.css'; 6 | 7 | function App({ route }) { 8 | return ( 9 |
10 |
11 |

12 | {'redux-connect example with ssr, api call, redirect, 404'} 13 |

14 |
15 |
    16 |
  • 17 | 18 | {'Root (redirects to second child)'} 19 | 20 |
  • 21 |
  • 22 | 23 | {'Wrapped component'} 24 | 25 |
  • 26 |
  • 27 | 28 | {'Unwrapped component'} 29 | 30 |
  • 31 |
  • 32 | 33 | {'Non existing route'} 34 | 35 |
  • 36 |
37 |
38 | {renderRoutes(route.routes)} 39 |
40 |
41 | ); 42 | } 43 | 44 | export default App; 45 | -------------------------------------------------------------------------------- /examples/api-redirect-err/src/components/NotFound.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | 3 | export default class NotFound extends Component { 4 | componentWillMount() { 5 | const { staticContext } = this.props; 6 | 7 | if (staticContext) { 8 | staticContext.code = 404; 9 | } 10 | } 11 | render() { 12 | return ( 13 |
14 | {'Page not found'} 15 |
16 | ); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /examples/api-redirect-err/src/components/Unwrapped.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export default function Unwrapped() { 4 | return ( 5 |
6 |

7 | {'Unwrapped component'} 8 |

9 |
10 | ); 11 | } 12 | -------------------------------------------------------------------------------- /examples/api-redirect-err/src/components/Wrapped.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Switch from 'react-router/Switch'; 3 | import Route from 'react-router/Route'; 4 | import Redirect from 'react-router/Redirect'; 5 | import Link from 'react-router-dom/Link'; 6 | import WrappedChildFirst from './WrappedChildFirst'; 7 | import WrappedChildSecond from './WrappedChildSecond'; 8 | 9 | export default function Wrapped({ value }) { 10 | return ( 11 |
12 |

13 | {'Wrapped component'} 14 |

15 |
16 | {`lunch: ${value}`} 17 |
18 |
    19 |
  • 20 | 21 | {'First child'} 22 | 23 |
  • 24 |
  • 25 | 26 | {'Second child'} 27 | 28 |
  • 29 |
  • 30 | 31 | {'Redirect to the first child'} 32 | 33 |
  • 34 |
35 | 36 | 'Wrapped component root'} /> 37 | 38 | 39 | 40 | 41 |
42 | ); 43 | } 44 | -------------------------------------------------------------------------------- /examples/api-redirect-err/src/components/WrappedChildFirst.js: -------------------------------------------------------------------------------- 1 | export default function WrappedChildFirst() { 2 | return 'Wrapped child first'; 3 | } 4 | -------------------------------------------------------------------------------- /examples/api-redirect-err/src/components/WrappedChildSecond.js: -------------------------------------------------------------------------------- 1 | export default function WrappedChildSecond() { 2 | return 'Wrapped child second'; 3 | } 4 | -------------------------------------------------------------------------------- /examples/api-redirect-err/src/containers/Wrapped.js: -------------------------------------------------------------------------------- 1 | import { connect } from 'react-redux'; 2 | import { compose } from 'redux'; 3 | import { fetchData } from '../store/reducers/lunch'; 4 | import { asyncConnect } from '../../../../'; 5 | import Wrapped from '../components/Wrapped'; 6 | 7 | const $asyncConnect = asyncConnect([{ 8 | // no `key` property, promise just fills store and then we get the value with classic `connect` 9 | promise: ({ store, helpers }) => { 10 | const { data, loaded } = store.getState().lunch; 11 | 12 | if (loaded) { 13 | return Promise.resolve(data); 14 | } 15 | 16 | return store.dispatch(fetchData(helpers.http)); 17 | }, 18 | }]); 19 | 20 | const $connect = connect(state => state.lunch.data); 21 | 22 | export default compose($asyncConnect, $connect)(Wrapped); 23 | 24 | -------------------------------------------------------------------------------- /examples/api-redirect-err/src/helpers.js: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | 3 | const isNode = new Function('try{return this===global}catch(e){return false}'); 4 | 5 | export const http = axios.create({ 6 | timeout: 5000, 7 | baseURL: isNode() ? `http://localhost:${process.env.PORT || 3000}/` : undefined, 8 | }); 9 | -------------------------------------------------------------------------------- /examples/api-redirect-err/src/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | padding: 0; 4 | font-family: sans-serif; 5 | } 6 | -------------------------------------------------------------------------------- /examples/api-redirect-err/src/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import BrowserRouter from 'react-router-dom/BrowserRouter'; 4 | import { Provider } from 'react-redux'; 5 | import { ReduxAsyncConnect } from '../../../'; 6 | import configureStore from './store'; 7 | import routes from './routes'; 8 | import * as helpers from './helpers'; 9 | 10 | import './index.css'; 11 | 12 | const store = configureStore(window.__DATA); 13 | 14 | ReactDOM.render( 15 | 16 | 17 | 18 | 19 | 20 | , document.getElementById('root')); 21 | -------------------------------------------------------------------------------- /examples/api-redirect-err/src/routes.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Redirect from 'react-router/Redirect'; 3 | import App from './App'; 4 | import Wrapped from './containers/Wrapped'; 5 | import Unwrapped from './components/Unwrapped'; 6 | import NotFound from './components/NotFound'; 7 | 8 | export default [{ 9 | path: '/', 10 | component: App, 11 | routes: [{ 12 | path: '/', 13 | exact: true, 14 | component: () => , 15 | }, { 16 | path: '/unwrapped', 17 | component: Unwrapped, 18 | }, { 19 | path: '/wrapped', 20 | component: Wrapped, 21 | }, { 22 | path: '*', 23 | component: NotFound, 24 | }], 25 | }]; 26 | -------------------------------------------------------------------------------- /examples/api-redirect-err/src/serverRender.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Provider } from 'react-redux'; 3 | import StaticRouter from 'react-router/StaticRouter'; 4 | import { renderToString } from 'react-dom/server'; 5 | import url from 'url'; 6 | import { ReduxAsyncConnect, loadOnServer } from '../../../'; 7 | import configureStore from './store'; 8 | import routes from './routes'; 9 | import * as helpers from './helpers'; 10 | 11 | export default function serverRender(req, res, html) { 12 | const store = configureStore(); 13 | // loadOnServer expecting location object (with pathname property) 14 | const location = url.parse(req.url); 15 | 16 | // traversing the matched tree and collecting data 17 | loadOnServer({ store, location, routes, helpers }) 18 | .then(() => { 19 | // context object to collect rendering side effects (redirects, status code etc.) 20 | const context = {}; 21 | 22 | const markup = renderToString( 23 | 24 | {/* passing the context and the same location as to loadOnServer fn */} 25 | 26 | 27 | 28 | 29 | ); 30 | 31 | // handling redirects either from component or from staticContext 32 | if (context.url) { 33 | return res.redirect(302, context.url); 34 | } 35 | 36 | const responseHtml = html 37 | .replace('{{SSR}}', markup) 38 | .replace('{{DATA}}', JSON.stringify(store.getState())); 39 | 40 | return res.status(context.code || 200).send(responseHtml); 41 | }) 42 | .catch(() => res.status(500).end()); 43 | } 44 | 45 | -------------------------------------------------------------------------------- /examples/api-redirect-err/src/store/index.js: -------------------------------------------------------------------------------- 1 | import { createStore, combineReducers, applyMiddleware } from 'redux'; 2 | import promiseMiddleware from 'redux-promise'; 3 | import { reducer as reduxAsyncConnect } from '../../../../'; 4 | import lunch from './reducers/lunch'; 5 | 6 | export default function configureStore(initialState) { 7 | return createStore( 8 | // `reduxAsyncConnect` is mandatory name for mount point of redux-connect's reducer 9 | combineReducers({ lunch, reduxAsyncConnect }), 10 | initialState, 11 | applyMiddleware(promiseMiddleware) 12 | ); 13 | } 14 | -------------------------------------------------------------------------------- /examples/api-redirect-err/src/store/reducers/lunch.js: -------------------------------------------------------------------------------- 1 | import { createAction, handleActions } from 'redux-actions'; 2 | 3 | const initialState = { 4 | data: null, 5 | loaded: false, 6 | err: null, 7 | }; 8 | 9 | export const fetchData = createAction('@@lunch/fetch', http => http.get('/api/endpoint')); 10 | 11 | export default handleActions({ 12 | [fetchData]: { 13 | next: (state, { payload }) => ({ 14 | ...state, 15 | data: payload.data, 16 | loaded: true, 17 | }), 18 | throw: (state, { payload }) => ({ 19 | ...state, 20 | err: String(payload), 21 | loaded: true, 22 | }), 23 | }, 24 | }, initialState); 25 | -------------------------------------------------------------------------------- /examples/basic/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/ignore-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | 6 | # testing 7 | /coverage 8 | 9 | # production 10 | /build 11 | /babel-src 12 | 13 | # misc 14 | .DS_Store 15 | .env.local 16 | .env.development.local 17 | .env.test.local 18 | .env.production.local 19 | 20 | npm-debug.log* 21 | yarn-debug.log* 22 | yarn-error.log* 23 | -------------------------------------------------------------------------------- /examples/basic/README.md: -------------------------------------------------------------------------------- 1 | Basic `redux-connect` routing example with SSR 2 | === 3 | 4 | To start application run: 5 | 6 | ````bash 7 | yarn 8 | yarn start 9 | ```` 10 | 11 | then open in browser [localhost:3000](http://localhost:3000) 12 | -------------------------------------------------------------------------------- /examples/basic/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "basic", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "bluebird": "^3.5.1", 7 | "compression": "^1.7.1", 8 | "express": "^4.16.2", 9 | "ignore-styles": "^5.0.1", 10 | "morgan": "^1.9.0", 11 | "react": "^16.2.0", 12 | "react-dom": "^16.2.0", 13 | "react-redux": "^5.0.6", 14 | "react-router": "^4.2.0", 15 | "react-router-config": "^1.0.0-beta.4", 16 | "react-router-dom": "^4.2.2", 17 | "react-scripts": "1.0.17", 18 | "redux": "^3.7.2", 19 | "rimraf": "^2.6.2" 20 | }, 21 | "scripts": { 22 | "start": "NODE_ENV=production npm run build && node server/index.js", 23 | "start:client": "react-scripts start", 24 | "build:server": "babel src --out-dir babel-src --copy-files", 25 | "build:client": "react-scripts build", 26 | "build": "npm run build:client && npm run build:server", 27 | "test": "react-scripts test --env=jsdom", 28 | "eject": "react-scripts eject", 29 | "clean": "rimraf build babel-src" 30 | }, 31 | "devDependencies": { 32 | "babel-cli": "^6.26.0", 33 | "babel-preset-env": "^1.6.1" 34 | }, 35 | "babel": { 36 | "presets": [ 37 | "env", 38 | "react-app" 39 | ] 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /examples/basic/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/makeomatic/redux-connect/6e4d865fa5fbb8db2a505cb177583f34fb9edd95/examples/basic/public/favicon.ico -------------------------------------------------------------------------------- /examples/basic/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | React App 10 | 11 | 12 | 15 |
{{SSR}}
16 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /examples/basic/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "React App", 3 | "name": "Create React App Sample", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | } 10 | ], 11 | "start_url": "./index.html", 12 | "display": "standalone", 13 | "theme_color": "#000000", 14 | "background_color": "#ffffff" 15 | } 16 | -------------------------------------------------------------------------------- /examples/basic/server/index.js: -------------------------------------------------------------------------------- 1 | const express = require('express'); 2 | const compression = require('compression'); 3 | const morgan = require('morgan'); 4 | const path = require('path'); 5 | const fs = require('fs'); 6 | const ignoreStyles = require('ignore-styles').default; 7 | const serverRender = require('../babel-src/serverRender').default; 8 | 9 | ignoreStyles(); 10 | 11 | const PORT = process.env.PORT || 3000; 12 | const app = express(); 13 | 14 | app.use(compression()); // gzip 15 | 16 | app.use(morgan('combined')); // logger 17 | 18 | app.use(express.static(path.resolve(__dirname, '..', 'build'), { index: false })); 19 | 20 | app.use('/', function index(req, res) { 21 | const filePath = path.resolve(__dirname, '..', 'build', 'index.html'); 22 | 23 | fs.readFile(filePath, { encoding: 'utf-8' }, function renderHtml(err, html) { 24 | if (err) { 25 | return res.status(404).end(); 26 | } 27 | 28 | return serverRender(req, res, html); 29 | }); 30 | }); 31 | 32 | app.listen(PORT, () => console.log(`App listening on port: ${PORT}`)); 33 | -------------------------------------------------------------------------------- /examples/basic/src/App.css: -------------------------------------------------------------------------------- 1 | .App { 2 | text-align: center; 3 | } 4 | 5 | .App-logo { 6 | animation: App-logo-spin infinite 20s linear; 7 | height: 80px; 8 | } 9 | 10 | .App-header { 11 | background-color: #222; 12 | height: 60px; 13 | padding: 20px; 14 | color: white; 15 | } 16 | 17 | .App-title { 18 | font-size: 1.5em; 19 | } 20 | 21 | .App-intro { 22 | font-size: large; 23 | } 24 | 25 | .App-route-list { 26 | list-style: none; 27 | padding: 0; 28 | } 29 | 30 | @keyframes App-logo-spin { 31 | from { transform: rotate(0deg); } 32 | to { transform: rotate(360deg); } 33 | } 34 | -------------------------------------------------------------------------------- /examples/basic/src/App.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Link from 'react-router-dom/Link'; 3 | import { renderRoutes } from 'react-router-config'; 4 | import './App.css'; 5 | 6 | function App({ route }) { 7 | return ( 8 |
9 |
10 |

Redux-Connect basic routing example with SSR

11 |
12 |
    13 |
  • 14 | 15 | {'Root'} 16 | 17 |
  • 18 |
  • 19 | 20 | {'Wrapped component'} 21 | 22 |
  • 23 |
  • 24 | 25 | {'Unwrapped component'} 26 | 27 |
  • 28 |
29 | {renderRoutes(route.routes)} 30 |
31 | ); 32 | } 33 | 34 | export default App; 35 | -------------------------------------------------------------------------------- /examples/basic/src/components/Unwrapped.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export default function Unwrapped() { 4 | return ( 5 |
6 | {'Unwrapped component'} 7 |
8 | ); 9 | } 10 | -------------------------------------------------------------------------------- /examples/basic/src/components/Wrapped.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Link from 'react-router-dom/Link'; 3 | import { renderRoutes } from 'react-router-config'; 4 | 5 | function Wrapped({ route, lunch }) { 6 | return ( 7 |
8 |

9 | {'Wrapped component'} 10 |

11 |
12 | {`lunch: ${lunch}`} 13 |
14 | 15 | {'Wrapped child'} 16 | 17 | {renderRoutes(route.routes)} 18 |
19 | ); 20 | } 21 | 22 | export default Wrapped; 23 | -------------------------------------------------------------------------------- /examples/basic/src/components/WrappedChild.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | function WrappedChild({ beverage }) { 4 | return ( 5 |
6 |

7 | {'Wrapped child component'} 8 |

9 |
10 | {`beverage: ${beverage}`} 11 |
12 |
13 | ); 14 | } 15 | 16 | export default WrappedChild; 17 | -------------------------------------------------------------------------------- /examples/basic/src/containers/Wrapped.js: -------------------------------------------------------------------------------- 1 | import Promise from 'bluebird'; 2 | import { asyncConnect } from '../../../../'; 3 | import Wrapped from '../components/Wrapped'; 4 | 5 | export default asyncConnect([{ 6 | key: 'lunch', 7 | promise: () => Promise.delay(500, 'borsch'), 8 | }])(Wrapped); 9 | -------------------------------------------------------------------------------- /examples/basic/src/containers/WrappedChild.js: -------------------------------------------------------------------------------- 1 | import Promise from 'bluebird'; 2 | import { asyncConnect } from '../../../../'; 3 | import WrappedChild from '../components/WrappedChild'; 4 | 5 | export default asyncConnect([{ 6 | key: 'beverage', 7 | promise: ({ helpers }) => Promise.delay(500, helpers.drink()), 8 | }])(WrappedChild); 9 | -------------------------------------------------------------------------------- /examples/basic/src/helpers.js: -------------------------------------------------------------------------------- 1 | export function drink() { 2 | return 'compote'; 3 | } 4 | -------------------------------------------------------------------------------- /examples/basic/src/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | padding: 0; 4 | font-family: sans-serif; 5 | } 6 | -------------------------------------------------------------------------------- /examples/basic/src/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import { Provider } from 'react-redux'; 4 | import BrowserRouter from 'react-router-dom/BrowserRouter'; 5 | import { ReduxAsyncConnect } from '../../../'; 6 | import configureStore from './store'; 7 | import routes from './routes'; 8 | import * as helpers from './helpers'; 9 | 10 | import './index.css'; 11 | 12 | const store = configureStore(window.__DATA); 13 | 14 | ReactDOM.hydrate( 15 | 16 | 17 | 18 | 19 | 20 | , document.getElementById('root')); 21 | -------------------------------------------------------------------------------- /examples/basic/src/routes.js: -------------------------------------------------------------------------------- 1 | import App from './App'; 2 | import Wrapped from './containers/Wrapped'; 3 | import WrappedChild from './containers/WrappedChild'; 4 | import Unwrapped from './components/Unwrapped'; 5 | 6 | export default [{ 7 | component: App, 8 | routes: [{ 9 | path: '/wrapped', 10 | component: Wrapped, 11 | routes: [{ 12 | path: '/wrapped/child', 13 | component: WrappedChild, 14 | }], 15 | }, { 16 | path: '/unwrapped', 17 | component: Unwrapped, 18 | }], 19 | }]; -------------------------------------------------------------------------------- /examples/basic/src/serverRender.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import url from 'url'; 3 | import { renderToString } from 'react-dom/server'; 4 | import { Provider } from 'react-redux'; 5 | import StaticRouter from 'react-router/StaticRouter'; 6 | import { ReduxAsyncConnect, loadOnServer } from '../../../'; 7 | import configureStore from './store'; 8 | import routes from './routes'; 9 | import * as helpers from './helpers'; 10 | 11 | export default function serverRender(req, res, html) { 12 | const store = configureStore(); 13 | const location = url.parse(req.url); 14 | 15 | loadOnServer({ store, location, routes, helpers }) 16 | .then(() => { 17 | const context = {}; 18 | 19 | const markup = renderToString( 20 | 21 | 22 | 23 | 24 | 25 | ); 26 | 27 | if (context.url) { 28 | return res.redirect(302, context.url); 29 | } 30 | 31 | const responseData = html.replace('{{SSR}}', markup) 32 | .replace('{{DATA}}', JSON.stringify(store.getState())); 33 | 34 | return res.status(context.code || 200).send(responseData); 35 | }) 36 | .catch(() => res.status(500).end()); 37 | } 38 | -------------------------------------------------------------------------------- /examples/basic/src/store.js: -------------------------------------------------------------------------------- 1 | import { createStore, combineReducers } from 'redux'; 2 | import { reducer as reduxAsyncConnect } from '../../../'; 3 | 4 | export default function configureStore(initialState) { 5 | return createStore(combineReducers({ reduxAsyncConnect }), initialState); 6 | } 7 | -------------------------------------------------------------------------------- /modules/components/AsyncConnect.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable react/forbid-prop-types,react/no-unused-prop-types,react/require-default-props */ 2 | import React, { Component } from 'react'; 3 | import PropTypes from 'prop-types'; 4 | import { Route } from 'react-router'; 5 | import { renderRoutes } from 'react-router-config'; 6 | import { ReactReduxContext } from 'react-redux'; 7 | import { loadAsyncConnect } from '../helpers/utils'; 8 | import { getMutableState } from '../helpers/state'; 9 | 10 | export class AsyncConnect extends Component { 11 | constructor(props) { 12 | super(props); 13 | 14 | this.state = { 15 | previousLocation: this.isLoaded() ? null : props.location, 16 | }; 17 | 18 | this.mounted = false; 19 | this.loadDataCounter = 0; 20 | } 21 | 22 | componentDidMount() { 23 | this.mounted = true; 24 | const dataLoaded = this.isLoaded(); 25 | 26 | // we dont need it if we already made it on server-side 27 | if (!dataLoaded) { 28 | this.loadAsyncData(this.props); 29 | } 30 | } 31 | 32 | UNSAFE_componentWillReceiveProps(nextProps) { // eslint-disable-line camelcase 33 | const { location, reloadOnPropsChange } = this.props; 34 | const navigated = location !== nextProps.location; 35 | 36 | // Allow a user supplied function to determine if an async reload is necessary 37 | if (navigated && reloadOnPropsChange(this.props, nextProps)) { 38 | this.loadAsyncData(nextProps); 39 | } 40 | } 41 | 42 | componentWillUnmount() { 43 | this.mounted = false; 44 | } 45 | 46 | isLoaded() { 47 | const { reduxConnectStore } = this.props; 48 | return getMutableState(reduxConnectStore.getState()).reduxAsyncConnect.loaded; 49 | } 50 | 51 | loadAsyncData({ reduxConnectStore, ...otherProps }) { 52 | const { location, beginGlobalLoad, endGlobalLoad } = this.props; 53 | const loadResult = loadAsyncConnect({ 54 | ...otherProps, 55 | store: reduxConnectStore, 56 | }); 57 | 58 | this.setState({ previousLocation: location }); 59 | 60 | // TODO: think of a better solution to a problem? 61 | this.loadDataCounter += 1; 62 | beginGlobalLoad(); 63 | return ((loadDataCounterOriginal) => loadResult.then(() => { 64 | // We need to change propsToShow only if loadAsyncData that called this promise 65 | // is the last invocation of loadAsyncData method. Otherwise we can face a situation 66 | // when user is changing route several times and we finally show him route that has 67 | // loaded props last time and not the last called route 68 | if ( 69 | this.loadDataCounter === loadDataCounterOriginal 70 | && this.mounted !== false 71 | ) { 72 | this.setState({ previousLocation: null }); 73 | } 74 | 75 | // TODO: investigate race conditions 76 | // do we need to call this if it's not last invocation? 77 | endGlobalLoad(); 78 | }))(this.loadDataCounter); 79 | } 80 | 81 | render() { 82 | const { previousLocation } = this.state; 83 | const { location, render } = this.props; 84 | 85 | return ( 86 | render(this.props)} 89 | /> 90 | ); 91 | } 92 | } 93 | 94 | AsyncConnect.propTypes = { 95 | render: PropTypes.func, 96 | beginGlobalLoad: PropTypes.func.isRequired, 97 | endGlobalLoad: PropTypes.func.isRequired, 98 | reloadOnPropsChange: PropTypes.func, 99 | routes: PropTypes.array.isRequired, 100 | location: PropTypes.object.isRequired, 101 | match: PropTypes.object.isRequired, 102 | helpers: PropTypes.any, 103 | reduxConnectStore: PropTypes.object.isRequired, 104 | }; 105 | 106 | AsyncConnect.defaultProps = { 107 | helpers: {}, 108 | reloadOnPropsChange() { 109 | return true; 110 | }, 111 | render({ routes }) { 112 | return renderRoutes(routes); 113 | }, 114 | }; 115 | 116 | const AsyncConnectWithContext = ({ context, ...otherProps }) => { 117 | const Context = context || ReactReduxContext; 118 | 119 | if (Context == null) { 120 | throw new Error('Please upgrade to react-redux v6'); 121 | } 122 | 123 | return ( 124 | 125 | {({ store: reduxConnectStore }) => ( 126 | 130 | )} 131 | 132 | ); 133 | }; 134 | 135 | AsyncConnectWithContext.propTypes = { 136 | context: PropTypes.object, 137 | }; 138 | 139 | export default AsyncConnectWithContext; 140 | -------------------------------------------------------------------------------- /modules/containers/AsyncConnect.js: -------------------------------------------------------------------------------- 1 | import { connect } from 'react-redux'; 2 | import { withRouter } from 'react-router'; 3 | import AsyncConnectWithContext from '../components/AsyncConnect'; 4 | import { beginGlobalLoad, endGlobalLoad } from '../store'; 5 | 6 | export default connect(null, { 7 | beginGlobalLoad, 8 | endGlobalLoad, 9 | })(withRouter(AsyncConnectWithContext)); 10 | -------------------------------------------------------------------------------- /modules/containers/decorator.js: -------------------------------------------------------------------------------- 1 | import { connect } from 'react-redux'; 2 | import { isPromise } from '../helpers/utils'; 3 | import { load, loadFail, loadSuccess } from '../store'; 4 | import { getMutableState, getImmutableState } from '../helpers/state'; 5 | 6 | /** 7 | * Wraps react components with data loaders 8 | * @param {Array} asyncItems 9 | * @return {WrappedComponent} 10 | */ 11 | function wrapWithDispatch(asyncItems) { 12 | return asyncItems.map((item) => { 13 | const { key } = item; 14 | if (!key) return item; 15 | 16 | return { 17 | ...item, 18 | promise: (options) => { 19 | const { store: { dispatch } } = options; 20 | const next = item.promise(options); 21 | 22 | // NOTE: possibly refactor this with a breaking change in mind for future versions 23 | // we can return result of processed promise/thunk if need be 24 | if (isPromise(next)) { 25 | dispatch(load(key)); 26 | // add action dispatchers 27 | next 28 | .then((data) => dispatch(loadSuccess(key, data))) 29 | .catch((err) => dispatch(loadFail(key, err))); 30 | } else if (next) { 31 | dispatch(loadSuccess(key, next)); 32 | } 33 | 34 | return next; 35 | }, 36 | }; 37 | }); 38 | } 39 | 40 | /** 41 | * Exports decorator, which wraps React components with asyncConnect and connect at the same time 42 | * @param {Array} asyncItems 43 | * @param {Function} [mapStateToProps] 44 | * @param {Object|Function} [mapDispatchToProps] 45 | * @param {Function} [mergeProps] 46 | * @param {Object} [options] 47 | * @return {Function} 48 | */ 49 | export function asyncConnect(asyncItems, mapStateToProps, mapDispatchToProps, mergeProps, options) { 50 | return (Component) => { 51 | Component.reduxAsyncConnect = wrapWithDispatch(asyncItems); 52 | 53 | const finalMapStateToProps = (state, ownProps) => { 54 | const mutableState = getMutableState(state); 55 | const asyncStateToProps = asyncItems.reduce((result, { key }) => { 56 | if (!key) { 57 | return result; 58 | } 59 | 60 | return { 61 | ...result, 62 | [key]: mutableState.reduxAsyncConnect[key], 63 | }; 64 | }, {}); 65 | 66 | if (typeof mapStateToProps !== 'function') { 67 | return asyncStateToProps; 68 | } 69 | 70 | return { 71 | ...mapStateToProps(getImmutableState(mutableState), ownProps), 72 | ...asyncStateToProps, 73 | }; 74 | }; 75 | 76 | return connect(finalMapStateToProps, mapDispatchToProps, mergeProps, options)(Component); 77 | }; 78 | } 79 | 80 | // convenience export 81 | export default asyncConnect; 82 | -------------------------------------------------------------------------------- /modules/helpers/state.js: -------------------------------------------------------------------------------- 1 | // Global vars holding the custom state conversion methods. Default is just identity methods 2 | const identity = (arg) => arg; 3 | 4 | // default pass-through functions 5 | let immutableStateFunc = identity; 6 | let mutableStateFunc = identity; 7 | 8 | /** 9 | * Sets the function to be used for converting mutable state to immutable state 10 | * @param {Function} func Converts mutable state to immutable state [(state) => state] 11 | */ 12 | export function setToImmutableStateFunc(func) { 13 | immutableStateFunc = func; 14 | } 15 | 16 | /** 17 | * Sets the function to be used for converting immutable state to mutable state 18 | * @param {Function} func Converts immutable state to mutable state [(state) => state] 19 | */ 20 | export function setToMutableStateFunc(func) { 21 | mutableStateFunc = func; 22 | } 23 | 24 | /** 25 | * Call when needing to transform mutable data to immutable data using the preset function 26 | * @param {Object} state Mutable state thats converted to immutable state by user defined func 27 | */ 28 | export const getImmutableState = (state) => immutableStateFunc(state); 29 | 30 | /** 31 | * Call when needing to transform immutable data to mutable data using the preset function 32 | * @param {Immutable} state Immutable state thats converted to mutable state by user defined func 33 | */ 34 | export const getMutableState = (state) => mutableStateFunc(state); 35 | -------------------------------------------------------------------------------- /modules/helpers/utils.js: -------------------------------------------------------------------------------- 1 | import { matchRoutes } from 'react-router-config'; 2 | import { endGlobalLoad } from '../store'; 3 | 4 | /** 5 | * Tells us if input looks like promise or not 6 | * @param {Mixed} obj 7 | * @return {Boolean} 8 | */ 9 | export function isPromise(obj) { 10 | return typeof obj === 'object' && obj && obj.then instanceof Function; 11 | } 12 | 13 | /** 14 | * Utility to be able to iterate over array of promises in an async fashion 15 | * @param {Array} iterable 16 | * @param {Function} iterator 17 | * @return {Promise} 18 | */ 19 | const mapSeries = Promise.mapSeries || function promiseMapSeries(iterable, iterator) { 20 | const { length } = iterable; 21 | const results = new Array(length); 22 | let i = 0; 23 | 24 | function iterateOverResults() { 25 | return iterator(iterable[i], i, iterable).then((result) => { 26 | results[i] = result; 27 | i += 1; 28 | if (i < length) { 29 | return iterateOverResults(); 30 | } 31 | 32 | return results; 33 | }); 34 | } 35 | 36 | return iterateOverResults(); 37 | }; 38 | 39 | /** 40 | * We need to iterate over all components for specified routes. 41 | * Components array can include objects if named components are used: 42 | * https://github.com/rackt/react-router/blob/latest/docs/API.md#named-components 43 | * 44 | * @param components 45 | * @param iterator 46 | */ 47 | export function eachComponents(components, iterator) { 48 | const l = components.length; 49 | for (let i = 0; i < l; i += 1) { 50 | const component = components[i]; 51 | if (typeof component === 'object') { 52 | const keys = Object.keys(component); 53 | keys.forEach((key) => iterator(component[key], i, key)); 54 | } else { 55 | iterator(component, i); 56 | } 57 | } 58 | } 59 | 60 | /** 61 | * Returns flattened array of components that are wrapped with reduxAsyncConnect 62 | * @param {Array} components 63 | * @return {Array} 64 | */ 65 | export function filterAndFlattenComponents(components) { 66 | const flattened = []; 67 | eachComponents(components, (component) => { 68 | if (component && component.reduxAsyncConnect) { 69 | flattened.push(component); 70 | } 71 | }); 72 | return flattened; 73 | } 74 | 75 | /** 76 | * Returns an array of components that are wrapped 77 | * with reduxAsyncConnect 78 | * @param {Array} branch 79 | * @return {Array} 80 | */ 81 | export function filterComponents(branch) { 82 | return branch.reduce((result, { route, match }) => { 83 | if (route.component && route.component.reduxAsyncConnect) { 84 | result.push([route.component, { route, match }]); 85 | } 86 | 87 | return result; 88 | }, []); 89 | } 90 | 91 | /** 92 | * Function that accepts components with reduxAsyncConnect definitions 93 | * and loads data 94 | * @param {Object} data.routes - static route configuration 95 | * @param {String} data.location - location object e.g. { pathname, query, ... } 96 | * @param {Function} [data.filter] - filtering function 97 | * @return {Promise} 98 | */ 99 | export function loadAsyncConnect({ 100 | location, 101 | routes = [], 102 | filter = () => true, 103 | ...rest 104 | }) { 105 | const layered = filterComponents(matchRoutes(routes, location.pathname)); 106 | 107 | if (layered.length === 0) { 108 | return Promise.resolve(); 109 | } 110 | 111 | // this allows us to have nested promises, that rely on each other's completion 112 | // cycle 113 | return mapSeries(layered, ([component, routeParams]) => { 114 | if (component == null) { 115 | return Promise.resolve(); 116 | } 117 | 118 | // Collect the results of each component 119 | const results = []; 120 | const asyncItemsArr = []; 121 | const asyncItems = component.reduxAsyncConnect; 122 | asyncItemsArr.push(...asyncItems); 123 | 124 | // get array of results 125 | results.push(...asyncItems.reduce((itemsResults, item) => { 126 | if (filter(item, component)) { 127 | let promiseOrResult = item.promise({ 128 | ...rest, 129 | ...routeParams, 130 | location, 131 | routes, 132 | }); 133 | 134 | if (isPromise(promiseOrResult)) { 135 | promiseOrResult = promiseOrResult.catch((error) => ({ error })); 136 | } 137 | 138 | itemsResults.push(promiseOrResult); 139 | } 140 | 141 | return itemsResults; 142 | }, [])); 143 | 144 | return Promise.all(results) 145 | .then((finalResults) => finalResults.reduce((finalResult, result, idx) => { 146 | const { key } = asyncItemsArr[idx]; 147 | if (key) { 148 | finalResult[key] = result; 149 | } 150 | 151 | return finalResult; 152 | }, {})); 153 | }); 154 | } 155 | 156 | /** 157 | * Helper to load data on server 158 | * @param {Mixed} args 159 | * @return {Promise} 160 | */ 161 | export function loadOnServer(args) { 162 | return loadAsyncConnect(args).then(() => { 163 | args.store.dispatch(endGlobalLoad()); 164 | }); 165 | } 166 | -------------------------------------------------------------------------------- /modules/index.js: -------------------------------------------------------------------------------- 1 | export ReduxAsyncConnect from './containers/AsyncConnect'; 2 | export { asyncConnect } from './containers/decorator'; 3 | export { loadOnServer } from './helpers/utils'; 4 | export { reducer, immutableReducer } from './store'; 5 | export { setToImmutableStateFunc, setToMutableStateFunc } from './helpers/state'; 6 | -------------------------------------------------------------------------------- /modules/store.js: -------------------------------------------------------------------------------- 1 | import { createAction, handleActions } from 'redux-actions'; 2 | import { getMutableState, getImmutableState } from './helpers/state'; 3 | 4 | export const clearKey = createAction('@redux-conn/CLEAR'); 5 | export const beginGlobalLoad = createAction('@redux-conn/BEGIN_GLOBAL_LOAD'); 6 | export const endGlobalLoad = createAction('@redux-conn/END_GLOBAL_LOAD'); 7 | export const load = createAction('@redux-conn/LOAD', (key) => ({ key })); 8 | export const loadSuccess = createAction('@redux-conn/LOAD_SUCCESS', (key, data) => ({ key, data })); 9 | export const loadFail = createAction('@redux-conn/LOAD_FAIL', (key, error) => ({ key, error })); 10 | 11 | const initialState = { 12 | loaded: false, 13 | loadState: {}, 14 | }; 15 | 16 | export const reducer = handleActions({ 17 | [beginGlobalLoad]: (state) => ({ 18 | ...state, 19 | loaded: false, 20 | }), 21 | 22 | [endGlobalLoad]: (state) => ({ 23 | ...state, 24 | loaded: true, 25 | }), 26 | 27 | [load]: (state, { payload }) => ({ 28 | ...state, 29 | loadState: { 30 | ...state.loadState, 31 | [payload.key]: { 32 | loading: true, 33 | loaded: false, 34 | }, 35 | }, 36 | }), 37 | 38 | [loadSuccess]: (state, { payload: { key, data } }) => ({ 39 | ...state, 40 | loadState: { 41 | ...state.loadState, 42 | [key]: { 43 | loading: false, 44 | loaded: true, 45 | error: null, 46 | }, 47 | }, 48 | [key]: data, 49 | }), 50 | 51 | [loadFail]: (state, { payload: { key, error } }) => ({ 52 | ...state, 53 | loadState: { 54 | ...state.loadState, 55 | [key]: { 56 | loading: false, 57 | loaded: false, 58 | error, 59 | }, 60 | }, 61 | }), 62 | 63 | [clearKey]: (state, { payload }) => ({ 64 | ...state, 65 | loadState: { 66 | ...state.loadState, 67 | [payload]: { 68 | loading: false, 69 | loaded: false, 70 | error: null, 71 | }, 72 | }, 73 | [payload]: null, 74 | }), 75 | 76 | }, initialState); 77 | 78 | export const immutableReducer = function wrapReducer(immutableState, action) { 79 | // We need to convert immutable state to mutable state before our reducer can act upon it 80 | let mutableState; 81 | if (immutableState === undefined) { 82 | // if state is undefined (no initial state yet) then we can't convert it, so let the 83 | // reducer set the initial state for us 84 | mutableState = immutableState; 85 | } else { 86 | // Convert immutable state to mutable state so our reducer will accept it 87 | mutableState = getMutableState(immutableState); 88 | } 89 | 90 | // Run the reducer and then re-convert the mutable output state back to immutable state 91 | return getImmutableState(reducer(mutableState, action)); 92 | }; 93 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "redux-connect", 3 | "version": "10.0.0", 4 | "description": "It allows you to request async data, store them in redux state and connect them to your react component.", 5 | "main": "lib/index.js", 6 | "module": "es/index.js", 7 | "sideEffects": false, 8 | "repository": { 9 | "type": "git", 10 | "url": "https://github.com/makeomatic/redux-connect" 11 | }, 12 | "scripts": { 13 | "build": "yarn build:lib && yarn build:es", 14 | "prebuild:lib": "rm -rf lib/*", 15 | "build:lib": "cross-env NODE_ENV=production babel --out-dir lib modules", 16 | "prebuild:es": "rm -rf es/*", 17 | "build:es": "cross-env NODE_ENV=production BABEL_ENV=es babel --out-dir es modules", 18 | "lint": "eslint ./modules", 19 | "pretest": "yarn lint", 20 | "test": "jest", 21 | "preversion": "yarn test", 22 | "postversion": "npm publish && git push && git push --tags", 23 | "prepare": "yarn build" 24 | }, 25 | "keywords": [ 26 | "redux", 27 | "react", 28 | "connect", 29 | "async", 30 | "props" 31 | ], 32 | "author": "Vitaly Aminev ", 33 | "contributors": [ 34 | "Rodion Salnik (http://brocoders.com)" 35 | ], 36 | "license": "MIT", 37 | "bugs": { 38 | "url": "https://github.com/makeomatic/redux-connect/issues" 39 | }, 40 | "homepage": "https://github.com/makeomatic/redux-connect", 41 | "peerDependencies": { 42 | "prop-types": "15.x.x", 43 | "react": "^16.8.4", 44 | "react-redux": "7.x.x", 45 | "react-router": "5.x.x", 46 | "react-router-config": "5.x.x", 47 | "react-router-dom": "5.x.x", 48 | "redux-actions": "2.x.x" 49 | }, 50 | "devDependencies": { 51 | "@babel/cli": "^7.6.4", 52 | "@babel/core": "^7.6.4", 53 | "@babel/plugin-proposal-class-properties": "^7.5.5", 54 | "@babel/plugin-proposal-export-default-from": "^7.5.2", 55 | "@babel/preset-env": "^7.6.3", 56 | "@babel/preset-react": "^7.6.3", 57 | "babel-eslint": "^10.0.3", 58 | "babel-plugin-transform-react-remove-prop-types": "^0.4.24", 59 | "bluebird": "^3.7.1", 60 | "cross-env": "^6.0.3", 61 | "enzyme": "^3.10.0", 62 | "enzyme-adapter-react-16": "^1.15.1", 63 | "eslint": "^6.5.1", 64 | "eslint-config-airbnb": "^18.0.1", 65 | "eslint-plugin-import": "^2.18.2", 66 | "eslint-plugin-jsx-a11y": "^6.2.3", 67 | "eslint-plugin-react": "^7.16.0", 68 | "eslint-plugin-react-hooks": "^2.1.2", 69 | "immutable": "^3.8.2", 70 | "jest": "^24.9.0", 71 | "prop-types": "^15.7.2", 72 | "raf": "^3.4.1", 73 | "react": "^16.10.2", 74 | "react-dom": "^16.10.2", 75 | "react-redux": "^7.1.1", 76 | "react-router": "^5.1.2", 77 | "react-router-config": "^5.1.1", 78 | "react-router-dom": "^5.1.2", 79 | "react-test-renderer": "^16.10.2", 80 | "redux": "^4.0.4", 81 | "redux-actions": "^2.6.5", 82 | "redux-immutable": "^4.0.0", 83 | "regenerator-runtime": "^0.13.3", 84 | "sinon": "^7.5.0" 85 | }, 86 | "jest": { 87 | "automock": false, 88 | "testEnvironment": "jsdom", 89 | "testURL": "http://localhost", 90 | "setupFiles": [ 91 | "raf/polyfill" 92 | ] 93 | }, 94 | "files": [ 95 | "modules/", 96 | "lib/", 97 | "es/" 98 | ] 99 | } 100 | --------------------------------------------------------------------------------