From daca94babc18838d4ed6b850fbd2aa7a3905f988 Mon Sep 17 00:00:00 2001 From: Vitaly Aminev Date: Wed, 17 Aug 2016 15:01:35 +0300 Subject: [PATCH] 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 --- .eslintrc | 3 +- README.MD | 46 ++++++++++ __tests__/.eslintrc | 7 ++ __tests__/redux-connect.spec.js | 135 ++++++++++++++++++++++++++--- modules/components/AsyncConnect.js | 16 +++- modules/containers/AsyncConnect.js | 2 +- modules/containers/decorator.js | 9 +- modules/helpers/state.js | 34 ++++++++ modules/index.js | 3 +- modules/store.js | 60 ++++++------- package.json | 48 +++++----- 11 files changed, 286 insertions(+), 77 deletions(-) create mode 100644 __tests__/.eslintrc create mode 100644 modules/helpers/state.js diff --git a/.eslintrc b/.eslintrc index c7e3cd9..70188ae 100644 --- a/.eslintrc +++ b/.eslintrc @@ -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", diff --git a/README.MD b/README.MD index 139535f..1aff67e 100644 --- a/README.MD +++ b/README.MD @@ -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 })); @@ -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 }) => ( + + } history={history}> + {getRoutes(store)} + + +); +``` + + ## Comparing with other libraries There are some solutions of problem described above: @@ -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) diff --git a/__tests__/.eslintrc b/__tests__/.eslintrc new file mode 100644 index 0000000..65c6ea9 --- /dev/null +++ b/__tests__/.eslintrc @@ -0,0 +1,7 @@ +{ + "rules": { + "import/no-extraneous-dependencies": 0, + "react/prop-types": 0, + "new-cap": 0 + } +} \ No newline at end of file diff --git a/__tests__/redux-connect.spec.js b/__tests__/redux-connect.spec.js index 3c147b5..51b0b77 100644 --- a/__tests__/redux-connect.spec.js +++ b/__tests__/redux-connect.spec.js @@ -1,11 +1,13 @@ -/* 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'; @@ -13,6 +15,7 @@ import AsyncConnect from '../modules/components/AsyncConnect'; import { asyncConnect, reducer as reduxAsyncConnect, + immutableReducer, loadOnServer, } from '../modules/index'; @@ -20,14 +23,37 @@ describe('', 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 => ; - const App = ({ ...rest, lunch }) =>
{lunch}
; + + /* 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, + }) =>
{lunch}
; + /* eslint-enable no-unused-vars */ + const WrappedApp = asyncConnect([{ key: 'lunch', promise: () => Promise.resolve('sandwich'), @@ -38,8 +64,10 @@ describe('', function suite() { externalState: state.reduxAsyncConnect.$$external, remappedProp: ownProps.route.remap, }))(App); + const UnwrappedApp = () =>
Hi, I do not use @asyncConnect
; const reducers = combineReducers({ reduxAsyncConnect }); + const routes = ( @@ -48,7 +76,7 @@ describe('', function suite() { ); // inter-test state - let state; + let testState; pit('properly fetches data on the server', function test() { return new Promise((resolve, reject) => { @@ -76,13 +104,13 @@ describe('', 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 @@ -99,7 +127,7 @@ describe('', 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'); @@ -231,9 +259,9 @@ describe('', 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 @@ -248,4 +276,83 @@ describe('', 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 = ( + + + + + ); + + // 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( + + + + ); + + 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); + }); + }); + }); }); diff --git a/modules/components/AsyncConnect.js b/modules/components/AsyncConnect.js index 0649bfc..8d18cd9 100644 --- a/modules/components/AsyncConnect.js +++ b/modules/components/AsyncConnect.js @@ -1,8 +1,9 @@ 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, @@ -10,6 +11,7 @@ export default class AsyncConnect extends Component { beginGlobalLoad: PropTypes.func.isRequired, endGlobalLoad: PropTypes.func.isRequired, helpers: PropTypes.any, + reloadOnPropsChange: PropTypes.func, }; static contextTypes = { @@ -17,6 +19,9 @@ export default class AsyncConnect extends Component { }; static defaultProps = { + reloadOnPropsChange() { + return true; + }, render(props) { return ; }, @@ -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) { @@ -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) { @@ -86,3 +94,5 @@ export default class AsyncConnect extends Component { return propsToShow && this.props.render(propsToShow); } } + +export default AsyncConnect; diff --git a/modules/containers/AsyncConnect.js b/modules/containers/AsyncConnect.js index 8298d03..04d4965 100644 --- a/modules/containers/AsyncConnect.js +++ b/modules/containers/AsyncConnect.js @@ -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); diff --git a/modules/containers/decorator.js b/modules/containers/decorator.js index 28a4961..3db626b 100644 --- a/modules/containers/decorator.js +++ b/modules/containers/decorator.js @@ -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 @@ -51,6 +52,7 @@ 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; @@ -58,7 +60,7 @@ export function asyncConnect(asyncItems, mapStateToProps, mapDispatchToProps, me return { ...result, - [key]: state.reduxAsyncConnect[key], + [key]: mutableState.reduxAsyncConnect[key], }; }, {}); @@ -67,7 +69,7 @@ export function asyncConnect(asyncItems, mapStateToProps, mapDispatchToProps, me } return { - ...mapStateToProps(state, ownProps), + ...mapStateToProps(getImmutableState(mutableState), ownProps), ...asyncStateToProps, }; }; @@ -75,3 +77,6 @@ export function asyncConnect(asyncItems, mapStateToProps, mapDispatchToProps, me return connect(finalMapStateToProps, mapDispatchToProps, mergeProps, options)(Component); }; } + +// convinience export +export default asyncConnect; diff --git a/modules/helpers/state.js b/modules/helpers/state.js new file mode 100644 index 0000000..4f4b1aa --- /dev/null +++ b/modules/helpers/state.js @@ -0,0 +1,34 @@ +// Global vars holding the custom state conversion methods. Default is just identity methods +const identity = arg => arg; + +// default pass-through functions +let immutableStateFunc = identity; +let mutableStateFunc = identity; + +/** + * Sets the function to be used for converting mutable state to immutable state + * @param {Function} func Converts mutable state to immutable state [(state) => state] + */ +export function setToImmutableStateFunc(func) { + immutableStateFunc = func; +} + +/** + * Sets the function to be used for converting immutable state to mutable state + * @param {Function} func Converts immutable state to mutable state [(state) => state] + */ +export function setToMutableStateFunc(func) { + mutableStateFunc = func; +} + +/** + * Call when needing to transform mutable data to immutable data using the preset function + * @param {Object} state Mutable state thats converted to immutable state by user defined func + */ +export const getImmutableState = state => immutableStateFunc(state); + +/** + * Call when needing to transform immutable data to mutable data using the preset function + * @param {Immutable} state Immutable state thats converted to mutable state by user defined func + */ +export const getMutableState = state => mutableStateFunc(state); diff --git a/modules/index.js b/modules/index.js index 0293afa..867525e 100644 --- a/modules/index.js +++ b/modules/index.js @@ -1,4 +1,5 @@ export ReduxAsyncConnect from './containers/AsyncConnect'; export { asyncConnect } from './containers/decorator'; export { loadOnServer } from './helpers/utils'; -export { reducer } from './store'; +export { reducer, immutableReducer } from './store'; +export { setToImmutableStateFunc, setToMutableStateFunc } from './helpers/state'; diff --git a/modules/store.js b/modules/store.js index 60827e0..ff6e545 100644 --- a/modules/store.js +++ b/modules/store.js @@ -1,11 +1,12 @@ import { createAction, handleActions } from 'redux-actions'; +import { getMutableState, getImmutableState } from './helpers/state'; -export const LOAD = '@reduxAsyncConnect/LOAD'; -export const LOAD_SUCCESS = '@reduxAsyncConnect/LOAD_SUCCESS'; -export const LOAD_FAIL = '@reduxAsyncConnect/LOAD_FAIL'; -export const CLEAR = '@reduxAsyncConnect/CLEAR'; -export const BEGIN_GLOBAL_LOAD = '@reduxAsyncConnect/BEGIN_GLOBAL_LOAD'; -export const END_GLOBAL_LOAD = '@reduxAsyncConnect/END_GLOBAL_LOAD'; +export const clearKey = createAction('@redux-conn/CLEAR'); +export const beginGlobalLoad = createAction('@redux-conn/BEGIN_GLOBAL_LOAD'); +export const endGlobalLoad = createAction('@redux-conn/END_GLOBAL_LOAD'); +export const load = createAction('@redux-conn/LOAD', key => ({ key })); +export const loadSuccess = createAction('@redux-conn/LOAD_SUCCESS', (key, data) => ({ key, data })); +export const loadFail = createAction('@redux-conn/LOAD_FAIL', (key, error) => ({ key, error })); const initialState = { loaded: false, @@ -13,18 +14,17 @@ const initialState = { }; export const reducer = handleActions({ - - [BEGIN_GLOBAL_LOAD]: (state) => ({ + [beginGlobalLoad]: (state) => ({ ...state, loaded: false, }), - [END_GLOBAL_LOAD]: (state) => ({ + [endGlobalLoad]: (state) => ({ ...state, loaded: true, }), - [LOAD]: (state, { payload }) => ({ + [load]: (state, { payload }) => ({ ...state, loadState: { ...state.loadState, @@ -35,7 +35,7 @@ export const reducer = handleActions({ }, }), - [LOAD_SUCCESS]: (state, { payload: { key, data } }) => ({ + [loadSuccess]: (state, { payload: { key, data } }) => ({ ...state, loadState: { ...state.loadState, @@ -48,7 +48,7 @@ export const reducer = handleActions({ [key]: data, }), - [LOAD_FAIL]: (state, { payload: { key, error } }) => ({ + [loadFail]: (state, { payload: { key, error } }) => ({ ...state, loadState: { ...state.loadState, @@ -60,7 +60,7 @@ export const reducer = handleActions({ }, }), - [CLEAR]: (state, { payload }) => ({ + [clearKey]: (state, { payload }) => ({ ...state, loadState: { ...state.loadState, @@ -75,22 +75,18 @@ export const reducer = handleActions({ }, initialState); -export const clearKey = createAction(CLEAR); - -export const beginGlobalLoad = createAction(BEGIN_GLOBAL_LOAD); - -export const endGlobalLoad = createAction(END_GLOBAL_LOAD); - -export const load = createAction(LOAD, (key) => ({ - key, -})); - -export const loadSuccess = createAction(LOAD_SUCCESS, (key, data) => ({ - key, - data, -})); - -export const loadFail = createAction(LOAD_FAIL, (key, error) => ({ - key, - error, -})); +export const immutableReducer = function wrapReducer(immutableState, action) { + // We need to convert immutable state to mutable state before our reducer can act upon it + let mutableState; + if (immutableState === undefined) { + // if state is undefined (no initial state yet) then we can't convert it, so let the + // reducer set the initial state for us + mutableState = immutableState; + } else { + // Convert immutable state to mutable state so our reducer will accept it + mutableState = getMutableState(immutableState); + } + + // Run the reducer and then re-convert the mutable output state back to immutable state + return getImmutableState(reducer(mutableState, action)); +}; diff --git a/package.json b/package.json index ba2fe65..d6390d7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "redux-connect", - "version": "2.4.1", + "version": "3.0.0", "description": "It allows you to request async data, store them in redux state and connect them to your react component.", "main": "lib/index.js", "repository": { @@ -35,35 +35,37 @@ "react-redux": "~4.x.x" }, "devDependencies": { - "babel-cli": "^6.4.5", - "babel-core": "^6.4.5", - "babel-eslint": "^6.0.0", - "babel-jest": "^12.0.2", - "babel-plugin-transform-runtime": "^6.8.0", - "babel-preset-es2015": "^6.3.13", + "babel-cli": "^6.11.4", + "babel-core": "^6.13.2", + "babel-eslint": "^6.1.2", + "babel-jest": "^14.1.0", + "babel-plugin-transform-runtime": "^6.12.0", + "babel-preset-es2015": "^6.13.2", "babel-preset-es2015-loose": "^7.0.0", - "babel-preset-react": "^6.3.13", + "babel-preset-react": "^6.11.1", "babel-preset-react-optimize": "^1.0.1", "babel-preset-stage-0": "^6.3.13", - "bluebird": "^3.3.3", - "enzyme": "^2.2.0", - "eslint": "^2.9.0", - "eslint-config-airbnb": "^9.0.1", - "eslint-plugin-import": "^1.6.1", - "eslint-plugin-jsx-a11y": "^1.2.0", - "eslint-plugin-react": "^5.0.1", - "jest-cli": "^12.0.2", - "react": "^15.0.0", - "react-addons-test-utils": "^15.0.2", - "react-dom": "^15.0.0", + "bluebird": "^3.4.1", + "enzyme": "^2.4.1", + "eslint": "^3.3.1", + "eslint-config-airbnb": "^10.0.1", + "eslint-plugin-import": "^1.13.0", + "eslint-plugin-jsx-a11y": "^2.1.0", + "eslint-plugin-react": "^6.1.1", + "immutable": "^3.8.1", + "jest-cli": "^14.1.0", + "react": "^15.3.0", + "react-addons-test-utils": "^15.3.0", + "react-dom": "^15.3.0", "react-redux": "^4.4.0", - "react-router": "^2.4.0", + "react-router": "^2.6.1", "redux": "^3.3.1", - "sinon": "^1.17.4" + "redux-immutable": "^3.0.7", + "sinon": "^1.17.5" }, "dependencies": { - "redux-actions": "^0.9.1", - "babel-runtime": "~6.x.x" + "babel-runtime": "^6.11.6", + "redux-actions": "^0.10.1" }, "jest": { "automock": false,