From 5fb24e44ceac6ac19b0d2bdffea87b467520f405 Mon Sep 17 00:00:00 2001 From: Riad Benguella Date: Thu, 12 Jul 2018 16:07:41 +0100 Subject: [PATCH] Data Module: Introduce the "registry" concept (#7527) * Data: Move registry into own file * Export the "createRegistry" function * Adding remountOnPropChange HoC --- .../src/components/registry-provider/index.js | 15 + .../components/remountOnPropChange/index.js | 42 + .../remountOnPropChange/test/index.js | 71 ++ .../src/components/with-dispatch/index.js | 91 ++ .../components/with-dispatch/test/index.js | 64 ++ .../data/src/components/with-select/index.js | 138 +++ .../src/components/with-select/test/index.js | 450 ++++++++ packages/data/src/default-registry.js | 3 + packages/data/src/index.js | 490 +------- packages/data/src/registry.js | 316 ++++++ packages/data/src/store/index.js | 14 +- packages/data/src/test/index.js | 1008 ----------------- packages/data/src/test/registry.js | 586 ++++++++++ viewport/test/if-viewport-matches.js | 13 +- 14 files changed, 1800 insertions(+), 1501 deletions(-) create mode 100644 packages/data/src/components/registry-provider/index.js create mode 100644 packages/data/src/components/remountOnPropChange/index.js create mode 100644 packages/data/src/components/remountOnPropChange/test/index.js create mode 100644 packages/data/src/components/with-dispatch/index.js create mode 100644 packages/data/src/components/with-dispatch/test/index.js create mode 100644 packages/data/src/components/with-select/index.js create mode 100644 packages/data/src/components/with-select/test/index.js create mode 100644 packages/data/src/default-registry.js create mode 100644 packages/data/src/registry.js delete mode 100644 packages/data/src/test/index.js create mode 100644 packages/data/src/test/registry.js diff --git a/packages/data/src/components/registry-provider/index.js b/packages/data/src/components/registry-provider/index.js new file mode 100644 index 0000000000000..c296f46a1ab1a --- /dev/null +++ b/packages/data/src/components/registry-provider/index.js @@ -0,0 +1,15 @@ +/** + * WordPress dependencies + */ +import { createContext } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import defaultRegistry from '../../default-registry'; + +const { Consumer, Provider } = createContext( defaultRegistry ); + +export const RegistryConsumer = Consumer; + +export default Provider; diff --git a/packages/data/src/components/remountOnPropChange/index.js b/packages/data/src/components/remountOnPropChange/index.js new file mode 100644 index 0000000000000..0e6f06d94d234 --- /dev/null +++ b/packages/data/src/components/remountOnPropChange/index.js @@ -0,0 +1,42 @@ +/** + * WordPress dependencies + */ +import { createHigherOrderComponent, Component } from '@wordpress/element'; + +/** + * Higher-order component creator, creating a new component that remounts + * the wrapped component each time a given prop value changes. + * + * @param {string} propName Prop name to monitor. + * + * @return {Function} Higher-order component. + */ +const remountOnPropChange = ( propName ) => createHigherOrderComponent( + ( WrappedComponent ) => class extends Component { + constructor( props ) { + super( ...arguments ); + this.state = { + propChangeId: 0, + propValue: props[ propName ], + }; + } + + static getDerivedStateFromProps( props, state ) { + if ( props[ propName ] === state.propValue ) { + return null; + } + + return { + propChangeId: state.propChangeId + 1, + propValue: props[ propName ], + }; + } + + render() { + return ; + } + }, + 'remountOnPropChange' +); + +export default remountOnPropChange; diff --git a/packages/data/src/components/remountOnPropChange/test/index.js b/packages/data/src/components/remountOnPropChange/test/index.js new file mode 100644 index 0000000000000..92be6d4ec637d --- /dev/null +++ b/packages/data/src/components/remountOnPropChange/test/index.js @@ -0,0 +1,71 @@ +/** + * External dependencies + */ +import TestRenderer from 'react-test-renderer'; + +/** + * WordPress dependencies + */ +import { Component } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import remountOnPropChange from '../'; + +describe( 'remountOnPropChange', () => { + let count = 0; + class MountCounter extends Component { + constructor() { + super( ...arguments ); + this.state = { + count: 0, + }; + } + + componentDidMount() { + count++; + this.setState( { + count: count, + } ); + } + + render() { + return this.state.count; + } + } + + beforeEach( () => { + count = 0; + } ); + + it( 'Should not remount the inner component if the prop value doesn\'t change', () => { + const Wrapped = remountOnPropChange( 'monitor' )( MountCounter ); + const testRenderer = TestRenderer.create( + + ); + + expect( testRenderer.toJSON() ).toBe( '1' ); + + // Changing an unmonitored prop + testRenderer.update( + + ); + expect( testRenderer.toJSON() ).toBe( '1' ); + } ); + + it( 'Should remount the inner component if the prop value changes', () => { + const Wrapped = remountOnPropChange( 'monitor' )( MountCounter ); + const testRenderer = TestRenderer.create( + + ); + + expect( testRenderer.toJSON() ).toBe( '1' ); + + // Changing an the monitored prop remounts the component + testRenderer.update( + + ); + expect( testRenderer.toJSON() ).toBe( '2' ); + } ); +} ); diff --git a/packages/data/src/components/with-dispatch/index.js b/packages/data/src/components/with-dispatch/index.js new file mode 100644 index 0000000000000..13022c8b98dab --- /dev/null +++ b/packages/data/src/components/with-dispatch/index.js @@ -0,0 +1,91 @@ +/** + * External dependencies + */ +import { mapValues } from 'lodash'; + +/** + * WordPress dependencies + */ +import { + Component, + compose, + createHigherOrderComponent, + pure, +} from '@wordpress/element'; + +/** + * Internal dependencies + */ +import remountOnPropChange from '../remountOnPropChange'; +import { RegistryConsumer } from '../registry-provider'; + +/** + * Higher-order component used to add dispatch props using registered action + * creators. + * + * @param {Object} mapDispatchToProps Object of prop names where value is a + * dispatch-bound action creator, or a + * function to be called with with the + * component's props and returning an + * action creator. + * + * @return {Component} Enhanced component with merged dispatcher props. + */ +const withDispatch = ( mapDispatchToProps ) => createHigherOrderComponent( + compose( [ + pure, + ( WrappedComponent ) => { + const ComponentWithDispatch = remountOnPropChange( 'registry' )( class extends Component { + constructor( props ) { + super( ...arguments ); + + this.proxyProps = {}; + this.setProxyProps( props ); + } + + componentDidUpdate() { + this.setProxyProps( this.props ); + } + + proxyDispatch( propName, ...args ) { + // Original dispatcher is a pre-bound (dispatching) action creator. + mapDispatchToProps( this.props.registry.dispatch, this.props.ownProps )[ propName ]( ...args ); + } + + setProxyProps( props ) { + // Assign as instance property so that in reconciling subsequent + // renders, the assigned prop values are referentially equal. + const propsToDispatchers = mapDispatchToProps( this.props.registry.dispatch, props.ownProps ); + this.proxyProps = mapValues( propsToDispatchers, ( dispatcher, propName ) => { + // Prebind with prop name so we have reference to the original + // dispatcher to invoke. Track between re-renders to avoid + // creating new function references every render. + if ( this.proxyProps.hasOwnProperty( propName ) ) { + return this.proxyProps[ propName ]; + } + + return this.proxyDispatch.bind( this, propName ); + } ); + } + + render() { + return ; + } + } ); + + return ( ownProps ) => ( + + { ( registry ) => ( + + ) } + + ); + }, + ] ), + 'withDispatch' +); + +export default withDispatch; diff --git a/packages/data/src/components/with-dispatch/test/index.js b/packages/data/src/components/with-dispatch/test/index.js new file mode 100644 index 0000000000000..e39e9d03ae22d --- /dev/null +++ b/packages/data/src/components/with-dispatch/test/index.js @@ -0,0 +1,64 @@ +/** + * External dependencies + */ +import TestRenderer from 'react-test-renderer'; + +/** + * Internal dependencies + */ +import withDispatch from '../'; +import { createRegistry } from '../../../registry'; +import RegistryProvider from '../../registry-provider'; + +describe( 'withDispatch', () => { + let registry; + beforeEach( () => { + registry = createRegistry(); + } ); + + it( 'passes the relevant data to the component', () => { + const store = registry.registerStore( 'counter', { + reducer: ( state = 0, action ) => { + if ( action.type === 'increment' ) { + return state + action.count; + } + return state; + }, + actions: { + increment: ( count = 1 ) => ( { type: 'increment', count } ), + }, + } ); + + const Component = withDispatch( ( _dispatch, ownProps ) => { + const { count } = ownProps; + + return { + increment: () => _dispatch( 'counter' ).increment( count ), + }; + } )( ( props ) => + ) ); + + const Component = compose( [ + withSelect( mapSelectToProps ), + withDispatch( mapDispatchToProps ), + ] )( OriginalComponent ); + + const testRenderer = TestRenderer.create( + + + + ); + const testInstance = testRenderer.root; + + expect( OriginalComponent ).toHaveBeenCalledTimes( 1 ); + expect( mapSelectToProps ).toHaveBeenCalledTimes( 1 ); + expect( mapDispatchToProps ).toHaveBeenCalledTimes( 1 ); + + // Simulate a click on the button + testInstance.findByType( 'button' ).props.onClick(); + + expect( testInstance.findByType( 'button' ).props.children ).toBe( 1 ); + // 3 times = + // 1. Initial mount + // 2. When click handler is called + // 3. After select updates its merge props + expect( mapDispatchToProps ).toHaveBeenCalledTimes( 3 ); + expect( mapSelectToProps ).toHaveBeenCalledTimes( 2 ); + expect( OriginalComponent ).toHaveBeenCalledTimes( 2 ); + } ); + + it( 'should rerun selection on props changes', () => { + registry.registerStore( 'counter', { + reducer: ( state = 0, action ) => { + if ( action.type === 'increment' ) { + return state + 1; + } + + return state; + }, + selectors: { + getCount: ( state, offset ) => state + offset, + }, + } ); + + const mapSelectToProps = jest.fn().mockImplementation( ( _select, ownProps ) => ( { + count: _select( 'counter' ).getCount( ownProps.offset ), + } ) ); + + const OriginalComponent = jest.fn().mockImplementation( ( props ) => ( +
{ props.count }
+ ) ); + + const Component = withSelect( mapSelectToProps )( OriginalComponent ); + + const testRenderer = TestRenderer.create( + + + + ); + const testInstance = testRenderer.root; + + expect( mapSelectToProps ).toHaveBeenCalledTimes( 1 ); + expect( OriginalComponent ).toHaveBeenCalledTimes( 1 ); + + testRenderer.update( + + + + ); + + expect( testInstance.findByType( 'div' ).props.children ).toBe( 10 ); + expect( mapSelectToProps ).toHaveBeenCalledTimes( 2 ); + expect( OriginalComponent ).toHaveBeenCalledTimes( 2 ); + } ); + + it( 'should not run selection if props have not changed', () => { + registry.registerStore( 'unchanging', { + reducer: ( state = {} ) => state, + selectors: { + getState: ( state ) => state, + }, + } ); + + const mapSelectToProps = jest.fn(); + + const OriginalComponent = jest.fn().mockImplementation( () =>
); + + const Component = compose( [ + withSelect( mapSelectToProps ), + ] )( OriginalComponent ); + + const Parent = ( props ) => ; + + const testRenderer = TestRenderer.create( + + + + ); + + expect( mapSelectToProps ).toHaveBeenCalledTimes( 1 ); + expect( OriginalComponent ).toHaveBeenCalledTimes( 1 ); + + testRenderer.update( + + + + ); + + expect( mapSelectToProps ).toHaveBeenCalledTimes( 1 ); + expect( OriginalComponent ).toHaveBeenCalledTimes( 1 ); + } ); + + it( 'should not run selection if state has changed but merge props the same', () => { + registry.registerStore( 'demo', { + reducer: () => ( {} ), + selectors: { + getUnchangingValue: () => 10, + }, + actions: { + update: () => ( { type: 'update' } ), + }, + } ); + + const mapSelectToProps = jest.fn().mockImplementation( ( _select ) => ( { + value: _select( 'demo' ).getUnchangingValue(), + } ) ); + + const OriginalComponent = jest.fn().mockImplementation( () =>
); + + const Component = withSelect( mapSelectToProps )( OriginalComponent ); + + TestRenderer.create( + + + + ); + + expect( mapSelectToProps ).toHaveBeenCalledTimes( 1 ); + expect( OriginalComponent ).toHaveBeenCalledTimes( 1 ); + + registry.dispatch( 'demo' ).update(); + + expect( mapSelectToProps ).toHaveBeenCalledTimes( 2 ); + expect( OriginalComponent ).toHaveBeenCalledTimes( 1 ); + } ); + + it( 'should render if props have changed but not state', () => { + registry.registerStore( 'unchanging', { + reducer: ( state = {} ) => state, + selectors: { + getState: ( state ) => state, + }, + } ); + + const mapSelectToProps = jest.fn(); + + const OriginalComponent = jest.fn().mockImplementation( () =>
); + + const Component = compose( [ + withSelect( mapSelectToProps ), + ] )( OriginalComponent ); + + const testRenderer = TestRenderer.create( + + + + ); + + expect( mapSelectToProps ).toHaveBeenCalledTimes( 1 ); + expect( OriginalComponent ).toHaveBeenCalledTimes( 1 ); + + testRenderer.update( + + + + ); + + expect( mapSelectToProps ).toHaveBeenCalledTimes( 2 ); + expect( OriginalComponent ).toHaveBeenCalledTimes( 2 ); + } ); + + it( 'should not rerun selection on unchanging state', () => { + const store = registry.registerStore( 'unchanging', { + reducer: ( state = {} ) => state, + selectors: { + getState: ( state ) => state, + }, + } ); + + const mapSelectToProps = jest.fn(); + + const OriginalComponent = jest.fn().mockImplementation( () =>
); + + const Component = compose( [ + withSelect( mapSelectToProps ), + ] )( OriginalComponent ); + + TestRenderer.create( + + + + ); + + expect( mapSelectToProps ).toHaveBeenCalledTimes( 1 ); + expect( OriginalComponent ).toHaveBeenCalledTimes( 1 ); + + store.dispatch( { type: 'dummy' } ); + + expect( mapSelectToProps ).toHaveBeenCalledTimes( 1 ); + expect( OriginalComponent ).toHaveBeenCalledTimes( 1 ); + } ); + + it( 'omits props which are not returned on subsequent mappings', () => { + registry.registerStore( 'demo', { + reducer: ( state = 'OK' ) => state, + selectors: { + getValue: ( state ) => state, + }, + } ); + + const mapSelectToProps = jest.fn().mockImplementation( ( _select, ownProps ) => { + return { + [ ownProps.propName ]: _select( 'demo' ).getValue(), + }; + } ); + + const OriginalComponent = jest.fn() + .mockImplementation( ( props ) =>
{ JSON.stringify( props ) }
); + + const Component = withSelect( mapSelectToProps )( OriginalComponent ); + + const testRenderer = TestRenderer.create( + + + + ); + const testInstance = testRenderer.root; + + expect( mapSelectToProps ).toHaveBeenCalledTimes( 1 ); + expect( OriginalComponent ).toHaveBeenCalledTimes( 1 ); + + expect( JSON.parse( testInstance.findByType( 'div' ).props.children ) ) + .toEqual( { foo: 'OK', propName: 'foo' } ); + + testRenderer.update( + + + + ); + + expect( mapSelectToProps ).toHaveBeenCalledTimes( 2 ); + expect( OriginalComponent ).toHaveBeenCalledTimes( 2 ); + expect( JSON.parse( testInstance.findByType( 'div' ).props.children ) ) + .toEqual( { bar: 'OK', propName: 'bar' } ); + } ); + + it( 'allows undefined return from mapSelectToProps', () => { + registry.registerStore( 'demo', { + reducer: ( state = 'OK' ) => state, + selectors: { + getValue: ( state ) => state, + }, + } ); + + const mapSelectToProps = jest.fn().mockImplementation( ( _select, ownProps ) => { + if ( ownProps.pass ) { + return { + count: _select( 'demo' ).getValue(), + }; + } + } ); + + const OriginalComponent = jest.fn().mockImplementation( ( + ( props ) =>
{ props.count || 'Unknown' }
+ ) ); + + const Component = withSelect( mapSelectToProps )( OriginalComponent ); + + const testRenderer = TestRenderer.create( + + + + ); + const testInstance = testRenderer.root; + + expect( mapSelectToProps ).toHaveBeenCalledTimes( 1 ); + expect( OriginalComponent ).toHaveBeenCalledTimes( 1 ); + expect( testInstance.findByType( 'div' ).props.children ).toBe( 'Unknown' ); + + testRenderer.update( + + + + ); + + expect( mapSelectToProps ).toHaveBeenCalledTimes( 2 ); + expect( OriginalComponent ).toHaveBeenCalledTimes( 2 ); + expect( testInstance.findByType( 'div' ).props.children ).toBe( 'OK' ); + + testRenderer.update( + + + + ); + + expect( mapSelectToProps ).toHaveBeenCalledTimes( 3 ); + expect( OriginalComponent ).toHaveBeenCalledTimes( 3 ); + expect( testInstance.findByType( 'div' ).props.children ).toBe( 'Unknown' ); + } ); + + it( 'should run selections on parents before its children', () => { + registry.registerStore( 'childRender', { + reducer: ( state = true, action ) => ( + action.type === 'TOGGLE_RENDER' ? ! state : state + ), + selectors: { + getValue: ( state ) => state, + }, + actions: { + toggleRender: () => ( { type: 'TOGGLE_RENDER' } ), + }, + } ); + + const childMapStateToProps = jest.fn(); + const parentMapStateToProps = jest.fn().mockImplementation( ( _select ) => ( { + isRenderingChild: _select( 'childRender' ).getValue(), + } ) ); + + const ChildOriginalComponent = jest.fn().mockImplementation( () =>
); + const ParentOriginalComponent = jest.fn().mockImplementation( ( props ) => ( +
{ props.isRenderingChild ? : null }
+ ) ); + + const Child = withSelect( childMapStateToProps )( ChildOriginalComponent ); + const Parent = withSelect( parentMapStateToProps )( ParentOriginalComponent ); + + TestRenderer.create( + + + + ); + + expect( childMapStateToProps ).toHaveBeenCalledTimes( 1 ); + expect( parentMapStateToProps ).toHaveBeenCalledTimes( 1 ); + expect( ChildOriginalComponent ).toHaveBeenCalledTimes( 1 ); + expect( ParentOriginalComponent ).toHaveBeenCalledTimes( 1 ); + + registry.dispatch( 'childRender' ).toggleRender(); + + expect( childMapStateToProps ).toHaveBeenCalledTimes( 1 ); + expect( parentMapStateToProps ).toHaveBeenCalledTimes( 2 ); + expect( ChildOriginalComponent ).toHaveBeenCalledTimes( 1 ); + expect( ParentOriginalComponent ).toHaveBeenCalledTimes( 2 ); + } ); +} ); diff --git a/packages/data/src/default-registry.js b/packages/data/src/default-registry.js new file mode 100644 index 0000000000000..f593e59530c31 --- /dev/null +++ b/packages/data/src/default-registry.js @@ -0,0 +1,3 @@ +import { createRegistry } from './registry'; + +export default createRegistry(); diff --git a/packages/data/src/index.js b/packages/data/src/index.js index 3fdbcdbf70274..c556c9bd81a38 100644 --- a/packages/data/src/index.js +++ b/packages/data/src/index.js @@ -1,103 +1,17 @@ /** * External dependencies */ -import { combineReducers, createStore } from 'redux'; -import { flowRight, without, mapValues, overEvery } from 'lodash'; - -/** - * WordPress dependencies - */ -import { - Component, - compose, - createHigherOrderComponent, - pure, -} from '@wordpress/element'; -import isShallowEqual from '@wordpress/is-shallow-equal'; +import { combineReducers } from 'redux'; /** * Internal dependencies */ -import registerDataStore from './store'; - +import defaultRegistry from './default-registry'; export { loadAndPersist, withRehydration } from './persist'; - -/** - * Module constants - */ -const stores = {}; -const selectors = {}; -const actions = {}; -let listeners = []; - -/** - * Global listener called for each store's update. - */ -export function globalListener() { - listeners.forEach( ( listener ) => listener() ); -} - -/** - * Convenience for registering reducer with actions and selectors. - * - * @param {string} reducerKey Reducer key. - * @param {Object} options Store description (reducer, actions, selectors, resolvers). - * - * @return {Object} Registered store object. - */ -export function registerStore( reducerKey, options ) { - if ( ! options.reducer ) { - throw new TypeError( 'Must specify store reducer' ); - } - - const store = registerReducer( reducerKey, options.reducer ); - - if ( options.actions ) { - registerActions( reducerKey, options.actions ); - } - - if ( options.selectors ) { - registerSelectors( reducerKey, options.selectors ); - } - - if ( options.resolvers ) { - registerResolvers( reducerKey, options.resolvers ); - } - - return store; -} - -/** - * Registers a new sub-reducer to the global state and returns a Redux-like store object. - * - * @param {string} reducerKey Reducer key. - * @param {Object} reducer Reducer function. - * - * @return {Object} Store Object. - */ -export function registerReducer( reducerKey, reducer ) { - const enhancers = []; - if ( window.__REDUX_DEVTOOLS_EXTENSION__ ) { - enhancers.push( window.__REDUX_DEVTOOLS_EXTENSION__( { name: reducerKey, instanceId: reducerKey } ) ); - } - const store = createStore( reducer, flowRight( enhancers ) ); - stores[ reducerKey ] = store; - - // Customize subscribe behavior to call listeners only on effective change, - // not on every dispatch. - let lastState = store.getState(); - store.subscribe( () => { - const state = store.getState(); - const hasChanged = state !== lastState; - lastState = state; - - if ( hasChanged ) { - globalListener(); - } - } ); - - return store; -} +export { default as withSelect } from './components/with-select'; +export { default as withDispatch } from './components/with-dispatch'; +export { default as RegistryProvider } from './components/registry-provider'; +export { createRegistry } from './registry'; /** * The combineReducers helper function turns an object whose values are different @@ -111,387 +25,11 @@ export function registerReducer( reducerKey, reducer ) { */ export { combineReducers }; -/** - * Registers selectors for external usage. - * - * @param {string} reducerKey Part of the state shape to register the - * selectors for. - * @param {Object} newSelectors Selectors to register. Keys will be used as the - * public facing API. Selectors will get passed the - * state as first argument. - */ -export function registerSelectors( reducerKey, newSelectors ) { - const store = stores[ reducerKey ]; - const createStateSelector = ( selector ) => ( ...args ) => selector( store.getState(), ...args ); - selectors[ reducerKey ] = mapValues( newSelectors, createStateSelector ); -} - -/** - * Registers resolvers for a given reducer key. Resolvers are side effects - * invoked once per argument set of a given selector call, used in ensuring - * that the data needs for the selector are satisfied. - * - * @param {string} reducerKey Part of the state shape to register the - * resolvers for. - * @param {Object} newResolvers Resolvers to register. - */ -export function registerResolvers( reducerKey, newResolvers ) { - const { hasStartedResolution } = select( 'core/data' ); - const { startResolution, finishResolution } = dispatch( 'core/data' ); - - const createResolver = ( selector, selectorName ) => { - // Don't modify selector behavior if no resolver exists. - if ( ! newResolvers.hasOwnProperty( selectorName ) ) { - return selector; - } - - const store = stores[ reducerKey ]; - - // Normalize resolver shape to object. - let resolver = newResolvers[ selectorName ]; - if ( ! resolver.fulfill ) { - resolver = { fulfill: resolver }; - } - - async function fulfill( ...args ) { - if ( hasStartedResolution( reducerKey, selectorName, args ) ) { - return; - } - - startResolution( reducerKey, selectorName, args ); - - // At this point, selectors have already been pre-bound to inject - // state, it would not be otherwise provided to fulfill. - const state = store.getState(); - - let fulfillment = resolver.fulfill( state, ...args ); - - // Attempt to normalize fulfillment as async iterable. - fulfillment = toAsyncIterable( fulfillment ); - if ( ! isAsyncIterable( fulfillment ) ) { - return; - } - - for await ( const maybeAction of fulfillment ) { - // Dispatch if it quacks like an action. - if ( isActionLike( maybeAction ) ) { - store.dispatch( maybeAction ); - } - } - - finishResolution( reducerKey, selectorName, args ); - } - - if ( typeof resolver.isFulfilled === 'function' ) { - // When resolver provides its own fulfillment condition, fulfill - // should only occur if not already fulfilled (opt-out condition). - fulfill = overEvery( [ - ( ...args ) => { - const state = store.getState(); - return ! resolver.isFulfilled( state, ...args ); - }, - fulfill, - ] ); - } - - return ( ...args ) => { - fulfill( ...args ); - return selector( ...args ); - }; - }; - - selectors[ reducerKey ] = mapValues( selectors[ reducerKey ], createResolver ); -} - -/** - * Registers actions for external usage. - * - * @param {string} reducerKey Part of the state shape to register the - * selectors for. - * @param {Object} newActions Actions to register. - */ -export function registerActions( reducerKey, newActions ) { - const store = stores[ reducerKey ]; - const createBoundAction = ( action ) => ( ...args ) => store.dispatch( action( ...args ) ); - actions[ reducerKey ] = mapValues( newActions, createBoundAction ); -} - -/** - * Subscribe to changes to any data. - * - * @param {Function} listener Listener function. - * - * @return {Function} Unsubscribe function. - */ -export const subscribe = ( listener ) => { - listeners.push( listener ); - - return () => { - listeners = without( listeners, listener ); - }; -}; - -/** - * Calls a selector given the current state and extra arguments. - * - * @param {string} reducerKey Part of the state shape to register the - * selectors for. - * - * @return {*} The selector's returned value. - */ -export function select( reducerKey ) { - return selectors[ reducerKey ]; -} - -/** - * Returns the available actions for a part of the state. - * - * @param {string} reducerKey Part of the state shape to dispatch the - * action for. - * - * @return {*} The action's returned value. - */ -export function dispatch( reducerKey ) { - return actions[ reducerKey ]; -} - -/** - * Higher-order component used to inject state-derived props using registered - * selectors. - * - * @param {Function} mapStateToProps Function called on every state change, - * expected to return object of props to - * merge with the component's own props. - * - * @return {Component} Enhanced component with merged state data props. - */ -export const withSelect = ( mapStateToProps ) => createHigherOrderComponent( ( WrappedComponent ) => { - /** - * Default merge props. A constant value is used as the fallback since it - * can be more efficiently shallow compared in case component is repeatedly - * rendered without its own merge props. - * - * @type {Object} - */ - const DEFAULT_MERGE_PROPS = {}; - - /** - * Given a props object, returns the next merge props by mapStateToProps. - * - * @param {Object} props Props to pass as argument to mapStateToProps. - * - * @return {Object} Props to merge into rendered wrapped element. - */ - function getNextMergeProps( props ) { - return ( - mapStateToProps( select, props ) || - DEFAULT_MERGE_PROPS - ); - } - - return class ComponentWithSelect extends Component { - constructor( props ) { - super( props ); - - this.subscribe(); - - this.mergeProps = getNextMergeProps( props ); - } - - componentDidMount() { - this.canRunSelection = true; - } - - componentWillUnmount() { - this.canRunSelection = false; - this.unsubscribe(); - } - - shouldComponentUpdate( nextProps, nextState ) { - const hasPropsChanged = ! isShallowEqual( this.props, nextProps ); - - // Only render if props have changed or merge props have been updated - // from the store subscriber. - if ( this.state === nextState && ! hasPropsChanged ) { - return false; - } - - // If merge props change as a result of the incoming props, they - // should be reflected as such in the upcoming render. - if ( hasPropsChanged ) { - const nextMergeProps = getNextMergeProps( nextProps ); - if ( ! isShallowEqual( this.mergeProps, nextMergeProps ) ) { - // Side effects are typically discouraged in lifecycle methods, but - // this component is heavily used and this is the most performant - // code we've found thus far. - // Prior efforts to use `getDerivedStateFromProps` have demonstrated - // miserable performance. - this.mergeProps = nextMergeProps; - } - } - - return true; - } - - subscribe() { - this.unsubscribe = subscribe( () => { - if ( ! this.canRunSelection ) { - return; - } - - const nextMergeProps = getNextMergeProps( this.props ); - if ( isShallowEqual( this.mergeProps, nextMergeProps ) ) { - return; - } - - this.mergeProps = nextMergeProps; - - // Schedule an update. Merge props are not assigned to state - // because derivation of merge props from incoming props occurs - // within shouldComponentUpdate, where setState is not allowed. - // setState is used here instead of forceUpdate because forceUpdate - // bypasses shouldComponentUpdate altogether, which isn't desireable - // if both state and props change within the same render. - // Unfortunately this requires that next merge props are generated - // twice. - this.setState( {} ); - } ); - } - - render() { - return ; - } - }; -}, 'withSelect' ); - -/** - * Higher-order component used to add dispatch props using registered action - * creators. - * - * @param {Object} mapDispatchToProps Object of prop names where value is a - * dispatch-bound action creator, or a - * function to be called with with the - * component's props and returning an - * action creator. - * - * @return {Component} Enhanced component with merged dispatcher props. - */ -export const withDispatch = ( mapDispatchToProps ) => createHigherOrderComponent( - compose( [ - pure, - ( WrappedComponent ) => { - return class ComponentWithDispatch extends Component { - constructor( props ) { - super( ...arguments ); - - this.proxyProps = {}; - this.setProxyProps( props ); - } - - componentDidUpdate() { - this.setProxyProps( this.props ); - } - - proxyDispatch( propName, ...args ) { - // Original dispatcher is a pre-bound (dispatching) action creator. - mapDispatchToProps( dispatch, this.props )[ propName ]( ...args ); - } - - setProxyProps( props ) { - // Assign as instance property so that in reconciling subsequent - // renders, the assigned prop values are referentially equal. - const propsToDispatchers = mapDispatchToProps( dispatch, props ); - this.proxyProps = mapValues( propsToDispatchers, ( dispatcher, propName ) => { - // Prebind with prop name so we have reference to the original - // dispatcher to invoke. Track between re-renders to avoid - // creating new function references every render. - if ( this.proxyProps.hasOwnProperty( propName ) ) { - return this.proxyProps[ propName ]; - } - - return this.proxyDispatch.bind( this, propName ); - } ); - } - - render() { - return ; - } - }; - }, - ] ), - 'withDispatch' -); - -/** - * Returns true if the given argument appears to be a dispatchable action. - * - * @param {*} action Object to test. - * - * @return {boolean} Whether object is action-like. - */ -export function isActionLike( action ) { - return ( - !! action && - typeof action.type === 'string' - ); -} - -/** - * Returns true if the given object is an async iterable, or false otherwise. - * - * @param {*} object Object to test. - * - * @return {boolean} Whether object is an async iterable. - */ -export function isAsyncIterable( object ) { - return ( - !! object && - typeof object[ Symbol.asyncIterator ] === 'function' - ); -} - -/** - * Returns true if the given object is iterable, or false otherwise. - * - * @param {*} object Object to test. - * - * @return {boolean} Whether object is iterable. - */ -export function isIterable( object ) { - return ( - !! object && - typeof object[ Symbol.iterator ] === 'function' - ); -} - -/** - * Normalizes the given object argument to an async iterable, asynchronously - * yielding on a singular or array of generator yields or promise resolution. - * - * @param {*} object Object to normalize. - * - * @return {AsyncGenerator} Async iterable actions. - */ -export function toAsyncIterable( object ) { - if ( isAsyncIterable( object ) ) { - return object; - } - - return ( async function* () { - // Normalize as iterable... - if ( ! isIterable( object ) ) { - object = [ object ]; - } - - for ( let maybeAction of object ) { - // ...of Promises. - if ( ! ( maybeAction instanceof Promise ) ) { - maybeAction = Promise.resolve( maybeAction ); - } - - yield await maybeAction; - } - }() ); -} - -registerDataStore(); +export const select = defaultRegistry.select; +export const dispatch = defaultRegistry.dispatch; +export const subscribe = defaultRegistry.subscribe; +export const registerStore = defaultRegistry.registerStore; +export const registerReducer = defaultRegistry.registerReducer; +export const registerActions = defaultRegistry.registerActions; +export const registerSelectors = defaultRegistry.registerSelectors; +export const registerResolvers = defaultRegistry.registerResolvers; diff --git a/packages/data/src/registry.js b/packages/data/src/registry.js new file mode 100644 index 0000000000000..5275329d26ba4 --- /dev/null +++ b/packages/data/src/registry.js @@ -0,0 +1,316 @@ +/** + * External dependencies + */ +import { createStore } from 'redux'; +import { flowRight, without, mapValues, overEvery, get } from 'lodash'; + +/** + * Internal dependencies + */ +import dataStore from './store'; + +/** + * Returns true if the given argument appears to be a dispatchable action. + * + * @param {*} action Object to test. + * + * @return {boolean} Whether object is action-like. + */ +export function isActionLike( action ) { + return ( + !! action && + typeof action.type === 'string' + ); +} + +/** + * Returns true if the given object is an async iterable, or false otherwise. + * + * @param {*} object Object to test. + * + * @return {boolean} Whether object is an async iterable. + */ +export function isAsyncIterable( object ) { + return ( + !! object && + typeof object[ Symbol.asyncIterator ] === 'function' + ); +} + +/** + * Returns true if the given object is iterable, or false otherwise. + * + * @param {*} object Object to test. + * + * @return {boolean} Whether object is iterable. + */ +export function isIterable( object ) { + return ( + !! object && + typeof object[ Symbol.iterator ] === 'function' + ); +} + +/** + * Normalizes the given object argument to an async iterable, asynchronously + * yielding on a singular or array of generator yields or promise resolution. + * + * @param {*} object Object to normalize. + * + * @return {AsyncGenerator} Async iterable actions. + */ +export function toAsyncIterable( object ) { + if ( isAsyncIterable( object ) ) { + return object; + } + + return ( async function* () { + // Normalize as iterable... + if ( ! isIterable( object ) ) { + object = [ object ]; + } + + for ( let maybeAction of object ) { + // ...of Promises. + if ( ! ( maybeAction instanceof Promise ) ) { + maybeAction = Promise.resolve( maybeAction ); + } + + yield await maybeAction; + } + }() ); +} + +export function createRegistry( storeConfigs = {} ) { + const namespaces = {}; + let listeners = []; + + /** + * Global listener called for each store's update. + */ + function globalListener() { + listeners.forEach( ( listener ) => listener() ); + } + + /** + * Registers a new sub-reducer to the global state and returns a Redux-like store object. + * + * @param {string} reducerKey Reducer key. + * @param {Object} reducer Reducer function. + * + * @return {Object} Store Object. + */ + function registerReducer( reducerKey, reducer ) { + const enhancers = []; + if ( window.__REDUX_DEVTOOLS_EXTENSION__ ) { + enhancers.push( window.__REDUX_DEVTOOLS_EXTENSION__( { name: reducerKey, instanceId: reducerKey } ) ); + } + const store = createStore( reducer, flowRight( enhancers ) ); + namespaces[ reducerKey ] = { store }; + + // Customize subscribe behavior to call listeners only on effective change, + // not on every dispatch. + let lastState = store.getState(); + store.subscribe( () => { + const state = store.getState(); + const hasChanged = state !== lastState; + lastState = state; + + if ( hasChanged ) { + globalListener(); + } + } ); + + return store; + } + + /** + * Registers selectors for external usage. + * + * @param {string} reducerKey Part of the state shape to register the + * selectors for. + * @param {Object} newSelectors Selectors to register. Keys will be used as the + * public facing API. Selectors will get passed the + * state as first argument. + */ + function registerSelectors( reducerKey, newSelectors ) { + const store = namespaces[ reducerKey ].store; + const createStateSelector = ( selector ) => ( ...args ) => selector( store.getState(), ...args ); + namespaces[ reducerKey ].selectors = mapValues( newSelectors, createStateSelector ); + } + + /** + * Registers resolvers for a given reducer key. Resolvers are side effects + * invoked once per argument set of a given selector call, used in ensuring + * that the data needs for the selector are satisfied. + * + * @param {string} reducerKey Part of the state shape to register the + * resolvers for. + * @param {Object} newResolvers Resolvers to register. + */ + function registerResolvers( reducerKey, newResolvers ) { + const { hasStartedResolution } = select( 'core/data' ); + const { startResolution, finishResolution } = dispatch( 'core/data' ); + + const createResolver = ( selector, selectorName ) => { + // Don't modify selector behavior if no resolver exists. + if ( ! newResolvers.hasOwnProperty( selectorName ) ) { + return selector; + } + + const store = namespaces[ reducerKey ].store; + + // Normalize resolver shape to object. + let resolver = newResolvers[ selectorName ]; + if ( ! resolver.fulfill ) { + resolver = { fulfill: resolver }; + } + + async function fulfill( ...args ) { + if ( hasStartedResolution( reducerKey, selectorName, args ) ) { + return; + } + + startResolution( reducerKey, selectorName, args ); + + // At this point, selectors have already been pre-bound to inject + // state, it would not be otherwise provided to fulfill. + const state = store.getState(); + + let fulfillment = resolver.fulfill( state, ...args ); + + // Attempt to normalize fulfillment as async iterable. + fulfillment = toAsyncIterable( fulfillment ); + if ( ! isAsyncIterable( fulfillment ) ) { + return; + } + + for await ( const maybeAction of fulfillment ) { + // Dispatch if it quacks like an action. + if ( isActionLike( maybeAction ) ) { + store.dispatch( maybeAction ); + } + } + + finishResolution( reducerKey, selectorName, args ); + } + + if ( typeof resolver.isFulfilled === 'function' ) { + // When resolver provides its own fulfillment condition, fulfill + // should only occur if not already fulfilled (opt-out condition). + fulfill = overEvery( [ + ( ...args ) => { + const state = store.getState(); + return ! resolver.isFulfilled( state, ...args ); + }, + fulfill, + ] ); + } + + return ( ...args ) => { + fulfill( ...args ); + return selector( ...args ); + }; + }; + + namespaces[ reducerKey ].selectors = mapValues( namespaces[ reducerKey ].selectors, createResolver ); + } + + /** + * Registers actions for external usage. + * + * @param {string} reducerKey Part of the state shape to register the + * selectors for. + * @param {Object} newActions Actions to register. + */ + function registerActions( reducerKey, newActions ) { + const store = namespaces[ reducerKey ].store; + const createBoundAction = ( action ) => ( ...args ) => store.dispatch( action( ...args ) ); + namespaces[ reducerKey ].actions = mapValues( newActions, createBoundAction ); + } + + /** + * Convenience for registering reducer with actions and selectors. + * + * @param {string} reducerKey Reducer key. + * @param {Object} options Store description (reducer, actions, selectors, resolvers). + * + * @return {Object} Registered store object. + */ + function registerStore( reducerKey, options ) { + if ( ! options.reducer ) { + throw new TypeError( 'Must specify store reducer' ); + } + + const store = registerReducer( reducerKey, options.reducer ); + + if ( options.actions ) { + registerActions( reducerKey, options.actions ); + } + + if ( options.selectors ) { + registerSelectors( reducerKey, options.selectors ); + } + + if ( options.resolvers ) { + registerResolvers( reducerKey, options.resolvers ); + } + + return store; + } + + /** + * Subscribe to changes to any data. + * + * @param {Function} listener Listener function. + * + * @return {Function} Unsubscribe function. + */ + const subscribe = ( listener ) => { + listeners.push( listener ); + + return () => { + listeners = without( listeners, listener ); + }; + }; + + /** + * Calls a selector given the current state and extra arguments. + * + * @param {string} reducerKey Part of the state shape to register the + * selectors for. + * + * @return {*} The selector's returned value. + */ + function select( reducerKey ) { + return get( namespaces, [ reducerKey, 'selectors' ] ); + } + + /** + * Returns the available actions for a part of the state. + * + * @param {string} reducerKey Part of the state shape to dispatch the + * action for. + * + * @return {*} The action's returned value. + */ + function dispatch( reducerKey ) { + return get( namespaces, [ reducerKey, 'actions' ] ); + } + + Object.entries( { + 'core/data': dataStore, + ...storeConfigs, + } ).map( ( [ name, config ] ) => registerStore( name, config ) ); + + return { + registerReducer, + registerSelectors, + registerResolvers, + registerActions, + registerStore, + subscribe, + select, + dispatch, + }; +} diff --git a/packages/data/src/store/index.js b/packages/data/src/store/index.js index 417babf5a51a8..67ff3b6722085 100644 --- a/packages/data/src/store/index.js +++ b/packages/data/src/store/index.js @@ -1,16 +1,12 @@ /** * Internal dependencies */ -import { registerStore } from '../'; - import reducer from './reducer'; import * as selectors from './selectors'; import * as actions from './actions'; -export default function registerDataStore() { - registerStore( 'core/data', { - reducer, - actions, - selectors, - } ); -} +export default { + reducer, + actions, + selectors, +}; diff --git a/packages/data/src/test/index.js b/packages/data/src/test/index.js deleted file mode 100644 index 53282b3aea1f5..0000000000000 --- a/packages/data/src/test/index.js +++ /dev/null @@ -1,1008 +0,0 @@ -/** - * External dependencies - */ -import { mount } from 'enzyme'; -import { castArray } from 'lodash'; - -/** - * WordPress dependencies - */ -import { compose } from '@wordpress/element'; - -/** - * Internal dependencies - */ -import { - registerStore, - registerReducer, - registerSelectors, - registerResolvers, - registerActions, - dispatch, - select, - withSelect, - withDispatch, - subscribe, - isActionLike, - isAsyncIterable, - isIterable, - toAsyncIterable, -} from '../'; - -// Mock data store to prevent self-initialization, as it needs to be reset -// between tests of `registerResolvers` by replacement (new `registerStore`). -jest.mock( '../store', () => () => {} ); -const registerDataStore = require.requireActual( '../store' ).default; - -describe( 'registerStore', () => { - it( 'should be shorthand for reducer, actions, selectors registration', () => { - const store = registerStore( 'butcher', { - reducer( state = { ribs: 6, chicken: 4 }, action ) { - switch ( action.type ) { - case 'sale': - return { - ...state, - [ action.meat ]: state[ action.meat ] / 2, - }; - } - - return state; - }, - selectors: { - getPrice: ( state, meat ) => state[ meat ], - }, - actions: { - startSale: ( meat ) => ( { type: 'sale', meat } ), - }, - } ); - - expect( store.getState() ).toEqual( { ribs: 6, chicken: 4 } ); - expect( dispatch( 'butcher' ) ).toHaveProperty( 'startSale' ); - expect( select( 'butcher' ) ).toHaveProperty( 'getPrice' ); - expect( select( 'butcher' ).getPrice( 'chicken' ) ).toBe( 4 ); - expect( select( 'butcher' ).getPrice( 'ribs' ) ).toBe( 6 ); - dispatch( 'butcher' ).startSale( 'chicken' ); - expect( select( 'butcher' ).getPrice( 'chicken' ) ).toBe( 2 ); - expect( select( 'butcher' ).getPrice( 'ribs' ) ).toBe( 6 ); - } ); -} ); - -describe( 'registerReducer', () => { - it( 'Should append reducers to the state', () => { - const reducer1 = () => 'chicken'; - const reducer2 = () => 'ribs'; - - const store = registerReducer( 'red1', reducer1 ); - expect( store.getState() ).toEqual( 'chicken' ); - - const store2 = registerReducer( 'red2', reducer2 ); - expect( store2.getState() ).toEqual( 'ribs' ); - } ); -} ); - -describe( 'registerResolvers', () => { - beforeEach( () => { - registerDataStore(); - } ); - - const unsubscribes = []; - afterEach( () => { - let unsubscribe; - while ( ( unsubscribe = unsubscribes.shift() ) ) { - unsubscribe(); - } - } ); - - function subscribeWithUnsubscribe( ...args ) { - const unsubscribe = subscribe( ...args ); - unsubscribes.push( unsubscribe ); - return unsubscribe; - } - - function subscribeUntil( predicates ) { - predicates = castArray( predicates ); - - return new Promise( ( resolve ) => { - subscribeWithUnsubscribe( () => { - if ( predicates.every( ( predicate ) => predicate() ) ) { - resolve(); - } - } ); - } ); - } - - it( 'should not do anything for selectors which do not have resolvers', () => { - registerReducer( 'demo', ( state = 'OK' ) => state ); - registerSelectors( 'demo', { - getValue: ( state ) => state, - } ); - registerResolvers( 'demo', {} ); - - expect( select( 'demo' ).getValue() ).toBe( 'OK' ); - } ); - - it( 'should behave as a side effect for the given selector, with arguments', () => { - const resolver = jest.fn(); - - registerReducer( 'demo', ( state = 'OK' ) => state ); - registerSelectors( 'demo', { - getValue: ( state ) => state, - } ); - registerResolvers( 'demo', { - getValue: resolver, - } ); - - const value = select( 'demo' ).getValue( 'arg1', 'arg2' ); - expect( value ).toBe( 'OK' ); - expect( resolver ).toHaveBeenCalledWith( 'OK', 'arg1', 'arg2' ); - select( 'demo' ).getValue( 'arg1', 'arg2' ); - expect( resolver ).toHaveBeenCalledTimes( 1 ); - select( 'demo' ).getValue( 'arg3', 'arg4' ); - expect( resolver ).toHaveBeenCalledTimes( 2 ); - } ); - - it( 'should support the object resolver definition', () => { - const resolver = jest.fn(); - - registerReducer( 'demo', ( state = 'OK' ) => state ); - registerSelectors( 'demo', { - getValue: ( state ) => state, - } ); - registerResolvers( 'demo', { - getValue: { fulfill: resolver }, - } ); - - const value = select( 'demo' ).getValue( 'arg1', 'arg2' ); - expect( value ).toBe( 'OK' ); - } ); - - it( 'should use isFulfilled definition before calling the side effect', () => { - const fulfill = jest.fn().mockImplementation( ( state, page ) => { - return { type: 'SET_PAGE', page, result: [] }; - } ); - - const store = registerReducer( 'demo', ( state = {}, action ) => { - switch ( action.type ) { - case 'SET_PAGE': - return { - ...state, - [ action.page ]: action.result, - }; - } - - return state; - } ); - - store.dispatch( { type: 'SET_PAGE', page: 4, result: [] } ); - - registerSelectors( 'demo', { - getPage: ( state, page ) => state[ page ], - } ); - registerResolvers( 'demo', { - getPage: { - fulfill, - isFulfilled( state, page ) { - return state.hasOwnProperty( page ); - }, - }, - } ); - - select( 'demo' ).getPage( 1 ); - select( 'demo' ).getPage( 2 ); - - expect( fulfill ).toHaveBeenCalledTimes( 2 ); - - select( 'demo' ).getPage( 1 ); - select( 'demo' ).getPage( 2 ); - select( 'demo' ).getPage( 3, {} ); - - // Expected: First and second page fulfillments already triggered, so - // should only be one more than previous assertion set. - expect( fulfill ).toHaveBeenCalledTimes( 3 ); - - select( 'demo' ).getPage( 1 ); - select( 'demo' ).getPage( 2 ); - select( 'demo' ).getPage( 3, {} ); - select( 'demo' ).getPage( 4 ); - - // Expected: - // - Fourth page was pre-filled. Necessary to determine via - // isFulfilled, but fulfillment resolver should not be triggered. - // - Third page arguments are not strictly equal but are equivalent, - // so fulfillment should already be satisfied. - expect( fulfill ).toHaveBeenCalledTimes( 3 ); - - select( 'demo' ).getPage( 4, {} ); - } ); - - it( 'should resolve action to dispatch', () => { - registerReducer( 'demo', ( state = 'NOTOK', action ) => { - return action.type === 'SET_OK' ? 'OK' : state; - } ); - registerSelectors( 'demo', { - getValue: ( state ) => state, - } ); - registerResolvers( 'demo', { - getValue: () => ( { type: 'SET_OK' } ), - } ); - - const promise = subscribeUntil( [ - () => select( 'demo' ).getValue() === 'OK', - () => select( 'core/data' ).hasFinishedResolution( 'demo', 'getValue' ), - ] ); - - select( 'demo' ).getValue(); - - return promise; - } ); - - it( 'should resolve mixed type action array to dispatch', () => { - registerReducer( 'counter', ( state = 0, action ) => { - return action.type === 'INCREMENT' ? state + 1 : state; - } ); - registerSelectors( 'counter', { - getCount: ( state ) => state, - } ); - registerResolvers( 'counter', { - getCount: () => [ - { type: 'INCREMENT' }, - Promise.resolve( { type: 'INCREMENT' } ), - ], - } ); - - const promise = subscribeUntil( [ - () => select( 'counter' ).getCount() === 2, - () => select( 'core/data' ).hasFinishedResolution( 'counter', 'getCount' ), - ] ); - - select( 'counter' ).getCount(); - - return promise; - } ); - - it( 'should resolve generator action to dispatch', () => { - registerReducer( 'demo', ( state = 'NOTOK', action ) => { - return action.type === 'SET_OK' ? 'OK' : state; - } ); - registerSelectors( 'demo', { - getValue: ( state ) => state, - } ); - registerResolvers( 'demo', { - * getValue() { - yield { type: 'SET_OK' }; - }, - } ); - - const promise = subscribeUntil( [ - () => select( 'demo' ).getValue() === 'OK', - () => select( 'core/data' ).hasFinishedResolution( 'demo', 'getValue' ), - ] ); - - select( 'demo' ).getValue(); - - return promise; - } ); - - it( 'should resolve promise action to dispatch', () => { - registerReducer( 'demo', ( state = 'NOTOK', action ) => { - return action.type === 'SET_OK' ? 'OK' : state; - } ); - registerSelectors( 'demo', { - getValue: ( state ) => state, - } ); - registerResolvers( 'demo', { - getValue: () => Promise.resolve( { type: 'SET_OK' } ), - } ); - - const promise = subscribeUntil( [ - () => select( 'demo' ).getValue() === 'OK', - () => select( 'core/data' ).hasFinishedResolution( 'demo', 'getValue' ), - ] ); - - select( 'demo' ).getValue(); - - return promise; - } ); - - it( 'should resolve promise non-action to dispatch', ( done ) => { - let shouldThrow = false; - registerReducer( 'demo', ( state = 'OK' ) => { - if ( shouldThrow ) { - throw 'Should not have dispatched'; - } - - return state; - } ); - shouldThrow = true; - registerSelectors( 'demo', { - getValue: ( state ) => state, - } ); - registerResolvers( 'demo', { - getValue: () => Promise.resolve(), - } ); - - select( 'demo' ).getValue(); - - process.nextTick( () => { - done(); - } ); - } ); - - it( 'should resolve async iterator action to dispatch', () => { - registerReducer( 'counter', ( state = 0, action ) => { - return action.type === 'INCREMENT' ? state + 1 : state; - } ); - registerSelectors( 'counter', { - getCount: ( state ) => state, - } ); - registerResolvers( 'counter', { - getCount: async function* () { - yield { type: 'INCREMENT' }; - yield await Promise.resolve( { type: 'INCREMENT' } ); - }, - } ); - - const promise = subscribeUntil( [ - () => select( 'counter' ).getCount() === 2, - () => select( 'core/data' ).hasFinishedResolution( 'counter', 'getCount' ), - ] ); - - select( 'counter' ).getCount(); - - return promise; - } ); - - it( 'should not dispatch resolved promise action on subsequent selector calls', () => { - registerReducer( 'demo', ( state = 'NOTOK', action ) => { - return action.type === 'SET_OK' && state === 'NOTOK' ? 'OK' : 'NOTOK'; - } ); - registerSelectors( 'demo', { - getValue: ( state ) => state, - } ); - registerResolvers( 'demo', { - getValue: () => Promise.resolve( { type: 'SET_OK' } ), - } ); - - const promise = subscribeUntil( () => select( 'demo' ).getValue() === 'OK' ); - - select( 'demo' ).getValue(); - select( 'demo' ).getValue(); - - return promise; - } ); -} ); - -describe( 'select', () => { - it( 'registers multiple selectors to the public API', () => { - const store = registerReducer( 'reducer1', () => 'state1' ); - const selector1 = jest.fn( () => 'result1' ); - const selector2 = jest.fn( () => 'result2' ); - - registerSelectors( 'reducer1', { - selector1, - selector2, - } ); - - expect( select( 'reducer1' ).selector1() ).toEqual( 'result1' ); - expect( selector1 ).toBeCalledWith( store.getState() ); - - expect( select( 'reducer1' ).selector2() ).toEqual( 'result2' ); - expect( selector2 ).toBeCalledWith( store.getState() ); - } ); -} ); - -describe( 'withSelect', () => { - let wrapper, store; - - afterEach( () => { - if ( wrapper ) { - wrapper.unmount(); - wrapper = null; - } - } ); - - it( 'passes the relevant data to the component', () => { - registerReducer( 'reactReducer', () => ( { reactKey: 'reactState' } ) ); - registerSelectors( 'reactReducer', { - reactSelector: ( state, key ) => state[ key ], - } ); - - // In normal circumstances, the fact that we have to add an arbitrary - // prefix to the variable name would be concerning, and perhaps an - // argument that we ought to expect developer to use select from the - // wp.data export. But in-fact, this serves as a good deterrent for - // including both `withSelect` and `select` in the same scope, which - // shouldn't occur for a typical component, and if it did might wrongly - // encourage the developer to use `select` within the component itself. - const mapSelectToProps = jest.fn().mockImplementation( ( _select, ownProps ) => ( { - data: _select( 'reactReducer' ).reactSelector( ownProps.keyName ), - } ) ); - - const OriginalComponent = jest.fn().mockImplementation( ( props ) => ( -
{ props.data }
- ) ); - - const Component = withSelect( mapSelectToProps )( OriginalComponent ); - - wrapper = mount( ); - - expect( mapSelectToProps ).toHaveBeenCalledTimes( 1 ); - expect( OriginalComponent ).toHaveBeenCalledTimes( 1 ); - - // Wrapper is the enhanced component. Find props on the rendered child. - const child = wrapper.childAt( 0 ); - expect( child.props() ).toEqual( { - keyName: 'reactKey', - data: 'reactState', - } ); - expect( wrapper.text() ).toBe( 'reactState' ); - } ); - - it( 'should rerun selection on state changes', () => { - registerReducer( 'counter', ( state = 0, action ) => { - if ( action.type === 'increment' ) { - return state + 1; - } - - return state; - } ); - - registerSelectors( 'counter', { - getCount: ( state ) => state, - } ); - - registerActions( 'counter', { - increment: () => ( { type: 'increment' } ), - } ); - - const mapSelectToProps = jest.fn().mockImplementation( ( _select ) => ( { - count: _select( 'counter' ).getCount(), - } ) ); - - const mapDispatchToProps = jest.fn().mockImplementation( ( _dispatch ) => ( { - increment: _dispatch( 'counter' ).increment, - } ) ); - - const OriginalComponent = jest.fn().mockImplementation( ( props ) => ( - - ) ); - - const Component = compose( [ - withSelect( mapSelectToProps ), - withDispatch( mapDispatchToProps ), - ] )( OriginalComponent ); - - wrapper = mount( ); - - expect( OriginalComponent ).toHaveBeenCalledTimes( 1 ); - expect( mapSelectToProps ).toHaveBeenCalledTimes( 1 ); - expect( mapDispatchToProps ).toHaveBeenCalledTimes( 1 ); - - const button = wrapper.find( 'button' ); - - button.simulate( 'click' ); - - expect( button.text() ).toBe( '1' ); - // 3 times = - // 1. Initial mount - // 2. When click handler is called - // 3. After select updates its merge props - expect( mapDispatchToProps ).toHaveBeenCalledTimes( 3 ); - expect( mapSelectToProps ).toHaveBeenCalledTimes( 2 ); - expect( OriginalComponent ).toHaveBeenCalledTimes( 2 ); - } ); - - it( 'should rerun selection on props changes', () => { - registerReducer( 'counter', ( state = 0, action ) => { - if ( action.type === 'increment' ) { - return state + 1; - } - - return state; - } ); - - registerSelectors( 'counter', { - getCount: ( state, offset ) => state + offset, - } ); - - const mapSelectToProps = jest.fn().mockImplementation( ( _select, ownProps ) => ( { - count: _select( 'counter' ).getCount( ownProps.offset ), - } ) ); - - const OriginalComponent = jest.fn().mockImplementation( ( props ) => ( -
{ props.count }
- ) ); - - const Component = withSelect( mapSelectToProps )( OriginalComponent ); - - wrapper = mount( ); - - expect( mapSelectToProps ).toHaveBeenCalledTimes( 1 ); - expect( OriginalComponent ).toHaveBeenCalledTimes( 1 ); - - wrapper.setProps( { offset: 10 } ); - - expect( wrapper.childAt( 0 ).text() ).toBe( '10' ); - expect( mapSelectToProps ).toHaveBeenCalledTimes( 2 ); - expect( OriginalComponent ).toHaveBeenCalledTimes( 2 ); - } ); - - it( 'should not run selection if props have not changed', () => { - store = registerReducer( 'unchanging', ( state = {} ) => state ); - - registerSelectors( 'unchanging', { - getState: ( state ) => state, - } ); - - const mapSelectToProps = jest.fn(); - - const OriginalComponent = jest.fn().mockImplementation( () =>
); - - const Component = compose( [ - withSelect( mapSelectToProps ), - ] )( OriginalComponent ); - - const Parent = ( props ) => ; - - wrapper = mount( ); - - expect( mapSelectToProps ).toHaveBeenCalledTimes( 1 ); - expect( OriginalComponent ).toHaveBeenCalledTimes( 1 ); - - wrapper.setProps( { propName: 'foo' } ); - - expect( mapSelectToProps ).toHaveBeenCalledTimes( 1 ); - expect( OriginalComponent ).toHaveBeenCalledTimes( 1 ); - } ); - - it( 'should not run selection if state has changed but merge props the same', () => { - store = registerReducer( 'demo', () => ( {} ) ); - - registerSelectors( 'demo', { - getUnchangingValue: () => 10, - } ); - - registerActions( 'demo', { - update: () => ( { type: 'update' } ), - } ); - - const mapSelectToProps = jest.fn().mockImplementation( ( _select ) => ( { - value: _select( 'demo' ).getUnchangingValue(), - } ) ); - - const OriginalComponent = jest.fn().mockImplementation( () =>
); - - const Component = withSelect( mapSelectToProps )( OriginalComponent ); - - wrapper = mount( ); - - expect( mapSelectToProps ).toHaveBeenCalledTimes( 1 ); - expect( OriginalComponent ).toHaveBeenCalledTimes( 1 ); - - dispatch( 'demo' ).update(); - - expect( mapSelectToProps ).toHaveBeenCalledTimes( 2 ); - expect( OriginalComponent ).toHaveBeenCalledTimes( 1 ); - } ); - - it( 'should render if props have changed but not state', () => { - store = registerReducer( 'unchanging', ( state = {} ) => state ); - - registerSelectors( 'unchanging', { - getState: ( state ) => state, - } ); - - const mapSelectToProps = jest.fn(); - - const OriginalComponent = jest.fn().mockImplementation( () =>
); - - const Component = compose( [ - withSelect( mapSelectToProps ), - ] )( OriginalComponent ); - - wrapper = mount( ); - - expect( mapSelectToProps ).toHaveBeenCalledTimes( 1 ); - expect( OriginalComponent ).toHaveBeenCalledTimes( 1 ); - - wrapper.setProps( { propName: 'foo' } ); - - expect( mapSelectToProps ).toHaveBeenCalledTimes( 2 ); - expect( OriginalComponent ).toHaveBeenCalledTimes( 2 ); - } ); - - it( 'should not rerun selection on unchanging state', () => { - store = registerReducer( 'unchanging', ( state = {} ) => state ); - - registerSelectors( 'unchanging', { - getState: ( state ) => state, - } ); - - const mapSelectToProps = jest.fn(); - - const OriginalComponent = jest.fn().mockImplementation( () =>
); - - const Component = compose( [ - withSelect( mapSelectToProps ), - ] )( OriginalComponent ); - - wrapper = mount( ); - - expect( mapSelectToProps ).toHaveBeenCalledTimes( 1 ); - expect( OriginalComponent ).toHaveBeenCalledTimes( 1 ); - - store.dispatch( { type: 'dummy' } ); - - expect( mapSelectToProps ).toHaveBeenCalledTimes( 1 ); - expect( OriginalComponent ).toHaveBeenCalledTimes( 1 ); - } ); - - it( 'omits props which are not returned on subsequent mappings', () => { - registerReducer( 'demo', ( state = 'OK' ) => state ); - registerSelectors( 'demo', { - getValue: ( state ) => state, - } ); - - const mapSelectToProps = jest.fn().mockImplementation( ( _select, ownProps ) => { - return { - [ ownProps.propName ]: _select( 'demo' ).getValue(), - }; - } ); - - const OriginalComponent = jest.fn().mockImplementation( () =>
); - - const Component = withSelect( mapSelectToProps )( OriginalComponent ); - - wrapper = mount( ); - - expect( mapSelectToProps ).toHaveBeenCalledTimes( 1 ); - expect( OriginalComponent ).toHaveBeenCalledTimes( 1 ); - expect( wrapper.childAt( 0 ).props() ).toEqual( { foo: 'OK', propName: 'foo' } ); - - wrapper.setProps( { propName: 'bar' } ); - - expect( mapSelectToProps ).toHaveBeenCalledTimes( 2 ); - expect( OriginalComponent ).toHaveBeenCalledTimes( 2 ); - expect( wrapper.childAt( 0 ).props() ).toEqual( { bar: 'OK', propName: 'bar' } ); - } ); - - it( 'allows undefined return from mapSelectToProps', () => { - registerReducer( 'demo', ( state = 'OK' ) => state ); - registerSelectors( 'demo', { - getValue: ( state ) => state, - } ); - - const mapSelectToProps = jest.fn().mockImplementation( ( _select, ownProps ) => { - if ( ownProps.pass ) { - return { - count: _select( 'demo' ).getValue(), - }; - } - } ); - - const OriginalComponent = jest.fn().mockImplementation( ( - ( props ) =>
{ props.count || 'Unknown' }
- ) ); - - const Component = withSelect( mapSelectToProps )( OriginalComponent ); - - wrapper = mount( ); - - expect( mapSelectToProps ).toHaveBeenCalledTimes( 1 ); - expect( OriginalComponent ).toHaveBeenCalledTimes( 1 ); - expect( wrapper.childAt( 0 ).text() ).toBe( 'Unknown' ); - - wrapper.setProps( { pass: true } ); - - expect( mapSelectToProps ).toHaveBeenCalledTimes( 2 ); - expect( OriginalComponent ).toHaveBeenCalledTimes( 2 ); - expect( wrapper.childAt( 0 ).text() ).toBe( 'OK' ); - - wrapper.setProps( { pass: false } ); - - expect( mapSelectToProps ).toHaveBeenCalledTimes( 3 ); - expect( OriginalComponent ).toHaveBeenCalledTimes( 3 ); - expect( wrapper.childAt( 0 ).text() ).toBe( 'Unknown' ); - } ); - - it( 'should run selections on parents before its children', () => { - registerReducer( 'childRender', ( state = true, action ) => ( - action.type === 'TOGGLE_RENDER' ? ! state : state - ) ); - registerSelectors( 'childRender', { - getValue: ( state ) => state, - } ); - registerActions( 'childRender', { - toggleRender: () => ( { type: 'TOGGLE_RENDER' } ), - } ); - - const childMapStateToProps = jest.fn(); - const parentMapStateToProps = jest.fn().mockImplementation( ( _select ) => ( { - isRenderingChild: _select( 'childRender' ).getValue(), - } ) ); - - const ChildOriginalComponent = jest.fn().mockImplementation( () =>
); - const ParentOriginalComponent = jest.fn().mockImplementation( ( props ) => ( -
{ props.isRenderingChild ? : null }
- ) ); - - const Child = withSelect( childMapStateToProps )( ChildOriginalComponent ); - const Parent = withSelect( parentMapStateToProps )( ParentOriginalComponent ); - - wrapper = mount( ); - - expect( childMapStateToProps ).toHaveBeenCalledTimes( 1 ); - expect( parentMapStateToProps ).toHaveBeenCalledTimes( 1 ); - expect( ChildOriginalComponent ).toHaveBeenCalledTimes( 1 ); - expect( ParentOriginalComponent ).toHaveBeenCalledTimes( 1 ); - - dispatch( 'childRender' ).toggleRender(); - - expect( childMapStateToProps ).toHaveBeenCalledTimes( 1 ); - expect( parentMapStateToProps ).toHaveBeenCalledTimes( 2 ); - expect( ChildOriginalComponent ).toHaveBeenCalledTimes( 1 ); - expect( ParentOriginalComponent ).toHaveBeenCalledTimes( 2 ); - } ); -} ); - -describe( 'withDispatch', () => { - let wrapper; - afterEach( () => { - if ( wrapper ) { - wrapper.unmount(); - wrapper = null; - } - } ); - - it( 'passes the relevant data to the component', () => { - const store = registerReducer( 'counter', ( state = 0, action ) => { - if ( action.type === 'increment' ) { - return state + action.count; - } - return state; - } ); - - const increment = ( count = 1 ) => ( { type: 'increment', count } ); - registerActions( 'counter', { - increment, - } ); - - const Component = withDispatch( ( _dispatch, ownProps ) => { - const { count } = ownProps; - - return { - increment: () => _dispatch( 'counter' ).increment( count ), - }; - } )( ( props ) =>