Skip to content

Commit daca94b

Browse files
authored
Release v3.0.0: immutable.js support, small breaking changes in action exports
BREAKING CHANGE: redux-actions upgraded to 0.10.x, action types are now embedded into the action creators. This could potentially break your app if you relied on actions used by redux-connect in your own reducers. Please use action creators directly as action names as they are embedded inside them. Read more on the redux-actions repository. Feat: adds support for Immutable.JS, curtesy of @toddbluhm. Read more in the README updates Small outline: * Added ability to use immutable stores with this lib * Added global methods for converting from mutable to immutable and back again * Added special method for controlling when the ReduxAsyncConnect component re-syncs with the server * Simplify the reducer portion to just wrap the original reducer * Export the immutable reducer as a separate reducer * Updated docs to reflect new immutable reducer export
1 parent 47b90dd commit daca94b

File tree

11 files changed

+286
-77
lines changed

11 files changed

+286
-77
lines changed

.eslintrc

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,8 @@
88
},
99
"rules": {
1010
"no-param-reassign": [2, {"props": false}],
11-
"prefer-arrow-callback": 0
11+
"prefer-arrow-callback": 0,
12+
"react/jsx-filename-extension": 0
1213
},
1314
"settings": {
1415
"import/parser": "babel-eslint",

README.MD

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@ import { ReduxAsyncConnect, loadOnServer, reducer as reduxAsyncConnect } from 'r
6969
import createHistory from 'history/lib/createMemoryHistory';
7070
import { Provider } from 'react-redux';
7171
import { createStore, combineReducers } from 'redux';
72+
import serialize from 'serialize-javascript';
7273

7374
app.get('*', (req, res) => {
7475
const store = createStore(combineReducers({ reduxAsyncConnect }));
@@ -144,6 +145,50 @@ you use
144145
const render = applyRouterMiddleware(...middleware);
145146
```
146147

148+
## Usage with `ImmutableJS`
149+
150+
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.
151+
152+
```js
153+
import { setToImmutableStateFunc, setToMutableStateFunc, immutableReducer as reduxAsyncConnect } from 'redux-connect';
154+
155+
// Set the mutability/immutability functions
156+
setToImmutableStateFunc((mutableState) => Immutable.fromJS(mutableState));
157+
setToMutableStateFunc((immutableState) => immutableState.toJS());
158+
159+
// Thats all, now just use redux-connect as normal
160+
export const rootReducer = combineReducers({
161+
reduxAsyncConnect,
162+
...
163+
})
164+
```
165+
166+
**React Router Issue**
167+
168+
While using the above immutablejs solution, an issue arose causing infinite recursion after firing
169+
off a react standard action. The recursion was caused because the `componentWillReceiveProps` method will attempt to resync with the server. Thus `componentWillReceiveProps -> resync with server -> changes props via reducer -> componentWillReceiveProps`
170+
171+
The solution was to only resync with server on route changes. A `reloadOnPropsChange` prop is expose on the ReduxAsyncConnect component to allow customization of when a resync to the server should occur.
172+
173+
Method signature `(props, nextProps) => bool`
174+
175+
```js
176+
const reloadOnPropsChange = (props, nextProps) => {
177+
// reload only when path/route has changed
178+
return props.location.pathname !== nextProps.location.pathname;
179+
};
180+
181+
export const Root = ({ store, history }) => (
182+
<Provider store={store} key="provider">
183+
<Router render={(props) => <ReduxAsyncConnect {...props}
184+
reloadOnPropsChange={reloadOnPropsChange}/>} history={history}>
185+
{getRoutes(store)}
186+
</Router>
187+
</Provider>
188+
);
189+
```
190+
191+
147192
## Comparing with other libraries
148193

149194
There are some solutions of problem described above:
@@ -172,6 +217,7 @@ until data is loaded.
172217

173218
## Contributors
174219
- [Vitaly Aminev](https://en.makeomatic.ru)
220+
- [Todd Bluhm](https://github.com/toddbluhm)
175221
- [Eliseu Monar](https://github.com/eliseumds)
176222
- [Rui Araújo](https://github.com/ruiaraujo)
177223
- [Rodion Salnik](https://github.com/sars)

__tests__/.eslintrc

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
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

Lines changed: 121 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,33 +1,59 @@
1-
/* eslint-disable react/prop-types */
21
import Promise from 'bluebird';
32
import React from 'react';
43
import { Provider, connect } from 'react-redux';
54
import { Router, createMemoryHistory, match, Route, IndexRoute } from 'react-router';
65
import { createStore, combineReducers } from 'redux';
6+
import { combineReducers as combineImmutableReducers } from 'redux-immutable';
77
import { mount, render } from 'enzyme';
88
import { spy } from 'sinon';
9+
import { default as Immutable } from 'immutable';
10+
import { setToImmutableStateFunc, setToMutableStateFunc } from '../modules/helpers/state';
911

1012
// import module
1113
import { endGlobalLoad, beginGlobalLoad } from '../modules/store';
1214
import AsyncConnect from '../modules/components/AsyncConnect';
1315
import {
1416
asyncConnect,
1517
reducer as reduxAsyncConnect,
18+
immutableReducer,
1619
loadOnServer,
1720
} from '../modules/index';
1821

1922
describe('<ReduxAsyncConnect />', function suite() {
2023
const initialState = {
2124
reduxAsyncConnect: { loaded: false, loadState: {}, $$external: 'supported' },
2225
};
26+
2327
const endGlobalLoadSpy = spy(endGlobalLoad);
2428
const beginGlobalLoadSpy = spy(beginGlobalLoad);
29+
2530
const ReduxAsyncConnect = connect(null, {
2631
beginGlobalLoad: beginGlobalLoadSpy,
2732
endGlobalLoad: endGlobalLoadSpy,
2833
})(AsyncConnect);
34+
2935
const renderReduxAsyncConnect = props => <ReduxAsyncConnect {...props} />;
30-
const App = ({ ...rest, lunch }) => <div {...rest}>{lunch}</div>;
36+
37+
/* eslint-disable no-unused-vars */
38+
const App = ({
39+
// NOTE: use this as a reference of props passed to your component from router
40+
// these are the params that are passed from router
41+
history,
42+
location,
43+
params,
44+
route,
45+
routeParams,
46+
routes,
47+
externalState,
48+
remappedProp,
49+
// our param
50+
lunch,
51+
// react-redux dispatch prop
52+
dispatch,
53+
...rest,
54+
}) => <div {...rest}>{lunch}</div>;
55+
/* eslint-enable no-unused-vars */
56+
3157
const WrappedApp = asyncConnect([{
3258
key: 'lunch',
3359
promise: () => Promise.resolve('sandwich'),
@@ -38,8 +64,10 @@ describe('<ReduxAsyncConnect />', function suite() {
3864
externalState: state.reduxAsyncConnect.$$external,
3965
remappedProp: ownProps.route.remap,
4066
}))(App);
67+
4168
const UnwrappedApp = () => <div>Hi, I do not use @asyncConnect</div>;
4269
const reducers = combineReducers({ reduxAsyncConnect });
70+
4371
const routes = (
4472
<Route path="/">
4573
<IndexRoute component={WrappedApp} remap="on" />
@@ -48,7 +76,7 @@ describe('<ReduxAsyncConnect />', function suite() {
4876
);
4977

5078
// inter-test state
51-
let state;
79+
let testState;
5280

5381
pit('properly fetches data on the server', function test() {
5482
return new Promise((resolve, reject) => {
@@ -76,13 +104,13 @@ describe('<ReduxAsyncConnect />', function suite() {
76104
);
77105

78106
expect(html.text()).toContain('sandwich');
79-
state = store.getState();
80-
expect(state.reduxAsyncConnect.loaded).toBe(true);
81-
expect(state.reduxAsyncConnect.lunch).toBe('sandwich');
82-
expect(state.reduxAsyncConnect.action).toBe('yammi');
83-
expect(state.reduxAsyncConnect.loadState.lunch.loading).toBe(false);
84-
expect(state.reduxAsyncConnect.loadState.lunch.loaded).toBe(true);
85-
expect(state.reduxAsyncConnect.loadState.lunch.error).toBe(null);
107+
testState = store.getState();
108+
expect(testState.reduxAsyncConnect.loaded).toBe(true);
109+
expect(testState.reduxAsyncConnect.lunch).toBe('sandwich');
110+
expect(testState.reduxAsyncConnect.action).toBe('yammi');
111+
expect(testState.reduxAsyncConnect.loadState.lunch.loading).toBe(false);
112+
expect(testState.reduxAsyncConnect.loadState.lunch.loaded).toBe(true);
113+
expect(testState.reduxAsyncConnect.loadState.lunch.error).toBe(null);
86114
expect(eat.calledOnce).toBe(true);
87115

88116
// global loader spy
@@ -99,7 +127,7 @@ describe('<ReduxAsyncConnect />', function suite() {
99127
});
100128

101129
it('properly picks data up from the server', function test() {
102-
const store = createStore(reducers, state);
130+
const store = createStore(reducers, testState);
103131
const history = createMemoryHistory();
104132
const proto = ReduxAsyncConnect.WrappedComponent.prototype;
105133
const eat = spy(() => 'yammi');
@@ -231,9 +259,9 @@ describe('<ReduxAsyncConnect />', function suite() {
231259
);
232260

233261
expect(html.text()).toContain('I do not use @asyncConnect');
234-
state = store.getState();
235-
expect(state.reduxAsyncConnect.loaded).toBe(true);
236-
expect(state.reduxAsyncConnect.lunch).toBe(undefined);
262+
testState = store.getState();
263+
expect(testState.reduxAsyncConnect.loaded).toBe(true);
264+
expect(testState.reduxAsyncConnect.lunch).toBe(undefined);
237265
expect(eat.called).toBe(false);
238266

239267
// global loader spy
@@ -248,4 +276,83 @@ describe('<ReduxAsyncConnect />', function suite() {
248276
});
249277
});
250278
});
279+
280+
pit('properly fetches data on the server when using immutable data structures', function test() {
281+
// We use a special reducer built for handling immutable js data
282+
const immutableReducers = combineImmutableReducers({
283+
reduxAsyncConnect: immutableReducer,
284+
});
285+
286+
// We need to re-wrap the component so the mapStateToProps expects immutable js data
287+
const ImmutableWrappedApp = asyncConnect([{
288+
key: 'lunch',
289+
promise: () => Promise.resolve('sandwich'),
290+
}, {
291+
key: 'action',
292+
promise: ({ helpers }) => Promise.resolve(helpers.eat()),
293+
}], (state, ownProps) => ({
294+
externalState: state.getIn(['reduxAsyncConnect', '$$external']), // use immutablejs methods
295+
remappedProp: ownProps.route.remap,
296+
}))(App);
297+
298+
// Custom routes using our custom immutable wrapped component
299+
const immutableRoutes = (
300+
<Route path="/">
301+
<IndexRoute component={ImmutableWrappedApp} remap="on" />
302+
<Route path="/notconnected" component={UnwrappedApp} />
303+
</Route>
304+
);
305+
306+
// Set the mutability/immutability functions
307+
setToImmutableStateFunc((mutableState) => Immutable.fromJS(mutableState));
308+
setToMutableStateFunc((immutableState) => immutableState.toJS());
309+
310+
return new Promise((resolve, reject) => {
311+
// Create the store with initial immutable data
312+
const store = createStore(immutableReducers, Immutable.Map({}));
313+
const eat = spy(() => 'yammi');
314+
315+
// Use the custom immutable routes
316+
match({ routes: immutableRoutes, location: '/' }, (err, redirect, renderProps) => {
317+
if (err) {
318+
return reject(err);
319+
}
320+
321+
if (redirect) {
322+
return reject(new Error('redirected'));
323+
}
324+
325+
if (!renderProps) {
326+
return reject(new Error('404'));
327+
}
328+
329+
return loadOnServer({ ...renderProps, store, helpers: { eat } }).then(() => {
330+
const html = render(
331+
<Provider store={store} key="provider">
332+
<ReduxAsyncConnect {...renderProps} />
333+
</Provider>
334+
);
335+
336+
expect(html.text()).toContain('sandwich');
337+
testState = store.getState().toJS(); // convert to plain js for assertions
338+
expect(testState.reduxAsyncConnect.loaded).toBe(true);
339+
expect(testState.reduxAsyncConnect.lunch).toBe('sandwich');
340+
expect(testState.reduxAsyncConnect.action).toBe('yammi');
341+
expect(testState.reduxAsyncConnect.loadState.lunch.loading).toBe(false);
342+
expect(testState.reduxAsyncConnect.loadState.lunch.loaded).toBe(true);
343+
expect(testState.reduxAsyncConnect.loadState.lunch.error).toBe(null);
344+
expect(eat.calledOnce).toBe(true);
345+
346+
// global loader spy
347+
expect(endGlobalLoadSpy.called).toBe(false);
348+
expect(beginGlobalLoadSpy.called).toBe(false);
349+
endGlobalLoadSpy.reset();
350+
beginGlobalLoadSpy.reset();
351+
352+
resolve();
353+
})
354+
.catch(reject);
355+
});
356+
});
357+
});
251358
});

modules/components/AsyncConnect.js

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,27 @@
11
import React, { PropTypes, Component } from 'react';
22
import RouterContext from 'react-router/lib/RouterContext';
33
import { loadAsyncConnect } from '../helpers/utils';
4+
import { getMutableState } from '../helpers/state';
45

5-
export default class AsyncConnect extends Component {
6+
export class AsyncConnect extends Component {
67
static propTypes = {
78
components: PropTypes.array.isRequired,
89
params: PropTypes.object.isRequired,
910
render: PropTypes.func.isRequired,
1011
beginGlobalLoad: PropTypes.func.isRequired,
1112
endGlobalLoad: PropTypes.func.isRequired,
1213
helpers: PropTypes.any,
14+
reloadOnPropsChange: PropTypes.func,
1315
};
1416

1517
static contextTypes = {
1618
store: PropTypes.object.isRequired,
1719
};
1820

1921
static defaultProps = {
22+
reloadOnPropsChange() {
23+
return true;
24+
},
2025
render(props) {
2126
return <RouterContext {...props} />;
2227
},
@@ -44,7 +49,10 @@ export default class AsyncConnect extends Component {
4449
}
4550

4651
componentWillReceiveProps(nextProps) {
47-
this.loadAsyncData(nextProps);
52+
// Allow a user supplied function to determine if an async reload is necessary
53+
if (this.props.reloadOnPropsChange(this.props, nextProps)) {
54+
this.loadAsyncData(nextProps);
55+
}
4856
}
4957

5058
shouldComponentUpdate(nextProps, nextState) {
@@ -56,7 +64,7 @@ export default class AsyncConnect extends Component {
5664
}
5765

5866
isLoaded() {
59-
return this.context.store.getState().reduxAsyncConnect.loaded;
67+
return getMutableState(this.context.store.getState()).reduxAsyncConnect.loaded;
6068
}
6169

6270
loadAsyncData(props) {
@@ -86,3 +94,5 @@ export default class AsyncConnect extends Component {
8694
return propsToShow && this.props.render(propsToShow);
8795
}
8896
}
97+
98+
export default AsyncConnect;

modules/containers/AsyncConnect.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
import AsyncConnect from '../components/AsyncConnect';
21
import { connect } from 'react-redux';
2+
import { AsyncConnect } from '../components/AsyncConnect';
33
import { beginGlobalLoad, endGlobalLoad } from '../store';
44

55
export default connect(null, { beginGlobalLoad, endGlobalLoad })(AsyncConnect);

modules/containers/decorator.js

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { connect } from 'react-redux';
22
import { isPromise } from '../helpers/utils';
33
import { load, loadFail, loadSuccess } from '../store';
4+
import { getMutableState, getImmutableState } from '../helpers/state';
45

56
/**
67
* Wraps react components with data loaders
@@ -51,14 +52,15 @@ export function asyncConnect(asyncItems, mapStateToProps, mapDispatchToProps, me
5152
Component.reduxAsyncConnect = wrapWithDispatch(asyncItems);
5253

5354
const finalMapStateToProps = (state, ownProps) => {
55+
const mutableState = getMutableState(state);
5456
const asyncStateToProps = asyncItems.reduce((result, { key }) => {
5557
if (!key) {
5658
return result;
5759
}
5860

5961
return {
6062
...result,
61-
[key]: state.reduxAsyncConnect[key],
63+
[key]: mutableState.reduxAsyncConnect[key],
6264
};
6365
}, {});
6466

@@ -67,11 +69,14 @@ export function asyncConnect(asyncItems, mapStateToProps, mapDispatchToProps, me
6769
}
6870

6971
return {
70-
...mapStateToProps(state, ownProps),
72+
...mapStateToProps(getImmutableState(mutableState), ownProps),
7173
...asyncStateToProps,
7274
};
7375
};
7476

7577
return connect(finalMapStateToProps, mapDispatchToProps, mergeProps, options)(Component);
7678
};
7779
}
80+
81+
// convinience export
82+
export default asyncConnect;

0 commit comments

Comments
 (0)