diff --git a/src/components/Provider.js b/src/components/Provider.js index 51f6f7670..303f2e822 100644 --- a/src/components/Provider.js +++ b/src/components/Provider.js @@ -1,5 +1,6 @@ import { Component, PropTypes, Children } from 'react' import storeShape from '../utils/storeShape' +import batchedUpdates from '../utils/batchedUpdates' let didWarnAboutReceivingStore = false function warnAboutReceivingStore() { @@ -21,6 +22,65 @@ function warnAboutReceivingStore() { /* eslint-disable no-console */ } +function batchListenerCalls(store) { + let currentListeners = [] + let nextListeners = currentListeners + + const ensureCanMutateNextListeners = () => { + if (nextListeners === currentListeners) { + nextListeners = currentListeners.slice() + } + } + + let notifyListeners = () => { + const listeners = currentListeners = nextListeners + for (let i = 0; i < listeners.length; i++) { + listeners[i]() + } + } + + let batchListener = () => { + batchedUpdates(notifyListeners) + } + + let unsubscribeBatchListener + + return { + ...store, + subscribe(listener) { + if (typeof listener !== 'function') { + throw new Error('Expected listener to be a function.') + } + + let isSubscribed = true + + ensureCanMutateNextListeners() + nextListeners.push(listener) + + if (!unsubscribeBatchListener) { + unsubscribeBatchListener = store.subscribe(batchListener) + } + + return () => { + if (!isSubscribed) { + return + } + + isSubscribed = false + + ensureCanMutateNextListeners() + const index = nextListeners.indexOf(listener) + nextListeners.splice(index, 1) + + if (!nextListeners.length && unsubscribeBatchListener) { + unsubscribeBatchListener() + unsubscribeBatchListener = null + } + } + } + } +} + export default class Provider extends Component { getChildContext() { return { store: this.store } @@ -28,7 +88,7 @@ export default class Provider extends Component { constructor(props, context) { super(props, context) - this.store = props.store + this.store = batchListenerCalls(props.store) } render() { diff --git a/src/utils/batchedUpdates.js b/src/utils/batchedUpdates.js new file mode 100644 index 000000000..bff4cdaab --- /dev/null +++ b/src/utils/batchedUpdates.js @@ -0,0 +1 @@ +export { unstable_batchedUpdates as default } from 'react-dom' diff --git a/src/utils/batchedUpdates.native.js b/src/utils/batchedUpdates.native.js new file mode 100644 index 000000000..b6b93efe1 --- /dev/null +++ b/src/utils/batchedUpdates.native.js @@ -0,0 +1 @@ +export { unstable_batchedUpdates as default } from 'react-native' diff --git a/test/components/Provider.spec.js b/test/components/Provider.spec.js index 8aa902ffa..e796fed57 100644 --- a/test/components/Provider.spec.js +++ b/test/components/Provider.spec.js @@ -1,8 +1,9 @@ import expect from 'expect' import React, { PropTypes, Component } from 'react' +import ReactDOM from 'react-dom' import TestUtils from 'react-addons-test-utils' import { createStore } from 'redux' -import { Provider } from '../../src/index' +import { Provider, connect } from '../../src/index' describe('React', () => { describe('Provider', () => { @@ -60,7 +61,10 @@ describe('React', () => { expect(spy.calls.length).toBe(0) const child = TestUtils.findRenderedComponentWithType(tree, Child) - expect(child.context.store).toBe(store) + expect(child.context.store).toExist() + expect(child.context.store.dispatch).toBeA('function') + expect(child.context.store.getState).toBeA('function') + expect(child.context.store.subscribe).toBeA('function') }) it('should warn once when receiving a new store in props', () => { @@ -107,5 +111,70 @@ describe('React', () => { expect(child.context.store.getState()).toEqual(11) expect(spy.calls.length).toBe(0) }) + + it('should pass state consistently to mapState', () => { + function stringBuilder(prev = '', action) { + return action.type === 'APPEND' + ? prev + action.body + : prev + } + + const store = createStore(stringBuilder) + + store.dispatch({ type: 'APPEND', body: 'a' }) + let childMapStateInvokes = 0 + + @connect(state => ({ state }), null, null, { withRef: true }) + class Container extends Component { + emitChange() { + store.dispatch({ type: 'APPEND', body: 'b' }) + } + + render() { + return ( +
+ + +
+ ) + } + } + + @connect((state, parentProps) => { + childMapStateInvokes++ + // The state from parent props should always be consistent with the current state + expect(state).toEqual(parentProps.parentState) + return {} + }) + class ChildContainer extends Component { + render() { + return
+ } + } + + const tree = TestUtils.renderIntoDocument( + + + + ) + + expect(childMapStateInvokes).toBe(1) + + // The store state stays consistent when setState calls are batched + ReactDOM.unstable_batchedUpdates(() => { + store.dispatch({ type: 'APPEND', body: 'c' }) + }) + expect(childMapStateInvokes).toBe(2) + + // setState calls DOM handlers are batched + const container = TestUtils.findRenderedComponentWithType(tree, Container) + const node = container.getWrappedInstance().refs.button + TestUtils.Simulate.click(node) + expect(childMapStateInvokes).toBe(3) + + // Provider uses unstable_batchedUpdates() under the hood + store.dispatch({ type: 'APPEND', body: 'd' }) + expect(childMapStateInvokes).toBe(4) + }) }) }) diff --git a/test/components/connect.spec.js b/test/components/connect.spec.js index 82a1c3ef5..366a121c4 100644 --- a/test/components/connect.spec.js +++ b/test/components/connect.spec.js @@ -1468,13 +1468,13 @@ describe('React', () => { expect(childMapStateInvokes).toBe(3) // In future all setState calls will be batched[1]. Uncomment when it - // happens. For now redux-batched-updates middleware can be used as - // workaround this. + // happens. For now you can use that takes care of this. // // [1]: https://twitter.com/sebmarkbage/status/642366976824864768 // - // store.dispatch({ type: 'APPEND', body: 'd' }) - // expect(childMapStateInvokes).toBe(4) + expect(() => { + store.dispatch({ type: 'APPEND', body: 'd' }) + }).toThrow(`Expected 'acbd' to equal 'acb'`) }) it('should not render the wrapped component when mapState does not produce change', () => {