Skip to content

Commit

Permalink
Release v3.0.0: immutable.js support, small breaking changes in actio…
Browse files Browse the repository at this point in the history
…n 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
  • Loading branch information
AVVS authored Aug 17, 2016
1 parent 47b90dd commit daca94b
Show file tree
Hide file tree
Showing 11 changed files with 286 additions and 77 deletions.
3 changes: 2 additions & 1 deletion .eslintrc
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@
},
"rules": {
"no-param-reassign": [2, {"props": false}],
"prefer-arrow-callback": 0
"prefer-arrow-callback": 0,
"react/jsx-filename-extension": 0
},
"settings": {
"import/parser": "babel-eslint",
Expand Down
46 changes: 46 additions & 0 deletions README.MD
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ import { ReduxAsyncConnect, loadOnServer, reducer as reduxAsyncConnect } from 'r
import createHistory from 'history/lib/createMemoryHistory';
import { Provider } from 'react-redux';
import { createStore, combineReducers } from 'redux';
import serialize from 'serialize-javascript';

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

## Usage with `ImmutableJS`

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.

```js
import { setToImmutableStateFunc, setToMutableStateFunc, immutableReducer as reduxAsyncConnect } from 'redux-connect';

// Set the mutability/immutability functions
setToImmutableStateFunc((mutableState) => Immutable.fromJS(mutableState));
setToMutableStateFunc((immutableState) => immutableState.toJS());

// Thats all, now just use redux-connect as normal
export const rootReducer = combineReducers({
reduxAsyncConnect,
...
})
```

**React Router Issue**

While using the above immutablejs solution, an issue arose causing infinite recursion after firing
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`

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.

Method signature `(props, nextProps) => bool`

```js
const reloadOnPropsChange = (props, nextProps) => {
// reload only when path/route has changed
return props.location.pathname !== nextProps.location.pathname;
};

export const Root = ({ store, history }) => (
<Provider store={store} key="provider">
<Router render={(props) => <ReduxAsyncConnect {...props}
reloadOnPropsChange={reloadOnPropsChange}/>} history={history}>
{getRoutes(store)}
</Router>
</Provider>
);
```


## Comparing with other libraries

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

## Contributors
- [Vitaly Aminev](https://en.makeomatic.ru)
- [Todd Bluhm](https://github.com/toddbluhm)
- [Eliseu Monar](https://github.com/eliseumds)
- [Rui Araújo](https://github.com/ruiaraujo)
- [Rodion Salnik](https://github.com/sars)
Expand Down
7 changes: 7 additions & 0 deletions __tests__/.eslintrc
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"rules": {
"import/no-extraneous-dependencies": 0,
"react/prop-types": 0,
"new-cap": 0
}
}
135 changes: 121 additions & 14 deletions __tests__/redux-connect.spec.js
Original file line number Diff line number Diff line change
@@ -1,33 +1,59 @@
/* eslint-disable react/prop-types */
import Promise from 'bluebird';
import React from 'react';
import { Provider, connect } from 'react-redux';
import { Router, createMemoryHistory, match, Route, IndexRoute } from 'react-router';
import { createStore, combineReducers } from 'redux';
import { combineReducers as combineImmutableReducers } from 'redux-immutable';
import { mount, render } from 'enzyme';
import { spy } from 'sinon';
import { default as Immutable } from 'immutable';
import { setToImmutableStateFunc, setToMutableStateFunc } from '../modules/helpers/state';

// import module
import { endGlobalLoad, beginGlobalLoad } from '../modules/store';
import AsyncConnect from '../modules/components/AsyncConnect';
import {
asyncConnect,
reducer as reduxAsyncConnect,
immutableReducer,
loadOnServer,
} from '../modules/index';

describe('<ReduxAsyncConnect />', function suite() {
const initialState = {
reduxAsyncConnect: { loaded: false, loadState: {}, $$external: 'supported' },
};

const endGlobalLoadSpy = spy(endGlobalLoad);
const beginGlobalLoadSpy = spy(beginGlobalLoad);

const ReduxAsyncConnect = connect(null, {
beginGlobalLoad: beginGlobalLoadSpy,
endGlobalLoad: endGlobalLoadSpy,
})(AsyncConnect);

const renderReduxAsyncConnect = props => <ReduxAsyncConnect {...props} />;
const App = ({ ...rest, lunch }) => <div {...rest}>{lunch}</div>;

/* eslint-disable no-unused-vars */
const App = ({
// NOTE: use this as a reference of props passed to your component from router
// these are the params that are passed from router
history,
location,
params,
route,
routeParams,
routes,
externalState,
remappedProp,
// our param
lunch,
// react-redux dispatch prop
dispatch,
...rest,
}) => <div {...rest}>{lunch}</div>;
/* eslint-enable no-unused-vars */

const WrappedApp = asyncConnect([{
key: 'lunch',
promise: () => Promise.resolve('sandwich'),
Expand All @@ -38,8 +64,10 @@ describe('<ReduxAsyncConnect />', function suite() {
externalState: state.reduxAsyncConnect.$$external,
remappedProp: ownProps.route.remap,
}))(App);

const UnwrappedApp = () => <div>Hi, I do not use @asyncConnect</div>;
const reducers = combineReducers({ reduxAsyncConnect });

const routes = (
<Route path="/">
<IndexRoute component={WrappedApp} remap="on" />
Expand All @@ -48,7 +76,7 @@ describe('<ReduxAsyncConnect />', function suite() {
);

// inter-test state
let state;
let testState;

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

expect(html.text()).toContain('sandwich');
state = store.getState();
expect(state.reduxAsyncConnect.loaded).toBe(true);
expect(state.reduxAsyncConnect.lunch).toBe('sandwich');
expect(state.reduxAsyncConnect.action).toBe('yammi');
expect(state.reduxAsyncConnect.loadState.lunch.loading).toBe(false);
expect(state.reduxAsyncConnect.loadState.lunch.loaded).toBe(true);
expect(state.reduxAsyncConnect.loadState.lunch.error).toBe(null);
testState = store.getState();
expect(testState.reduxAsyncConnect.loaded).toBe(true);
expect(testState.reduxAsyncConnect.lunch).toBe('sandwich');
expect(testState.reduxAsyncConnect.action).toBe('yammi');
expect(testState.reduxAsyncConnect.loadState.lunch.loading).toBe(false);
expect(testState.reduxAsyncConnect.loadState.lunch.loaded).toBe(true);
expect(testState.reduxAsyncConnect.loadState.lunch.error).toBe(null);
expect(eat.calledOnce).toBe(true);

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

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

expect(html.text()).toContain('I do not use @asyncConnect');
state = store.getState();
expect(state.reduxAsyncConnect.loaded).toBe(true);
expect(state.reduxAsyncConnect.lunch).toBe(undefined);
testState = store.getState();
expect(testState.reduxAsyncConnect.loaded).toBe(true);
expect(testState.reduxAsyncConnect.lunch).toBe(undefined);
expect(eat.called).toBe(false);

// global loader spy
Expand All @@ -248,4 +276,83 @@ describe('<ReduxAsyncConnect />', function suite() {
});
});
});

pit('properly fetches data on the server when using immutable data structures', function test() {
// We use a special reducer built for handling immutable js data
const immutableReducers = combineImmutableReducers({
reduxAsyncConnect: immutableReducer,
});

// We need to re-wrap the component so the mapStateToProps expects immutable js data
const ImmutableWrappedApp = asyncConnect([{
key: 'lunch',
promise: () => Promise.resolve('sandwich'),
}, {
key: 'action',
promise: ({ helpers }) => Promise.resolve(helpers.eat()),
}], (state, ownProps) => ({
externalState: state.getIn(['reduxAsyncConnect', '$$external']), // use immutablejs methods
remappedProp: ownProps.route.remap,
}))(App);

// Custom routes using our custom immutable wrapped component
const immutableRoutes = (
<Route path="/">
<IndexRoute component={ImmutableWrappedApp} remap="on" />
<Route path="/notconnected" component={UnwrappedApp} />
</Route>
);

// Set the mutability/immutability functions
setToImmutableStateFunc((mutableState) => Immutable.fromJS(mutableState));
setToMutableStateFunc((immutableState) => immutableState.toJS());

return new Promise((resolve, reject) => {
// Create the store with initial immutable data
const store = createStore(immutableReducers, Immutable.Map({}));
const eat = spy(() => 'yammi');

// Use the custom immutable routes
match({ routes: immutableRoutes, location: '/' }, (err, redirect, renderProps) => {
if (err) {
return reject(err);
}

if (redirect) {
return reject(new Error('redirected'));
}

if (!renderProps) {
return reject(new Error('404'));
}

return loadOnServer({ ...renderProps, store, helpers: { eat } }).then(() => {
const html = render(
<Provider store={store} key="provider">
<ReduxAsyncConnect {...renderProps} />
</Provider>
);

expect(html.text()).toContain('sandwich');
testState = store.getState().toJS(); // convert to plain js for assertions
expect(testState.reduxAsyncConnect.loaded).toBe(true);
expect(testState.reduxAsyncConnect.lunch).toBe('sandwich');
expect(testState.reduxAsyncConnect.action).toBe('yammi');
expect(testState.reduxAsyncConnect.loadState.lunch.loading).toBe(false);
expect(testState.reduxAsyncConnect.loadState.lunch.loaded).toBe(true);
expect(testState.reduxAsyncConnect.loadState.lunch.error).toBe(null);
expect(eat.calledOnce).toBe(true);

// global loader spy
expect(endGlobalLoadSpy.called).toBe(false);
expect(beginGlobalLoadSpy.called).toBe(false);
endGlobalLoadSpy.reset();
beginGlobalLoadSpy.reset();

resolve();
})
.catch(reject);
});
});
});
});
16 changes: 13 additions & 3 deletions modules/components/AsyncConnect.js
Original file line number Diff line number Diff line change
@@ -1,22 +1,27 @@
import React, { PropTypes, Component } from 'react';
import RouterContext from 'react-router/lib/RouterContext';
import { loadAsyncConnect } from '../helpers/utils';
import { getMutableState } from '../helpers/state';

export default class AsyncConnect extends Component {
export class AsyncConnect extends Component {
static propTypes = {
components: PropTypes.array.isRequired,
params: PropTypes.object.isRequired,
render: PropTypes.func.isRequired,
beginGlobalLoad: PropTypes.func.isRequired,
endGlobalLoad: PropTypes.func.isRequired,
helpers: PropTypes.any,
reloadOnPropsChange: PropTypes.func,
};

static contextTypes = {
store: PropTypes.object.isRequired,
};

static defaultProps = {
reloadOnPropsChange() {
return true;
},
render(props) {
return <RouterContext {...props} />;
},
Expand Down Expand Up @@ -44,7 +49,10 @@ export default class AsyncConnect extends Component {
}

componentWillReceiveProps(nextProps) {
this.loadAsyncData(nextProps);
// Allow a user supplied function to determine if an async reload is necessary
if (this.props.reloadOnPropsChange(this.props, nextProps)) {
this.loadAsyncData(nextProps);
}
}

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

isLoaded() {
return this.context.store.getState().reduxAsyncConnect.loaded;
return getMutableState(this.context.store.getState()).reduxAsyncConnect.loaded;
}

loadAsyncData(props) {
Expand Down Expand Up @@ -86,3 +94,5 @@ export default class AsyncConnect extends Component {
return propsToShow && this.props.render(propsToShow);
}
}

export default AsyncConnect;
2 changes: 1 addition & 1 deletion modules/containers/AsyncConnect.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import AsyncConnect from '../components/AsyncConnect';
import { connect } from 'react-redux';
import { AsyncConnect } from '../components/AsyncConnect';
import { beginGlobalLoad, endGlobalLoad } from '../store';

export default connect(null, { beginGlobalLoad, endGlobalLoad })(AsyncConnect);
9 changes: 7 additions & 2 deletions modules/containers/decorator.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { connect } from 'react-redux';
import { isPromise } from '../helpers/utils';
import { load, loadFail, loadSuccess } from '../store';
import { getMutableState, getImmutableState } from '../helpers/state';

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

const finalMapStateToProps = (state, ownProps) => {
const mutableState = getMutableState(state);
const asyncStateToProps = asyncItems.reduce((result, { key }) => {
if (!key) {
return result;
}

return {
...result,
[key]: state.reduxAsyncConnect[key],
[key]: mutableState.reduxAsyncConnect[key],
};
}, {});

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

return {
...mapStateToProps(state, ownProps),
...mapStateToProps(getImmutableState(mutableState), ownProps),
...asyncStateToProps,
};
};

return connect(finalMapStateToProps, mapDispatchToProps, mergeProps, options)(Component);
};
}

// convinience export
export default asyncConnect;
Loading

0 comments on commit daca94b

Please sign in to comment.