├── .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 | [](https://www.npmjs.com/package/redux-connect)
4 | [](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 |
--------------------------------------------------------------------------------