diff --git a/package-lock.json b/package-lock.json
index 8a432d50d705a..aae97e7e7145c 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -14134,6 +14134,11 @@
"aproba": "^1.1.1"
}
},
+ "rungen": {
+ "version": "0.3.2",
+ "resolved": "https://registry.npmjs.org/rungen/-/rungen-0.3.2.tgz",
+ "integrity": "sha1-QAwJ6+kU57F+C27zJjQA/Cq8fLM="
+ },
"rx": {
"version": "2.3.24",
"resolved": "https://registry.npmjs.org/rx/-/rx-2.3.24.tgz",
diff --git a/package.json b/package.json
index 358ba58d6c8fc..ffd7fdddcb587 100644
--- a/package.json
+++ b/package.json
@@ -55,6 +55,7 @@
"redux-optimist": "1.0.0",
"refx": "3.0.0",
"rememo": "3.0.0",
+ "rungen": "0.3.2",
"showdown": "1.8.6",
"simple-html-tokenizer": "0.4.1",
"tinycolor2": "1.4.1",
diff --git a/packages/core-data/src/entities.js b/packages/core-data/src/entities.js
index 687c5e55d32df..ac04255fb1189 100644
--- a/packages/core-data/src/entities.js
+++ b/packages/core-data/src/entities.js
@@ -66,7 +66,7 @@ export const getMethodName = ( kind, name, prefix = 'get', usePlural = false ) =
*
* @return {Array} Entities
*/
-export async function* getKindEntities( state, kind ) {
+export function* getKindEntities( state, kind ) {
let entities = getEntitiesByKind( state, kind );
if ( entities && entities.length !== 0 ) {
@@ -78,7 +78,7 @@ export async function* getKindEntities( state, kind ) {
return [];
}
- entities = await kindConfig.loadEntities();
+ entities = yield kindConfig.loadEntities();
yield addEntities( entities );
return entities;
diff --git a/packages/core-data/src/resolvers.js b/packages/core-data/src/resolvers.js
index b35844b6bd78e..57c87db8890a1 100644
--- a/packages/core-data/src/resolvers.js
+++ b/packages/core-data/src/resolvers.js
@@ -23,16 +23,16 @@ import { getKindEntities } from './entities';
* Requests categories from the REST API, yielding action objects on request
* progress.
*/
-export async function* getCategories() {
- const categories = await apiRequest( { path: '/wp/v2/categories?per_page=-1' } );
+export function* getCategories() {
+ const categories = yield apiRequest( { path: '/wp/v2/categories?per_page=-1' } );
yield receiveTerms( 'categories', categories );
}
/**
* Requests authors from the REST API.
*/
-export async function* getAuthors() {
- const users = await apiRequest( { path: '/wp/v2/users/?who=authors&per_page=-1' } );
+export function* getAuthors() {
+ const users = yield apiRequest( { path: '/wp/v2/users/?who=authors&per_page=-1' } );
yield receiveUserQuery( 'authors', users );
}
@@ -44,13 +44,13 @@ export async function* getAuthors() {
* @param {string} name Entity name.
* @param {number} key Record's key
*/
-export async function* getEntityRecord( state, kind, name, key ) {
- const entities = yield* await getKindEntities( state, kind );
+export function* getEntityRecord( state, kind, name, key ) {
+ const entities = yield getKindEntities( state, kind );
const entity = find( entities, { kind, name } );
if ( ! entity ) {
return;
}
- const record = await apiRequest( { path: `${ entity.baseUrl }/${ key }?context=edit` } );
+ const record = yield apiRequest( { path: `${ entity.baseUrl }/${ key }?context=edit` } );
yield receiveEntityRecords( kind, name, record );
}
@@ -61,20 +61,20 @@ export async function* getEntityRecord( state, kind, name, key ) {
* @param {string} kind Entity kind.
* @param {string} name Entity name.
*/
-export async function* getEntityRecords( state, kind, name ) {
- const entities = yield* await getKindEntities( state, kind );
+export function* getEntityRecords( state, kind, name ) {
+ const entities = yield getKindEntities( state, kind );
const entity = find( entities, { kind, name } );
if ( ! entity ) {
return;
}
- const records = await apiRequest( { path: `${ entity.baseUrl }?context=edit` } );
+ const records = yield apiRequest( { path: `${ entity.baseUrl }?context=edit` } );
yield receiveEntityRecords( kind, name, Object.values( records ) );
}
/**
* Requests theme supports data from the index.
*/
-export async function* getThemeSupports() {
- const index = await apiRequest( { path: '/' } );
+export function* getThemeSupports() {
+ const index = yield apiRequest( { path: '/' } );
yield receiveThemeSupportsFromIndex( index );
}
diff --git a/packages/core-data/src/test/entities.js b/packages/core-data/src/test/entities.js
index 4f97e4d039ab7..e46fb3a00386f 100644
--- a/packages/core-data/src/test/entities.js
+++ b/packages/core-data/src/test/entities.js
@@ -59,26 +59,27 @@ describe( 'getKindEntities', () => {
} );
} );
- it( 'shouldn\'t do anything if the entities have already been resolved', async () => {
+ it( 'shouldn\'t do anything if the entities have already been resolved', () => {
const state = {
entities: { config: [ { kind: 'postType' } ] },
};
const fulfillment = getKindEntities( state, 'postType' );
- const done = ( await fulfillment.next() ).done;
+ const done = fulfillment.next().done;
expect( done ).toBe( true );
} );
- it( 'shouldn\'t do anything if there no defined kind config', async () => {
+ it( 'shouldn\'t do anything if there no defined kind config', () => {
const state = { entities: { config: [] } };
const fulfillment = getKindEntities( state, 'unknownKind' );
- const done = ( await fulfillment.next() ).done;
+ const done = fulfillment.next().done;
expect( done ).toBe( true );
} );
it( 'should fetch and add the entities', async () => {
const state = { entities: { config: [] } };
const fulfillment = getKindEntities( state, 'postType' );
- const received = ( await fulfillment.next() ).value;
+ const receivedEntities = await fulfillment.next().value;
+ const received = ( fulfillment.next( receivedEntities ) ).value;
expect( received ).toEqual( addEntities( [ {
baseUrl: '/wp/v2/posts',
kind: 'postType',
diff --git a/packages/core-data/src/test/resolvers.js b/packages/core-data/src/test/resolvers.js
index 9e20fc496ae9c..d305e2808c08b 100644
--- a/packages/core-data/src/test/resolvers.js
+++ b/packages/core-data/src/test/resolvers.js
@@ -24,7 +24,8 @@ describe( 'getCategories', () => {
it( 'yields with requested terms', async () => {
const fulfillment = getCategories();
- const received = ( await fulfillment.next() ).value;
+ const categories = await fulfillment.next().value;
+ const received = fulfillment.next( categories ).value;
expect( received ).toEqual( receiveTerms( 'categories', CATEGORIES ) );
} );
} );
@@ -43,39 +44,16 @@ describe( 'getEntityRecord', () => {
if ( options.path === '/wp/v2/types/post?context=edit' ) {
return Promise.resolve( POST_TYPE );
}
- if ( options.path === '/wp/v2/posts/10?context=edit' ) {
- return Promise.resolve( POST );
- }
- if ( options.path === '/wp/v2/types?context=edit' ) {
- return Promise.resolve( POST_TYPES );
- }
} );
} );
it( 'yields with requested post type', async () => {
- const state = {
- entities: {
- config: [
- { name: 'postType', kind: 'root', baseUrl: '/wp/v2/types' },
- ],
- },
- };
- const fulfillment = getEntityRecord( state, 'root', 'postType', 'post' );
- const received = ( await fulfillment.next() ).value;
+ const fulfillment = getEntityRecord( {}, 'root', 'postType', 'post' );
+ fulfillment.next(); // Trigger the getKindEntities generator
+ const records = await fulfillment.next( [ { name: 'postType', kind: 'root', baseUrl: '/wp/v2/types' } ] ).value;
+ const received = await fulfillment.next( records ).value;
expect( received ).toEqual( receiveEntityRecords( 'root', 'postType', POST_TYPE ) );
} );
-
- it( 'loads the kind entities and yields with requested post type', async () => {
- const fulfillment = getEntityRecord( { entities: {} }, 'postType', 'post', 10 );
- const receivedEntities = ( await fulfillment.next() ).value;
- expect( receivedEntities ).toEqual( addEntities( [ {
- baseUrl: '/wp/v2/posts',
- kind: 'postType',
- name: 'post',
- } ] ) );
- const received = ( await fulfillment.next() ).value;
- expect( received ).toEqual( receiveEntityRecords( 'postType', 'post', POST ) );
- } );
} );
describe( 'getEntityRecords', () => {
@@ -93,15 +71,10 @@ describe( 'getEntityRecords', () => {
} );
it( 'yields with requested post type', async () => {
- const state = {
- entities: {
- config: [
- { name: 'postType', kind: 'root', baseUrl: '/wp/v2/types' },
- ],
- },
- };
- const fulfillment = getEntityRecords( state, 'root', 'postType' );
- const received = ( await fulfillment.next() ).value;
+ const fulfillment = getEntityRecords( {}, 'root', 'postType' );
+ fulfillment.next(); // Trigger the getKindEntities generator
+ const records = await fulfillment.next( [ { name: 'postType', kind: 'root', baseUrl: '/wp/v2/types' } ] ).value;
+ const received = ( await fulfillment.next( records ) ).value;
expect( received ).toEqual( receiveEntityRecords( 'root', 'postType', Object.values( POST_TYPES ) ) );
} );
} );
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..7b86a20a0cfca
--- /dev/null
+++ b/packages/data/src/components/registry-provider/index.js
@@ -0,0 +1,10 @@
+/**
+ * WordPress dependencies
+ */
+import { createContext } from '@wordpress/element';
+
+const { Consumer, Provider } = createContext( null );
+
+export const RegistryConsumer = Consumer;
+
+export default Provider;
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..ef41c38d0575b
--- /dev/null
+++ b/packages/data/src/components/with-dispatch/index.js
@@ -0,0 +1,94 @@
+/**
+ * External dependencies
+ */
+import { mapValues } from 'lodash';
+
+/**
+ * WordPress dependencies
+ */
+import {
+ Component,
+ compose,
+ createElement,
+ createHigherOrderComponent,
+ pure,
+} from '@wordpress/element';
+
+/**
+ * Internal dependencies
+ */
+import defaultRegistry from '../../default-registry';
+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 ) => {
+ 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.
+ const dispatch = this.props.registry ? this.props.registry.dispatch : defaultRegistry.dispatch;
+ mapDispatchToProps( 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 dispatch = props.registry ? props.registry.dispatch : defaultRegistry.dispatch;
+ const propsToDispatchers = mapDispatchToProps( 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..8ee8988307868
--- /dev/null
+++ b/packages/data/src/components/with-dispatch/test/index.js
@@ -0,0 +1,69 @@
+/**
+ * External dependencies
+ */
+import TestRenderer from 'react-test-renderer';
+
+/**
+ * WordPress dependencies
+ */
+import { createElement } from '@wordpress/element';
+
+/**
+ * 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 testRenderer = TestRenderer.create(
+
+
+
+ );
+ const testInstance = testRenderer.root;
+
+ const incrementBeforeSetProps = testInstance.findByType( 'button' ).props.onClick;
+
+ // Verify that dispatch respects props at the time of being invoked by
+ // changing props after the initial mount.
+ testRenderer.update(
+
+
+
+ );
+
+ // Function value reference should not have changed in props update.
+ expect( testInstance.findByType( 'button' ).props.onClick ).toBe( incrementBeforeSetProps );
+
+ incrementBeforeSetProps();
+
+ expect( store.getState() ).toBe( 2 );
+ } );
+} );
diff --git a/packages/data/src/components/with-select/index.js b/packages/data/src/components/with-select/index.js
new file mode 100644
index 0000000000000..486018df139de
--- /dev/null
+++ b/packages/data/src/components/with-select/index.js
@@ -0,0 +1,101 @@
+/**
+ * WordPress dependencies
+ */
+import {
+ Component,
+ createElement,
+ createHigherOrderComponent,
+} from '@wordpress/element';
+import isShallowEqual from '@wordpress/is-shallow-equal';
+
+/**
+ * Internal dependencies
+ */
+import defaultRegistry from '../../default-registry';
+import { RegistryConsumer } from '../registry-provider';
+
+/**
+ * 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 = {};
+
+ 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 select = props.registry ? props.registry.select : defaultRegistry.select;
+ const mergeProps = (
+ mapStateToProps( select, props.ownProps ) ||
+ DEFAULT_MERGE_PROPS
+ );
+
+ return { mergeProps };
+ }
+
+ componentDidMount() {
+ this.canRunSelection = true;
+ }
+
+ componentWillUnmount() {
+ this.canRunSelection = false;
+ this.unsubscribe();
+ }
+
+ shouldComponentUpdate( nextProps, nextState ) {
+ return (
+ ! isShallowEqual( this.props.ownProps, nextProps.ownProps ) ||
+ ! isShallowEqual( this.state.mergeProps, nextState.mergeProps )
+ );
+ }
+
+ subscribe() {
+ const subscribe = this.props.registry ? this.props.registry.subscribe : defaultRegistry.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 ;
+ }
+ }
+
+ return ( ownProps ) => (
+
+ { ( registry ) => (
+
+ ) }
+
+ );
+}, 'withSelect' );
+
+export default withSelect;
diff --git a/packages/data/src/components/with-select/test/index.js b/packages/data/src/components/with-select/test/index.js
new file mode 100644
index 0000000000000..dfdef80408b88
--- /dev/null
+++ b/packages/data/src/components/with-select/test/index.js
@@ -0,0 +1,379 @@
+/**
+ * External dependencies
+ */
+import TestRenderer from 'react-test-renderer';
+
+/**
+ * WordPress dependencies
+ */
+import { createElement, compose } from '@wordpress/element';
+
+/**
+ * Internal dependencies
+ */
+import withSelect from '../';
+import withDispatch from '../../with-dispatch';
+import { createRegistry } from '../../../registry';
+import RegistryProvider from '../../registry-provider';
+
+describe( 'withSelect', () => {
+ let registry;
+ beforeEach( () => {
+ registry = createRegistry();
+ } );
+
+ it( 'passes the relevant data to the component', () => {
+ registry.registerStore( 'reactReducer', {
+ reducer: () => ( { reactKey: 'reactState' } ),
+ selectors: {
+ 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 );
+
+ const testRenderer = TestRenderer.create(
+
+
+
+ );
+ const testInstance = testRenderer.root;
+
+ expect( mapSelectToProps ).toHaveBeenCalledTimes( 1 );
+ expect( OriginalComponent ).toHaveBeenCalledTimes( 1 );
+
+ // Wrapper is the enhanced component. Find props on the rendered child.
+ expect( testInstance.findByType( 'div' ).props ).toEqual( {
+ children: 'reactState',
+ } );
+ } );
+
+ it( 'should rerun selection on state changes', () => {
+ registry.registerStore( 'counter', {
+ reducer: ( state = 0, action ) => {
+ if ( action.type === 'increment' ) {
+ return state + 1;
+ }
+
+ return state;
+ },
+ selectors: {
+ getCount: ( state ) => state,
+ },
+ actions: {
+ 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 );
+
+ 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 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 14247b8bb601c..b9db830daf591 100644
--- a/packages/data/src/index.js
+++ b/packages/data/src/index.js
@@ -1,104 +1,16 @@
/**
* 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 { combineReducers } from 'redux';
/**
* Internal dependencies
*/
-import registerDataStore from './store';
-
+import defaultRegistry from './default-registry';
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;
-}
+export { default as withSelect } from './components/with-select';
+export { default as withDispatch } from './components/with-dispatch';
+export { default as RegistryProvider } from './components/registry-provider';
/**
* The combineReducers helper function turns an object whose values are different
@@ -112,349 +24,12 @@ 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 ) => {
- 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;
- }
- }() );
-}
+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;
-registerDataStore();
diff --git a/packages/data/src/registry.js b/packages/data/src/registry.js
new file mode 100644
index 0000000000000..2824897574df8
--- /dev/null
+++ b/packages/data/src/registry.js
@@ -0,0 +1,237 @@
+/**
+ * External dependencies
+ */
+import { createStore } from 'redux';
+import { flowRight, without, mapValues, overEvery, get } from 'lodash';
+import createStoreRuntime from './runtime';
+
+/**
+ * Internal dependencies
+ */
+import dataStore from './store';
+
+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 ] = {
+ runtime: createStoreRuntime( store ),
+ 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;
+ const runtime = namespaces[ reducerKey ].runtime;
+
+ // Normalize resolver shape to object.
+ let resolver = newResolvers[ selectorName ];
+ if ( ! resolver.fulfill ) {
+ resolver = { fulfill: resolver };
+ }
+
+ 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();
+
+ const fulfillment = resolver.fulfill( state, ...args );
+ runtime( fulfillment, () => {
+ 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 runtime = namespaces[ reducerKey ].runtime;
+ const createBoundAction = ( action ) => ( ...args ) => runtime( 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/runtime.js b/packages/data/src/runtime.js
new file mode 100644
index 0000000000000..880fce3ec044d
--- /dev/null
+++ b/packages/data/src/runtime.js
@@ -0,0 +1,124 @@
+/**
+ * External dependencies
+ */
+import { create, all } from 'rungen';
+
+/**
+ * WordPress dependencies
+ */
+import deprecated from '@wordpress/deprecated';
+
+/**
+ * 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 a sync iterable
+ * yielding on a singular or array of generator yields or promise resolution.
+ *
+ * @param {*} object Object to normalize.
+ *
+ * @return {Generator} Iterable actions.
+ */
+export function toSyncIterable( object ) {
+ // If it's an array make sure that each value is yielded separately
+ if ( Array.isArray( object ) ) {
+ object = all( object );
+ }
+
+ // Normalize as iterable...
+ if ( isIterable( object ) ) {
+ return object;
+ }
+
+ return ( function* () {
+ return yield object;
+ }() );
+}
+
+export default function createStoreRuntime( store ) {
+ const actionControl = ( value, next ) => {
+ if ( ! isActionLike( value ) ) {
+ return false;
+ }
+ store.dispatch( value );
+ next();
+ return true;
+ };
+
+ const promiseControl = ( value, next, rungen, yieldNext, raiseNext ) => {
+ if ( ! value || typeof value.then !== 'function' ) {
+ return false;
+ }
+ value.then( yieldNext, raiseNext );
+ return true;
+ };
+
+ const syncIterableRuntime = create( [
+ promiseControl,
+ actionControl,
+ ] );
+ const asyncIterableRuntime = async ( actionCreator ) => {
+ deprecated( 'Asynchronous generators support in Resolvers', {
+ version: '3.3',
+ alternative: 'Simple generators',
+ plugin: 'Gutenberg',
+ } );
+ for await ( const maybeAction of actionCreator ) {
+ // Dispatch if it quacks like an action.
+ if ( isActionLike( maybeAction ) ) {
+ store.dispatch( maybeAction );
+ }
+ }
+ };
+
+ return ( actionCreator, onSuccess ) => {
+ if ( isAsyncIterable( actionCreator ) ) {
+ // Todo dispatch deprecated
+ asyncIterableRuntime( actionCreator ).then( onSuccess );
+ return;
+ }
+
+ // Attempt to normalize the action creator as async iterable.
+ actionCreator = toSyncIterable( actionCreator );
+ syncIterableRuntime( actionCreator, onSuccess );
+ };
+}
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 816b3914dc5e5..0000000000000
--- a/packages/data/src/test/index.js
+++ /dev/null
@@ -1,950 +0,0 @@
-/**
- * External dependencies
- */
-import { mount } from 'enzyme';
-import { castArray } from 'lodash';
-
-/**
- * WordPress dependencies
- */
-import { compose, createElement } 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 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 ) => );
-
- wrapper = mount( );
-
- // Wrapper is the enhanced component. Find props on the rendered child.
- const child = wrapper.childAt( 0 );
-
- const incrementBeforeSetProps = child.prop( 'increment' );
-
- // Verify that dispatch respects props at the time of being invoked by
- // changing props after the initial mount.
- wrapper.setProps( { count: 2 } );
-
- // Function value reference should not have changed in props update.
- expect( child.prop( 'increment' ) ).toBe( incrementBeforeSetProps );
-
- wrapper.find( 'button' ).simulate( 'click' );
-
- expect( store.getState() ).toBe( 2 );
- } );
-} );
-
-describe( 'subscribe', () => {
- const unsubscribes = [];
- afterEach( () => {
- let unsubscribe;
- while ( ( unsubscribe = unsubscribes.shift() ) ) {
- unsubscribe();
- }
- } );
-
- function subscribeWithUnsubscribe( ...args ) {
- const unsubscribe = subscribe( ...args );
- unsubscribes.push( unsubscribe );
- return unsubscribe;
- }
-
- it( 'registers multiple selectors to the public API', () => {
- let incrementedValue = null;
- const store = registerReducer( 'myAwesomeReducer', ( state = 0 ) => state + 1 );
- registerSelectors( 'myAwesomeReducer', {
- globalSelector: ( state ) => state,
- } );
- const unsubscribe = subscribe( () => {
- incrementedValue = select( 'myAwesomeReducer' ).globalSelector();
- } );
- const action = { type: 'dummy' };
-
- store.dispatch( action ); // increment the data by => data = 2
- expect( incrementedValue ).toBe( 2 );
-
- store.dispatch( action ); // increment the data by => data = 3
- expect( incrementedValue ).toBe( 3 );
-
- unsubscribe(); // Store subscribe to changes, the data variable stops upgrading.
-
- store.dispatch( action );
- store.dispatch( action );
-
- expect( incrementedValue ).toBe( 3 );
- } );
-
- it( 'snapshots listeners on change, avoiding a later listener if subscribed during earlier callback', () => {
- const store = registerReducer( 'myAwesomeReducer', ( state = 0 ) => state + 1 );
- const secondListener = jest.fn();
- const firstListener = jest.fn( () => {
- subscribeWithUnsubscribe( secondListener );
- } );
-
- subscribeWithUnsubscribe( firstListener );
-
- store.dispatch( { type: 'dummy' } );
-
- expect( secondListener ).not.toHaveBeenCalled();
- } );
-
- it( 'snapshots listeners on change, calling a later listener even if unsubscribed during earlier callback', () => {
- const store = registerReducer( 'myAwesomeReducer', ( state = 0 ) => state + 1 );
- const firstListener = jest.fn( () => {
- secondUnsubscribe();
- } );
- const secondListener = jest.fn();
-
- subscribeWithUnsubscribe( firstListener );
- const secondUnsubscribe = subscribeWithUnsubscribe( secondListener );
-
- store.dispatch( { type: 'dummy' } );
-
- expect( secondListener ).toHaveBeenCalled();
- } );
-
- it( 'does not call listeners if state has not changed', () => {
- const store = registerReducer( 'unchanging', ( state = {} ) => state );
- const listener = jest.fn();
- subscribeWithUnsubscribe( listener );
-
- store.dispatch( { type: 'dummy' } );
-
- expect( listener ).not.toHaveBeenCalled();
- } );
-} );
-
-describe( 'dispatch', () => {
- it( 'registers actions to the public API', () => {
- 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,
- } );
-
- dispatch( 'counter' ).increment(); // state = 1
- dispatch( 'counter' ).increment( 4 ); // state = 5
- expect( store.getState() ).toBe( 5 );
- } );
-} );
-
-describe( 'isActionLike', () => {
- it( 'returns false if non-action-like', () => {
- expect( isActionLike( undefined ) ).toBe( false );
- expect( isActionLike( null ) ).toBe( false );
- expect( isActionLike( [] ) ).toBe( false );
- expect( isActionLike( {} ) ).toBe( false );
- expect( isActionLike( 1 ) ).toBe( false );
- expect( isActionLike( 0 ) ).toBe( false );
- expect( isActionLike( Infinity ) ).toBe( false );
- expect( isActionLike( { type: null } ) ).toBe( false );
- } );
-
- it( 'returns true if action-like', () => {
- expect( isActionLike( { type: 'POW' } ) ).toBe( true );
- } );
-} );
-
-describe( 'isAsyncIterable', () => {
- it( 'returns false if not async iterable', () => {
- expect( isAsyncIterable( undefined ) ).toBe( false );
- expect( isAsyncIterable( null ) ).toBe( false );
- expect( isAsyncIterable( [] ) ).toBe( false );
- expect( isAsyncIterable( {} ) ).toBe( false );
- } );
-
- it( 'returns true if async iterable', async () => {
- async function* getAsyncIterable() {
- yield new Promise( ( resolve ) => process.nextTick( resolve ) );
- }
-
- const result = getAsyncIterable();
-
- expect( isAsyncIterable( result ) ).toBe( true );
-
- await result;
- } );
-} );
-
-describe( 'isIterable', () => {
- it( 'returns false if not iterable', () => {
- expect( isIterable( undefined ) ).toBe( false );
- expect( isIterable( null ) ).toBe( false );
- expect( isIterable( {} ) ).toBe( false );
- expect( isIterable( Promise.resolve( {} ) ) ).toBe( false );
- } );
-
- it( 'returns true if iterable', () => {
- function* getIterable() {
- yield 'foo';
- }
-
- const result = getIterable();
-
- expect( isIterable( result ) ).toBe( true );
- expect( isIterable( [] ) ).toBe( true );
- } );
-} );
-
-describe( 'toAsyncIterable', () => {
- it( 'normalizes async iterable', async () => {
- async function* getAsyncIterable() {
- yield await Promise.resolve( { ok: true } );
- }
-
- const object = getAsyncIterable();
- const normalized = toAsyncIterable( object );
-
- expect( ( await normalized.next() ).value ).toEqual( { ok: true } );
- } );
-
- it( 'normalizes promise', async () => {
- const object = Promise.resolve( { ok: true } );
- const normalized = toAsyncIterable( object );
-
- expect( ( await normalized.next() ).value ).toEqual( { ok: true } );
- } );
-
- it( 'normalizes object', async () => {
- const object = { ok: true };
- const normalized = toAsyncIterable( object );
-
- expect( ( await normalized.next() ).value ).toEqual( { ok: true } );
- } );
-
- it( 'normalizes array of promise', async () => {
- const object = [ Promise.resolve( { ok: true } ) ];
- const normalized = toAsyncIterable( object );
-
- expect( ( await normalized.next() ).value ).toEqual( { ok: true } );
- } );
-
- it( 'normalizes mixed array', async () => {
- const object = [ { foo: 'bar' }, Promise.resolve( { ok: true } ) ];
- const normalized = toAsyncIterable( object );
-
- expect( ( await normalized.next() ).value ).toEqual( { foo: 'bar' } );
- expect( ( await normalized.next() ).value ).toEqual( { ok: true } );
- } );
-
- it( 'normalizes generator', async () => {
- function* getIterable() {
- yield Promise.resolve( { ok: true } );
- }
-
- const object = getIterable();
- const normalized = toAsyncIterable( object );
-
- expect( ( await normalized.next() ).value ).toEqual( { ok: true } );
- } );
-} );
diff --git a/packages/data/src/test/registry.js b/packages/data/src/test/registry.js
new file mode 100644
index 0000000000000..3506118467d75
--- /dev/null
+++ b/packages/data/src/test/registry.js
@@ -0,0 +1,457 @@
+/**
+ * External dependencies
+ */
+import { castArray } from 'lodash';
+
+/**
+ * Internal dependencies
+ */
+import { createRegistry } from '../registry';
+
+describe( 'createRegistry', () => {
+ let registry;
+
+ beforeEach( () => {
+ registry = createRegistry();
+ } );
+
+ describe( 'registerStore', () => {
+ it( 'should be shorthand for reducer, actions, selectors registration', () => {
+ const store = registry.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( registry.dispatch( 'butcher' ) ).toHaveProperty( 'startSale' );
+ expect( registry.select( 'butcher' ) ).toHaveProperty( 'getPrice' );
+ expect( registry.select( 'butcher' ).getPrice( 'chicken' ) ).toBe( 4 );
+ expect( registry.select( 'butcher' ).getPrice( 'ribs' ) ).toBe( 6 );
+ registry.dispatch( 'butcher' ).startSale( 'chicken' );
+ expect( registry.select( 'butcher' ).getPrice( 'chicken' ) ).toBe( 2 );
+ expect( registry.select( 'butcher' ).getPrice( 'ribs' ) ).toBe( 6 );
+ } );
+ } );
+
+ describe( 'registerReducer', () => {
+ it( 'Should append reducers to the state', () => {
+ const reducer1 = () => 'chicken';
+ const reducer2 = () => 'ribs';
+
+ const store = registry.registerReducer( 'red1', reducer1 );
+ expect( store.getState() ).toEqual( 'chicken' );
+
+ const store2 = registry.registerReducer( 'red2', reducer2 );
+ expect( store2.getState() ).toEqual( 'ribs' );
+ } );
+ } );
+
+ describe( 'registerResolvers', () => {
+ const unsubscribes = [];
+ afterEach( () => {
+ let unsubscribe;
+ while ( ( unsubscribe = unsubscribes.shift() ) ) {
+ unsubscribe();
+ }
+ } );
+
+ function subscribeWithUnsubscribe( ...args ) {
+ const unsubscribe = registry.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', () => {
+ registry.registerReducer( 'demo', ( state = 'OK' ) => state );
+ registry.registerSelectors( 'demo', {
+ getValue: ( state ) => state,
+ } );
+ registry.registerResolvers( 'demo', {} );
+
+ expect( registry.select( 'demo' ).getValue() ).toBe( 'OK' );
+ } );
+
+ it( 'should behave as a side effect for the given selector, with arguments', () => {
+ const resolver = jest.fn();
+
+ registry.registerReducer( 'demo', ( state = 'OK' ) => state );
+ registry.registerSelectors( 'demo', {
+ getValue: ( state ) => state,
+ } );
+ registry.registerResolvers( 'demo', {
+ getValue: resolver,
+ } );
+
+ const value = registry.select( 'demo' ).getValue( 'arg1', 'arg2' );
+ expect( value ).toBe( 'OK' );
+ expect( resolver ).toHaveBeenCalledWith( 'OK', 'arg1', 'arg2' );
+ registry.select( 'demo' ).getValue( 'arg1', 'arg2' );
+ expect( resolver ).toHaveBeenCalledTimes( 1 );
+ registry.select( 'demo' ).getValue( 'arg3', 'arg4' );
+ expect( resolver ).toHaveBeenCalledTimes( 2 );
+ } );
+
+ it( 'should support the object resolver definition', () => {
+ const resolver = jest.fn();
+
+ registry.registerReducer( 'demo', ( state = 'OK' ) => state );
+ registry.registerSelectors( 'demo', {
+ getValue: ( state ) => state,
+ } );
+ registry.registerResolvers( 'demo', {
+ getValue: { fulfill: resolver },
+ } );
+
+ const value = registry.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 = registry.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: [] } );
+
+ registry.registerSelectors( 'demo', {
+ getPage: ( state, page ) => state[ page ],
+ } );
+ registry.registerResolvers( 'demo', {
+ getPage: {
+ fulfill,
+ isFulfilled( state, page ) {
+ return state.hasOwnProperty( page );
+ },
+ },
+ } );
+
+ registry.select( 'demo' ).getPage( 1 );
+ registry.select( 'demo' ).getPage( 2 );
+
+ expect( fulfill ).toHaveBeenCalledTimes( 2 );
+
+ registry.select( 'demo' ).getPage( 1 );
+ registry.select( 'demo' ).getPage( 2 );
+ registry.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 );
+
+ registry.select( 'demo' ).getPage( 1 );
+ registry.select( 'demo' ).getPage( 2 );
+ registry.select( 'demo' ).getPage( 3, {} );
+ registry.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 );
+
+ registry.select( 'demo' ).getPage( 4, {} );
+ } );
+
+ it( 'should resolve 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: () => ( { type: 'SET_OK' } ),
+ } );
+
+ const promise = subscribeUntil( [
+ () => registry.select( 'demo' ).getValue() === 'OK',
+ () => registry.select( 'core/data' ).hasFinishedResolution( 'demo', 'getValue' ),
+ ] );
+
+ registry.select( 'demo' ).getValue();
+
+ return promise;
+ } );
+
+ it( 'should resolve mixed type action array 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: () => [
+ { type: 'INCREMENT' },
+ ( function* () {
+ yield { type: 'INCREMENT' };
+ }() ),
+ ],
+ } );
+
+ const promise = subscribeUntil( [
+ () => registry.select( 'counter' ).getCount() === 2,
+ () => registry.select( 'core/data' ).hasFinishedResolution( 'counter', 'getCount' ),
+ ] );
+
+ registry.select( 'counter' ).getCount();
+
+ return promise;
+ } );
+
+ it( 'should resolve generator 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() {
+ yield { type: 'SET_OK' };
+ },
+ } );
+
+ const promise = subscribeUntil( [
+ () => registry.select( 'demo' ).getValue() === 'OK',
+ () => registry.select( 'core/data' ).hasFinishedResolution( 'demo', 'getValue' ),
+ ] );
+
+ registry.select( 'demo' ).getValue();
+
+ return promise;
+ } );
+
+ it( 'should resolve promise non-action to dispatch', ( done ) => {
+ let shouldThrow = false;
+ registry.registerReducer( 'demo', ( state = 'OK' ) => {
+ if ( shouldThrow ) {
+ throw 'Should not have dispatched';
+ }
+
+ return state;
+ } );
+ shouldThrow = true;
+ registry.registerSelectors( 'demo', {
+ getValue: ( state ) => state,
+ } );
+ registry.registerResolvers( 'demo', {
+ getValue: () => Promise.resolve(),
+ } );
+
+ registry.select( 'demo' ).getValue();
+
+ process.nextTick( () => {
+ done();
+ } );
+ } );
+
+ it( 'should resolve sync 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: function* () {
+ yield { type: 'INCREMENT' };
+ const action = yield Promise.resolve( { type: 'INCREMENT' } );
+ yield action;
+ },
+ } );
+
+ const promise = subscribeUntil( [
+ () => registry.select( 'counter' ).getCount() === 2,
+ () => registry.select( 'core/data' ).hasFinishedResolution( 'counter', 'getCount' ),
+ ] );
+
+ registry.select( 'counter' ).getCount();
+
+ return promise;
+ } );
+
+ 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' } );
+ },
+ } );
+
+ const promise = subscribeUntil( [
+ () => registry.select( 'counter' ).getCount() === 2,
+ () => registry.select( 'core/data' ).hasFinishedResolution( 'counter', 'getCount' ),
+ ] );
+
+ registry.select( 'counter' ).getCount();
+ expect( console ).toHaveWarned();
+
+ return promise;
+ } );
+ } );
+
+ 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' );
+
+ registry.registerSelectors( 'reducer1', {
+ selector1,
+ selector2,
+ } );
+
+ expect( registry.select( 'reducer1' ).selector1() ).toEqual( 'result1' );
+ expect( selector1 ).toBeCalledWith( store.getState() );
+
+ expect( registry.select( 'reducer1' ).selector2() ).toEqual( 'result2' );
+ expect( selector2 ).toBeCalledWith( store.getState() );
+ } );
+ } );
+
+ describe( 'subscribe', () => {
+ const unsubscribes = [];
+ afterEach( () => {
+ let unsubscribe;
+ while ( ( unsubscribe = unsubscribes.shift() ) ) {
+ unsubscribe();
+ }
+ } );
+
+ function subscribeWithUnsubscribe( ...args ) {
+ const unsubscribe = registry.subscribe( ...args );
+ unsubscribes.push( unsubscribe );
+ return unsubscribe;
+ }
+
+ it( 'registers multiple selectors to the public API', () => {
+ let incrementedValue = null;
+ const store = registry.registerReducer( 'myAwesomeReducer', ( state = 0 ) => state + 1 );
+ registry.registerSelectors( 'myAwesomeReducer', {
+ globalSelector: ( state ) => state,
+ } );
+ const unsubscribe = registry.subscribe( () => {
+ incrementedValue = registry.select( 'myAwesomeReducer' ).globalSelector();
+ } );
+ const action = { type: 'dummy' };
+
+ store.dispatch( action ); // increment the data by => data = 2
+ expect( incrementedValue ).toBe( 2 );
+
+ store.dispatch( action ); // increment the data by => data = 3
+ expect( incrementedValue ).toBe( 3 );
+
+ unsubscribe(); // Store subscribe to changes, the data variable stops upgrading.
+
+ store.dispatch( action );
+ store.dispatch( action );
+
+ expect( incrementedValue ).toBe( 3 );
+ } );
+
+ it( 'snapshots listeners on change, avoiding a later listener if subscribed during earlier callback', () => {
+ const store = registry.registerReducer( 'myAwesomeReducer', ( state = 0 ) => state + 1 );
+ const secondListener = jest.fn();
+ const firstListener = jest.fn( () => {
+ subscribeWithUnsubscribe( secondListener );
+ } );
+
+ subscribeWithUnsubscribe( firstListener );
+
+ store.dispatch( { type: 'dummy' } );
+
+ expect( secondListener ).not.toHaveBeenCalled();
+ } );
+
+ it( 'snapshots listeners on change, calling a later listener even if unsubscribed during earlier callback', () => {
+ const store = registry.registerReducer( 'myAwesomeReducer', ( state = 0 ) => state + 1 );
+ const firstListener = jest.fn( () => {
+ secondUnsubscribe();
+ } );
+ const secondListener = jest.fn();
+
+ subscribeWithUnsubscribe( firstListener );
+ const secondUnsubscribe = subscribeWithUnsubscribe( secondListener );
+
+ store.dispatch( { type: 'dummy' } );
+
+ expect( secondListener ).toHaveBeenCalled();
+ } );
+
+ it( 'does not call listeners if state has not changed', () => {
+ const store = registry.registerReducer( 'unchanging', ( state = {} ) => state );
+ const listener = jest.fn();
+ subscribeWithUnsubscribe( listener );
+
+ store.dispatch( { type: 'dummy' } );
+
+ expect( listener ).not.toHaveBeenCalled();
+ } );
+ } );
+
+ describe( 'dispatch', () => {
+ it( 'registers actions to the public API', () => {
+ const store = registry.registerReducer( 'counter', ( state = 0, action ) => {
+ if ( action.type === 'increment' ) {
+ return state + action.count;
+ }
+ return state;
+ } );
+ const increment = ( count = 1 ) => ( { type: 'increment', count } );
+ registry.registerActions( 'counter', {
+ increment,
+ } );
+
+ registry.dispatch( 'counter' ).increment(); // state = 1
+ registry.dispatch( 'counter' ).increment( 4 ); // state = 5
+ expect( store.getState() ).toBe( 5 );
+ } );
+ } );
+} );
diff --git a/viewport/test/if-viewport-matches.js b/viewport/test/if-viewport-matches.js
index d1f2c1a247d49..c5eca13c0ea14 100644
--- a/viewport/test/if-viewport-matches.js
+++ b/viewport/test/if-viewport-matches.js
@@ -1,7 +1,7 @@
/**
* External dependencies
*/
-import { mount } from 'enzyme';
+import TestRenderer from 'react-test-renderer';
/**
* WordPress dependencies
@@ -20,20 +20,16 @@ describe( 'ifViewportMatches()', () => {
it( 'should not render if query does not match', () => {
dispatch( 'core/viewport' ).setIsMatching( { '> wide': false } );
const EnhancedComponent = ifViewportMatches( '> wide' )( Component );
- const wrapper = mount( );
+ const testRenderer = TestRenderer.create( );
- expect( wrapper.find( Component ) ).toHaveLength( 0 );
-
- wrapper.unmount();
+ expect( testRenderer.root.findAllByType( Component ) ).toHaveLength( 0 );
} );
it( 'should render if query does match', () => {
dispatch( 'core/viewport' ).setIsMatching( { '> wide': true } );
const EnhancedComponent = ifViewportMatches( '> wide' )( Component );
- const wrapper = mount( );
-
- expect( wrapper.find( Component ).childAt( 0 ).type() ).toBe( 'div' );
+ const testRenderer = TestRenderer.create( );
- wrapper.unmount();
+ expect( testRenderer.root.findAllByType( Component ) ).toHaveLength( 1 );
} );
} );
diff --git a/viewport/test/with-viewport-match.js b/viewport/test/with-viewport-match.js
index a2d17ded6e956..c941542d8aca3 100644
--- a/viewport/test/with-viewport-match.js
+++ b/viewport/test/with-viewport-match.js
@@ -1,7 +1,7 @@
/**
* External dependencies
*/
-import { mount } from 'enzyme';
+import TestRenderer from 'react-test-renderer';
/**
* WordPress dependencies
@@ -15,15 +15,13 @@ import '../store';
import withViewportMatch from '../with-viewport-match';
describe( 'withViewportMatch()', () => {
- const Component = () => Hello
;
+ const Component = ( props ) => { props.isWide ? 'true' : 'false' }
;
it( 'should render with result of query as custom prop name', () => {
dispatch( 'core/viewport' ).setIsMatching( { '> wide': true } );
const EnhancedComponent = withViewportMatch( { isWide: '> wide' } )( Component );
- const wrapper = mount( );
+ const testRenderer = TestRenderer.create( );
- expect( wrapper.find( Component ).props() ).toEqual( { isWide: true } );
-
- wrapper.unmount();
+ expect( testRenderer.root.findByType( 'div' ).props.children ).toBe( 'true' );
} );
} );