From 6ef5716437ad9eea9c04f338db0187e084c463c3 Mon Sep 17 00:00:00 2001 From: Andrew Duthie Date: Fri, 22 Jun 2018 09:12:02 -0400 Subject: [PATCH 1/8] Data: Move registry into own file --- packages/data/src/index.js | 480 +----------------- packages/data/src/registry.js | 460 +++++++++++++++++ .../data/src/test/{index.js => registry.js} | 0 3 files changed, 480 insertions(+), 460 deletions(-) create mode 100644 packages/data/src/registry.js rename packages/data/src/test/{index.js => registry.js} (100%) diff --git a/packages/data/src/index.js b/packages/data/src/index.js index 14247b8bb601c..b695595196779 100644 --- a/packages/data/src/index.js +++ b/packages/data/src/index.js @@ -1,460 +1,20 @@ -/** - * External dependencies - */ -import { combineReducers, createStore } from 'redux'; -import { flowRight, without, mapValues, overEvery } from 'lodash'; - -/** - * WordPress dependencies - */ -import { - Component, - compose, - createElement, - createHigherOrderComponent, - pure, -} from '@wordpress/element'; -import isShallowEqual from '@wordpress/is-shallow-equal'; - -/** - * Internal dependencies - */ -import registerDataStore from './store'; - -export { loadAndPersist, withRehydration, withRehydratation } 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; -} - -/** - * The combineReducers helper function turns an object whose values are different - * reducing functions into a single reducing function you can pass to registerReducer. - * - * @param {Object} reducers An object whose values correspond to different reducing - * functions that need to be combined into one. - * - * @return {Function} A reducer that invokes every reducer inside the reducers - * object, and constructs a state object with the same shape. - */ -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 ) => { - const DEFAULT_MERGE_PROPS = {}; - - return class ComponentWithSelect extends Component { - constructor() { - super( ...arguments ); - - this.subscribe(); - - this.state = {}; - } - - static getDerivedStateFromProps( 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. - const mergeProps = ( - mapStateToProps( select, props ) || - DEFAULT_MERGE_PROPS - ); - - return { mergeProps }; - } - - componentDidMount() { - this.canRunSelection = true; - } - - componentWillUnmount() { - this.canRunSelection = false; - this.unsubscribe(); - } - - shouldComponentUpdate( nextProps, nextState ) { - return ( - ! isShallowEqual( this.props, nextProps ) || - ! isShallowEqual( this.state.mergeProps, nextState.mergeProps ) - ); - } - - subscribe() { - this.unsubscribe = subscribe( () => { - if ( ! this.canRunSelection ) { - return; - } - - // Trigger an update. Behavior of `getDerivedStateFromProps` as - // of React 16.4.0 is such that it will be called by any update - // to the component, including state changes. - // - // See: https://reactjs.org/blog/2018/05/23/react-v-16-4.html#bugfix-for-getderivedstatefromprops - 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 { + loadAndPersist, + withRehydration, + globalListener, + registerStore, + registerReducer, + combineReducers, + registerSelectors, + registerResolvers, + registerActions, + subscribe, + select, + dispatch, + withSelect, + withDispatch, + isActionLike, + isAsyncIterable, + isIterable, + toAsyncIterable, +} from './registry'; diff --git a/packages/data/src/registry.js b/packages/data/src/registry.js new file mode 100644 index 0000000000000..14247b8bb601c --- /dev/null +++ b/packages/data/src/registry.js @@ -0,0 +1,460 @@ +/** + * External dependencies + */ +import { combineReducers, createStore } from 'redux'; +import { flowRight, without, mapValues, overEvery } from 'lodash'; + +/** + * WordPress dependencies + */ +import { + Component, + compose, + createElement, + createHigherOrderComponent, + pure, +} from '@wordpress/element'; +import isShallowEqual from '@wordpress/is-shallow-equal'; + +/** + * Internal dependencies + */ +import registerDataStore from './store'; + +export { loadAndPersist, withRehydration, withRehydratation } 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; +} + +/** + * The combineReducers helper function turns an object whose values are different + * reducing functions into a single reducing function you can pass to registerReducer. + * + * @param {Object} reducers An object whose values correspond to different reducing + * functions that need to be combined into one. + * + * @return {Function} A reducer that invokes every reducer inside the reducers + * object, and constructs a state object with the same shape. + */ +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 ) => { + const DEFAULT_MERGE_PROPS = {}; + + return class ComponentWithSelect extends Component { + constructor() { + super( ...arguments ); + + this.subscribe(); + + this.state = {}; + } + + static getDerivedStateFromProps( 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. + const mergeProps = ( + mapStateToProps( select, props ) || + DEFAULT_MERGE_PROPS + ); + + return { mergeProps }; + } + + componentDidMount() { + this.canRunSelection = true; + } + + componentWillUnmount() { + this.canRunSelection = false; + this.unsubscribe(); + } + + shouldComponentUpdate( nextProps, nextState ) { + return ( + ! isShallowEqual( this.props, nextProps ) || + ! isShallowEqual( this.state.mergeProps, nextState.mergeProps ) + ); + } + + subscribe() { + this.unsubscribe = subscribe( () => { + if ( ! this.canRunSelection ) { + return; + } + + // Trigger an update. Behavior of `getDerivedStateFromProps` as + // of React 16.4.0 is such that it will be called by any update + // to the component, including state changes. + // + // See: https://reactjs.org/blog/2018/05/23/react-v-16-4.html#bugfix-for-getderivedstatefromprops + 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(); diff --git a/packages/data/src/test/index.js b/packages/data/src/test/registry.js similarity index 100% rename from packages/data/src/test/index.js rename to packages/data/src/test/registry.js From 7cf782ec00853c68b7f363d06a153b0d61b1be02 Mon Sep 17 00:00:00 2001 From: Riad Benguella Date: Fri, 22 Jun 2018 15:49:59 +0100 Subject: [PATCH 2/8] Data Module: Add default registry and createRegistry function --- packages/data/src/components/with-dispatch.js | 80 ++ packages/data/src/components/with-select.js | 87 ++ packages/data/src/default-registry.js | 3 + packages/data/src/index.js | 54 +- packages/data/src/registry.js | 613 ++++------ packages/data/src/store/index.js | 15 +- packages/data/src/test/index.js | 369 ++++++ packages/data/src/test/registry.js | 1060 ++++++----------- 8 files changed, 1160 insertions(+), 1121 deletions(-) create mode 100644 packages/data/src/components/with-dispatch.js create mode 100644 packages/data/src/components/with-select.js create mode 100644 packages/data/src/default-registry.js create mode 100644 packages/data/src/test/index.js diff --git a/packages/data/src/components/with-dispatch.js b/packages/data/src/components/with-dispatch.js new file mode 100644 index 0000000000000..e46d49daaf2f7 --- /dev/null +++ b/packages/data/src/components/with-dispatch.js @@ -0,0 +1,80 @@ +/** + * External dependencies + */ +import { mapValues } from 'lodash'; + +/** + * WordPress dependencies + */ +import { + Component, + compose, + createElement, + createHigherOrderComponent, + pure, +} from '@wordpress/element'; + +/** + * Internal dependencies + */ +import defaultRegistry from '../default-registry'; + +/** + * 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 ) => { + 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( defaultRegistry.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( defaultRegistry.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' +); + +export default withDispatch; diff --git a/packages/data/src/components/with-select.js b/packages/data/src/components/with-select.js new file mode 100644 index 0000000000000..330ec553f41a2 --- /dev/null +++ b/packages/data/src/components/with-select.js @@ -0,0 +1,87 @@ +/** + * WordPress dependencies + */ +import { + Component, + createElement, + createHigherOrderComponent, +} from '@wordpress/element'; +import isShallowEqual from '@wordpress/is-shallow-equal'; + +/** + * Internal dependencies + */ +import defaultRegistry from '../default-registry'; + +/** + * 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. + */ +const withSelect = ( mapStateToProps ) => createHigherOrderComponent( ( WrappedComponent ) => { + const DEFAULT_MERGE_PROPS = {}; + + return class ComponentWithSelect extends Component { + constructor() { + super( ...arguments ); + + this.subscribe(); + + this.state = {}; + } + + static getDerivedStateFromProps( 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. + const mergeProps = ( + mapStateToProps( defaultRegistry.select, props ) || + DEFAULT_MERGE_PROPS + ); + + return { mergeProps }; + } + + componentDidMount() { + this.canRunSelection = true; + } + + componentWillUnmount() { + this.canRunSelection = false; + this.unsubscribe(); + } + + shouldComponentUpdate( nextProps, nextState ) { + return ( + ! isShallowEqual( this.props, nextProps ) || + ! isShallowEqual( this.state.mergeProps, nextState.mergeProps ) + ); + } + + subscribe() { + this.unsubscribe = defaultRegistry.subscribe( () => { + if ( ! this.canRunSelection ) { + return; + } + + // Trigger an update. Behavior of `getDerivedStateFromProps` as + // of React 16.4.0 is such that it will be called by any update + // to the component, including state changes. + // + // See: https://reactjs.org/blog/2018/05/23/react-v-16-4.html#bugfix-for-getderivedstatefromprops + this.setState( () => ( {} ) ); + } ); + } + + render() { + return ; + } + }; +}, 'withSelect' ); + +export default withSelect; 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 b695595196779..44f88e9f87c87 100644 --- a/packages/data/src/index.js +++ b/packages/data/src/index.js @@ -1,20 +1,34 @@ -export { - loadAndPersist, - withRehydration, - globalListener, - registerStore, - registerReducer, - combineReducers, - registerSelectors, - registerResolvers, - registerActions, - subscribe, - select, - dispatch, - withSelect, - withDispatch, - isActionLike, - isAsyncIterable, - isIterable, - toAsyncIterable, -} from './registry'; +/** + * External dependencies + */ +import { combineReducers } from 'redux'; + +/** + * Internal dependencies + */ +import defaultRegistry from './default-registry'; +export { loadAndPersist, withRehydration, withRehydratation } from './persist'; +export { default as withSelect } from './components/with-select'; +export { default as withDispatch } from './components/with-dispatch'; + +/** + * The combineReducers helper function turns an object whose values are different + * reducing functions into a single reducing function you can pass to registerReducer. + * + * @param {Object} reducers An object whose values correspond to different reducing + * functions that need to be combined into one. + * + * @return {Function} A reducer that invokes every reducer inside the reducers + * object, and constructs a state object with the same shape. + */ +export { combineReducers }; + +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 index 14247b8bb601c..20f92da726d89 100644 --- a/packages/data/src/registry.js +++ b/packages/data/src/registry.js @@ -1,389 +1,13 @@ /** * External dependencies */ -import { combineReducers, createStore } from 'redux'; -import { flowRight, without, mapValues, overEvery } from 'lodash'; - -/** - * WordPress dependencies - */ -import { - Component, - compose, - createElement, - createHigherOrderComponent, - pure, -} from '@wordpress/element'; -import isShallowEqual from '@wordpress/is-shallow-equal'; +import { createStore } from 'redux'; +import { flowRight, without, mapValues, overEvery, get } from 'lodash'; /** * Internal dependencies */ -import registerDataStore from './store'; - -export { loadAndPersist, withRehydration, withRehydratation } 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; -} - -/** - * The combineReducers helper function turns an object whose values are different - * reducing functions into a single reducing function you can pass to registerReducer. - * - * @param {Object} reducers An object whose values correspond to different reducing - * functions that need to be combined into one. - * - * @return {Function} A reducer that invokes every reducer inside the reducers - * object, and constructs a state object with the same shape. - */ -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 ) => { - const DEFAULT_MERGE_PROPS = {}; - - return class ComponentWithSelect extends Component { - constructor() { - super( ...arguments ); - - this.subscribe(); - - this.state = {}; - } - - static getDerivedStateFromProps( 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. - const mergeProps = ( - mapStateToProps( select, props ) || - DEFAULT_MERGE_PROPS - ); - - return { mergeProps }; - } - - componentDidMount() { - this.canRunSelection = true; - } - - componentWillUnmount() { - this.canRunSelection = false; - this.unsubscribe(); - } - - shouldComponentUpdate( nextProps, nextState ) { - return ( - ! isShallowEqual( this.props, nextProps ) || - ! isShallowEqual( this.state.mergeProps, nextState.mergeProps ) - ); - } - - subscribe() { - this.unsubscribe = subscribe( () => { - if ( ! this.canRunSelection ) { - return; - } - - // Trigger an update. Behavior of `getDerivedStateFromProps` as - // of React 16.4.0 is such that it will be called by any update - // to the component, including state changes. - // - // See: https://reactjs.org/blog/2018/05/23/react-v-16-4.html#bugfix-for-getderivedstatefromprops - 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' -); +import dataStore from './store'; /** * Returns true if the given argument appears to be a dispatchable action. @@ -457,4 +81,233 @@ export function toAsyncIterable( object ) { }() ); } -registerDataStore(); +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' ] ); + } + + [ 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..4d53f8f540d03 100644 --- a/packages/data/src/store/index.js +++ b/packages/data/src/store/index.js @@ -1,16 +1,13 @@ /** * 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 { + name: 'core/data', + reducer, + actions, + selectors, +}; diff --git a/packages/data/src/test/index.js b/packages/data/src/test/index.js new file mode 100644 index 0000000000000..2078682c49abe --- /dev/null +++ b/packages/data/src/test/index.js @@ -0,0 +1,369 @@ +/** + * External dependencies + */ +import { mount } from 'enzyme'; + +/** + * WordPress dependencies + */ +import { compose, createElement } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import { + registerReducer, + registerSelectors, + registerActions, + dispatch, + withSelect, + withDispatch, +} from '../'; + +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 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 ) => - ) ); - - 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 resolve promise action to dispatch', () => { + registry.registerReducer( 'demo', ( state = 'NOTOK', action ) => { + return action.type === 'SET_OK' ? 'OK' : state; + } ); + registry.registerSelectors( 'demo', { + getValue: ( state ) => state, + } ); + registry.registerResolvers( 'demo', { + getValue: () => Promise.resolve( { type: 'SET_OK' } ), + } ); - it( 'should rerun selection on props changes', () => { - registerReducer( 'counter', ( state = 0, action ) => { - if ( action.type === 'increment' ) { - return state + 1; - } + const promise = subscribeUntil( [ + () => registry.select( 'demo' ).getValue() === 'OK', + () => registry.select( 'core/data' ).hasFinishedResolution( 'demo', 'getValue' ), + ] ); - return state; - } ); + registry.select( 'demo' ).getValue(); - registerSelectors( 'counter', { - getCount: ( state, offset ) => state + offset, + return promise; } ); - 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 } ); + it( 'should resolve promise non-action to dispatch', ( done ) => { + let shouldThrow = false; + registry.registerReducer( 'demo', ( state = 'OK' ) => { + if ( shouldThrow ) { + throw 'Should not have dispatched'; + } - expect( wrapper.childAt( 0 ).text() ).toBe( '10' ); - expect( mapSelectToProps ).toHaveBeenCalledTimes( 2 ); - expect( OriginalComponent ).toHaveBeenCalledTimes( 2 ); - } ); + return state; + } ); + shouldThrow = true; + registry.registerSelectors( 'demo', { + getValue: ( state ) => state, + } ); + registry.registerResolvers( 'demo', { + getValue: () => Promise.resolve(), + } ); - it( 'should render if props have changed but not state', () => { - store = registerReducer( 'unchanging', ( state = {} ) => state ); + registry.select( 'demo' ).getValue(); - registerSelectors( 'unchanging', { - getState: ( state ) => state, + process.nextTick( () => { + done(); + } ); } ); - 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' } ); + it( 'should resolve async iterator action to dispatch', () => { + registry.registerReducer( 'counter', ( state = 0, action ) => { + return action.type === 'INCREMENT' ? state + 1 : state; + } ); + registry.registerSelectors( 'counter', { + getCount: ( state ) => state, + } ); + registry.registerResolvers( 'counter', { + getCount: async function* () { + yield { type: 'INCREMENT' }; + yield await Promise.resolve( { type: 'INCREMENT' } ); + }, + } ); - expect( mapSelectToProps ).toHaveBeenCalledTimes( 2 ); - expect( OriginalComponent ).toHaveBeenCalledTimes( 2 ); - } ); + const promise = subscribeUntil( [ + () => registry.select( 'counter' ).getCount() === 2, + () => registry.select( 'core/data' ).hasFinishedResolution( 'counter', 'getCount' ), + ] ); - it( 'should not rerun selection on unchanging state', () => { - store = registerReducer( 'unchanging', ( state = {} ) => state ); + registry.select( 'counter' ).getCount(); - registerSelectors( 'unchanging', { - getState: ( state ) => state, + return promise; } ); - 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' } ); + it( 'should not dispatch resolved promise action on subsequent selector calls', () => { + registry.registerReducer( 'demo', ( state = 'NOTOK', action ) => { + return action.type === 'SET_OK' && state === 'NOTOK' ? 'OK' : 'NOTOK'; + } ); + registry.registerSelectors( 'demo', { + getValue: ( state ) => state, + } ); + registry.registerResolvers( 'demo', { + getValue: () => Promise.resolve( { type: 'SET_OK' } ), + } ); - expect( mapSelectToProps ).toHaveBeenCalledTimes( 1 ); - expect( OriginalComponent ).toHaveBeenCalledTimes( 1 ); - } ); + const promise = subscribeUntil( () => registry.select( 'demo' ).getValue() === 'OK' ); - it( 'omits props which are not returned on subsequent mappings', () => { - registerReducer( 'demo', ( state = 'OK' ) => state ); - registerSelectors( 'demo', { - getValue: ( state ) => state, - } ); + registry.select( 'demo' ).getValue(); + registry.select( 'demo' ).getValue(); - const mapSelectToProps = jest.fn().mockImplementation( ( _select, ownProps ) => { - return { - [ ownProps.propName ]: _select( 'demo' ).getValue(), - }; + return promise; } ); - - 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 ); + describe( 'select', () => { + it( 'registers multiple selectors to the public API', () => { + const store = registry.registerReducer( 'reducer1', () => 'state1' ); + const selector1 = jest.fn( () => 'result1' ); + const selector2 = jest.fn( () => 'result2' ); - 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 } ); + registry.registerSelectors( 'reducer1', { + selector1, + selector2, + } ); - expect( mapSelectToProps ).toHaveBeenCalledTimes( 3 ); - expect( OriginalComponent ).toHaveBeenCalledTimes( 3 ); - expect( wrapper.childAt( 0 ).text() ).toBe( 'Unknown' ); - } ); + expect( registry.select( 'reducer1' ).selector1() ).toEqual( 'result1' ); + expect( selector1 ).toBeCalledWith( store.getState() ); - 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' } ), + expect( registry.select( 'reducer1' ).selector2() ).toEqual( 'result2' ); + expect( selector2 ).toBeCalledWith( store.getState() ); } ); - - 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; + describe( 'subscribe', () => { + const unsubscribes = []; + afterEach( () => { + let unsubscribe; + while ( ( unsubscribe = unsubscribes.shift() ) ) { + unsubscribe(); } - 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 ) =>