diff --git a/packages/data/CHANGELOG.md b/packages/data/CHANGELOG.md index 533101d02e8a8..391d68abe4c46 100644 --- a/packages/data/CHANGELOG.md +++ b/packages/data/CHANGELOG.md @@ -1,3 +1,13 @@ +## Master + +### New Feature + +- Expose `useSelect` hook for usage in functional components. ([#15737](https://github.com/WordPress/gutenberg/pull/15737)) + +### Enhancements + +- `withSelect` internally uses the new `useSelect` hook. ([#15737](https://github.com/WordPress/gutenberg/pull/15737). **Note:** This _could_ impact performance of code using `withSelect` in edge-cases. To avoid impact, memoize passed in `mapSelectToProps` callbacks or implement `useSelect` directly with dependencies. + ## 4.5.0 (2019-05-21) ### Bug Fix diff --git a/packages/data/README.md b/packages/data/README.md index 65b525d404099..8984e86b1c577 100644 --- a/packages/data/README.md +++ b/packages/data/README.md @@ -379,11 +379,43 @@ _Returns_ # **RegistryConsumer** -Undocumented declaration. +A custom react Context consumer exposing the provided `registry` to +children components. Used along with the RegistryProvider. + +You can read more about the react context api here: + + +_Usage_ + +````js +const { + RegistryProvider, + RegistryConsumer, + createRegistry +} = wp.data; + +const registry = createRegistry( {} ); + +const App = ( { props } ) => { + return +
Hello There
+ + { ( registry ) => ( + +
+} # **RegistryProvider** -Undocumented declaration. +A custom Context provider for exposing the provided `registry` to children +components via a consumer. + +See RegistryConsumer documentation for +example. # **select** @@ -391,13 +423,13 @@ Given the name of a registered store, returns an object of the store's selectors The selector functions are been pre-bound to pass the current state automatically. As a consumer, you need only pass arguments of the selector, if applicable. -_Usage_ +*Usage* ```js const { select } = wp.data; select( 'my-shop' ).getPrice( 'hammer' ); -``` +```` _Parameters_ @@ -435,6 +467,91 @@ _Parameters_ Undocumented declaration. +# **useRegistry** + +A custom react hook exposing the registry context for use. + +This exposes the `registry` value provided via the +Registry Provider to a component implementing +this hook. + +It acts similarly to the `useContext` react hook. + +Note: Generally speaking, `useRegistry` is a low level hook that in most cases +won't be needed for implementation. Most interactions with the wp.data api +can be performed via the `useSelect` hook, or the `withSelect` and +`withDispatch` higher order components. + +_Usage_ + +```js +const { + RegistryProvider, + createRegistry, + useRegistry, +} = wp.data + +const registry = createRegistry( {} ); + +const SomeChildUsingRegistry = ( props ) => { + const registry = useRegistry( registry ); + // ...logic implementing the registry in other react hooks. +}; + + +const ParentProvidingRegistry = ( props ) => { + return + + +}; +``` + +_Returns_ + +- `Function`: A custom react hook exposing the registry context value. + +# **useSelect** + +Custom react hook for retrieving props from registered selectors. + +In general, this custom React hook follows the +[rules of hooks](https://reactjs.org/docs/hooks-rules.html). + +_Usage_ + +```js +const { useSelect } = wp.data; + +function HammerPriceDisplay( { currency } ) { + const price = useSelect( ( select ) => { + return select( 'my-shop' ).getPrice( 'hammer', currency ) + }, [ currency ] ); + return new Intl.NumberFormat( 'en-US', { + style: 'currency', + currency, + } ).format( price ); +} + +// Rendered in the application: +// +``` + +In the above example, when `HammerPriceDisplay` is rendered into an +application, the price will be retrieved from the store state using the +`mapSelect` callback on `useSelect`. If the currency prop changes then +any price in the state for that currency is retrieved. If the currency prop +doesn't change and other props are passed in that do change, the price will +not change because the dependency is just the currency. + +_Parameters_ + +- _\_mapSelect_ `Function`: Function called on every state change. The returned value is exposed to the component implementing this hook. The function receives the `registry.select` method on the first argument and the `registry` on the second argument. +- _deps_ `Array`: If provided, this memoizes the mapSelect so the same `mapSelect` is invoked on every state change unless the dependencies change. + +_Returns_ + +- `Function`: A custom react hook. + # **withDispatch** Higher-order component used to add dispatch props using registered action creators. @@ -545,7 +662,10 @@ const HammerPriceDisplay = withSelect( ( select, ownProps ) => { // ``` -In the above example, when `HammerPriceDisplay` is rendered into an application, it will pass the price into the underlying `PriceDisplay` component and update automatically if the price of a hammer ever changes in the store. +In the above example, when `HammerPriceDisplay` is rendered into an +application, it will pass the price into the underlying `PriceDisplay` +component and update automatically if the price of a hammer ever changes in +the store. _Parameters_ diff --git a/packages/data/src/components/async-mode-provider/context.js b/packages/data/src/components/async-mode-provider/context.js new file mode 100644 index 0000000000000..c0e59ef713818 --- /dev/null +++ b/packages/data/src/components/async-mode-provider/context.js @@ -0,0 +1,12 @@ +/** + * WordPress dependencies + */ +import { createContext } from '@wordpress/element'; + +export const Context = createContext( false ); + +const { Consumer, Provider } = Context; + +export const AsyncModeConsumer = Consumer; + +export default Provider; diff --git a/packages/data/src/components/async-mode-provider/index.js b/packages/data/src/components/async-mode-provider/index.js index 30e877d0f1ed3..778dce8fc6eb2 100644 --- a/packages/data/src/components/async-mode-provider/index.js +++ b/packages/data/src/components/async-mode-provider/index.js @@ -1,10 +1,2 @@ -/** - * WordPress dependencies - */ -import { createContext } from '@wordpress/element'; - -const { Consumer, Provider } = createContext( false ); - -export const AsyncModeConsumer = Consumer; - -export default Provider; +export { default as useAsyncMode } from './use-async-mode'; +export { default as AsyncModeProvider, AsyncModeConsumer } from './context'; diff --git a/packages/data/src/components/async-mode-provider/use-async-mode.js b/packages/data/src/components/async-mode-provider/use-async-mode.js new file mode 100644 index 0000000000000..2f53c34ae2e07 --- /dev/null +++ b/packages/data/src/components/async-mode-provider/use-async-mode.js @@ -0,0 +1,13 @@ +/** + * WordPress dependencies + */ +import { useContext } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import { Context } from './context'; + +export default function useAsyncMode() { + return useContext( Context ); +} diff --git a/packages/data/src/components/registry-provider/context.js b/packages/data/src/components/registry-provider/context.js new file mode 100644 index 0000000000000..a1fe1a9e56183 --- /dev/null +++ b/packages/data/src/components/registry-provider/context.js @@ -0,0 +1,54 @@ +/** + * WordPress dependencies + */ +import { createContext } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import defaultRegistry from '../../default-registry'; + +export const Context = createContext( defaultRegistry ); + +const { Consumer, Provider } = Context; + +/** + * A custom react Context consumer exposing the provided `registry` to + * children components. Used along with the RegistryProvider. + * + * You can read more about the react context api here: + * https://reactjs.org/docs/context.html#contextprovider + * + * @example + * ```js + * const { + * RegistryProvider, + * RegistryConsumer, + * createRegistry + * } = wp.data; + * + * const registry = createRegistry( {} ); + * + * const App = ( { props } ) => { + * return + *
Hello There
+ * + * { ( registry ) => ( + * + *
+ * } + */ +export const RegistryConsumer = Consumer; + +/** + * A custom Context provider for exposing the provided `registry` to children + * components via a consumer. + * + * See RegistryConsumer documentation for + * example. + */ +export default Provider; diff --git a/packages/data/src/components/registry-provider/index.js b/packages/data/src/components/registry-provider/index.js index c296f46a1ab1a..e4f8d99bec597 100644 --- a/packages/data/src/components/registry-provider/index.js +++ b/packages/data/src/components/registry-provider/index.js @@ -1,15 +1,2 @@ -/** - * WordPress dependencies - */ -import { createContext } from '@wordpress/element'; - -/** - * Internal dependencies - */ -import defaultRegistry from '../../default-registry'; - -const { Consumer, Provider } = createContext( defaultRegistry ); - -export const RegistryConsumer = Consumer; - -export default Provider; +export { default as RegistryProvider, RegistryConsumer } from './context'; +export { default as useRegistry } from './use-registry'; diff --git a/packages/data/src/components/registry-provider/use-registry.js b/packages/data/src/components/registry-provider/use-registry.js new file mode 100644 index 0000000000000..846cb5627fd2f --- /dev/null +++ b/packages/data/src/components/registry-provider/use-registry.js @@ -0,0 +1,52 @@ +/** + * WordPress dependencies + */ +import { useContext } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import { Context } from './context'; + +/** + * A custom react hook exposing the registry context for use. + * + * This exposes the `registry` value provided via the + * Registry Provider to a component implementing + * this hook. + * + * It acts similarly to the `useContext` react hook. + * + * Note: Generally speaking, `useRegistry` is a low level hook that in most cases + * won't be needed for implementation. Most interactions with the wp.data api + * can be performed via the `useSelect` hook, or the `withSelect` and + * `withDispatch` higher order components. + * + * @example + * ```js + * const { + * RegistryProvider, + * createRegistry, + * useRegistry, + * } = wp.data + * + * const registry = createRegistry( {} ); + * + * const SomeChildUsingRegistry = ( props ) => { + * const registry = useRegistry( registry ); + * // ...logic implementing the registry in other react hooks. + * }; + * + * + * const ParentProvidingRegistry = ( props ) => { + * return + * + * + * }; + * ``` + * + * @return {Function} A custom react hook exposing the registry context value. + */ +export default function useRegistry() { + return useContext( Context ); +} diff --git a/packages/data/src/components/use-select/index.js b/packages/data/src/components/use-select/index.js new file mode 100644 index 0000000000000..d71f72b21c917 --- /dev/null +++ b/packages/data/src/components/use-select/index.js @@ -0,0 +1,167 @@ +/** + * WordPress dependencies + */ +import { createQueue } from '@wordpress/priority-queue'; +import { + useLayoutEffect, + useRef, + useMemo, + useCallback, + useEffect, + useReducer, +} from '@wordpress/element'; +import { isShallowEqualObjects } from '@wordpress/is-shallow-equal'; + +/** + * Internal dependencies + */ +import useRegistry from '../registry-provider/use-registry'; +import useAsyncMode from '../async-mode-provider/use-async-mode'; + +/** + * Favor useLayoutEffect to ensure the store subscription callback always has + * the selector from the latest render. If a store update happens between render + * and the effect, this could cause missed/stale updates or inconsistent state. + * + * Fallback to useEffect for server rendered components because currently React + * throws a warning when using useLayoutEffect in that environment. + */ +const useIsomorphicLayoutEffect = + typeof window !== 'undefined' ? useLayoutEffect : useEffect; + +const renderQueue = createQueue(); + +/** + * Custom react hook for retrieving props from registered selectors. + * + * In general, this custom React hook follows the + * [rules of hooks](https://reactjs.org/docs/hooks-rules.html). + * + * @param {Function} _mapSelect Function called on every state change. The + * returned value is exposed to the component + * implementing this hook. The function receives + * the `registry.select` method on the first + * argument and the `registry` on the second + * argument. + * @param {Array} deps If provided, this memoizes the mapSelect so the + * same `mapSelect` is invoked on every state + * change unless the dependencies change. + * + * @example + * ```js + * const { useSelect } = wp.data; + * + * function HammerPriceDisplay( { currency } ) { + * const price = useSelect( ( select ) => { + * return select( 'my-shop' ).getPrice( 'hammer', currency ) + * }, [ currency ] ); + * return new Intl.NumberFormat( 'en-US', { + * style: 'currency', + * currency, + * } ).format( price ); + * } + * + * // Rendered in the application: + * // + * ``` + * + * In the above example, when `HammerPriceDisplay` is rendered into an + * application, the price will be retrieved from the store state using the + * `mapSelect` callback on `useSelect`. If the currency prop changes then + * any price in the state for that currency is retrieved. If the currency prop + * doesn't change and other props are passed in that do change, the price will + * not change because the dependency is just the currency. + * + * @return {Function} A custom react hook. + */ +export default function useSelect( _mapSelect, deps ) { + const mapSelect = useCallback( _mapSelect, deps ); + const registry = useRegistry(); + const isAsync = useAsyncMode(); + const queueContext = useMemo( () => ( { queue: true } ), [ registry ] ); + const [ , forceRender ] = useReducer( ( s ) => s + 1, 0 ); + + const latestMapSelect = useRef(); + const latestIsAsync = useRef( isAsync ); + const latestMapOutput = useRef(); + const latestMapOutputError = useRef(); + const isMounted = useRef(); + + let mapOutput; + + try { + if ( + latestMapSelect.current !== mapSelect || + latestMapOutputError.current + ) { + mapOutput = mapSelect( registry.select, registry ); + } else { + mapOutput = latestMapOutput.current; + } + } catch ( error ) { + let errorMessage = `An error occurred while running 'mapSelect': ${ error.message }`; + + if ( latestMapOutputError.current ) { + errorMessage += `\nThe error may be correlated with this previous error:\n`; + errorMessage += `${ latestMapOutputError.current.stack }\n\n`; + errorMessage += 'Original stack trace:'; + + throw new Error( errorMessage ); + } + } + + useIsomorphicLayoutEffect( () => { + latestMapSelect.current = mapSelect; + if ( latestIsAsync.current !== isAsync ) { + latestIsAsync.current = isAsync; + renderQueue.flush( queueContext ); + } + latestMapOutput.current = mapOutput; + latestMapOutputError.current = undefined; + isMounted.current = true; + } ); + + useIsomorphicLayoutEffect( () => { + const onStoreChange = () => { + if ( isMounted.current ) { + try { + const newMapOutput = latestMapSelect.current( + registry.select, + registry + ); + if ( isShallowEqualObjects( latestMapOutput.current, newMapOutput ) ) { + return; + } + latestMapOutput.current = newMapOutput; + } catch ( error ) { + latestMapOutputError.current = error; + } + forceRender( {} ); + } + }; + + // catch any possible state changes during mount before the subscription + // could be set. + if ( latestIsAsync.current ) { + renderQueue.add( queueContext, onStoreChange ); + } else { + onStoreChange(); + } + + const unsubscribe = registry.subscribe( () => { + if ( latestIsAsync.current ) { + renderQueue.add( queueContext, onStoreChange ); + } else { + onStoreChange(); + } + } ); + + return () => { + isMounted.current = false; + unsubscribe(); + renderQueue.flush( queueContext ); + }; + }, [ registry ] ); + + return mapOutput; +} diff --git a/packages/data/src/components/use-select/test/index.js b/packages/data/src/components/use-select/test/index.js new file mode 100644 index 0000000000000..ce8d15f3aa070 --- /dev/null +++ b/packages/data/src/components/use-select/test/index.js @@ -0,0 +1,136 @@ +/** + * External dependencies + */ +import TestRenderer, { act } from 'react-test-renderer'; + +/** + * Internal dependencies + */ +import { createRegistry } from '../../../registry'; +import { RegistryProvider } from '../../registry-provider'; +import useSelect from '../index'; + +describe( 'useSelect', () => { + let registry; + beforeEach( () => { + registry = createRegistry(); + } ); + + const getTestComponent = ( mapSelectSpy, dependencyKey ) => ( props ) => { + const dependencies = props[ dependencyKey ]; + mapSelectSpy.mockImplementation( + ( select ) => ( { + results: select( 'testStore' ).testSelector( props.keyName ), + } ) + ); + const data = useSelect( mapSelectSpy, [ dependencies ] ); + return
{ data.results }
; + }; + + it( 'passes the relevant data to the component', () => { + registry.registerStore( 'testStore', { + reducer: () => ( { foo: 'bar' } ), + selectors: { + testSelector: ( state, key ) => state[ key ], + }, + } ); + const selectSpy = jest.fn(); + const TestComponent = jest.fn().mockImplementation( + getTestComponent( selectSpy, 'keyName' ) + ); + let renderer; + act( () => { + renderer = TestRenderer.create( + + + + ); + } ); + const testInstance = renderer.root; + // 2 times expected + // - 1 for initial mount + // - 1 for after mount before subscription set. + expect( selectSpy ).toHaveBeenCalledTimes( 2 ); + expect( TestComponent ).toHaveBeenCalledTimes( 1 ); + + // ensure expected state was rendered + expect( testInstance.findByType( 'div' ).props ).toEqual( { + children: 'bar', + } ); + } ); + + it( 'uses memoized selector if dependencies do not change', () => { + registry.registerStore( 'testStore', { + reducer: () => ( { foo: 'bar' } ), + selectors: { + testSelector: ( state, key ) => state[ key ], + }, + } ); + + const selectSpyFoo = jest.fn().mockImplementation( () => 'foo' ); + const selectSpyBar = jest.fn().mockImplementation( () => 'bar' ); + const TestComponent = jest.fn().mockImplementation( + ( props ) => { + const mapSelect = props.change ? selectSpyFoo : selectSpyBar; + const data = useSelect( mapSelect, [ props.keyName ] ); + return
{ data }
; + } + ); + let renderer; + act( () => { + renderer = TestRenderer.create( + + + + ); + } ); + const testInstance = renderer.root; + + expect( selectSpyFoo ).toHaveBeenCalledTimes( 2 ); + expect( selectSpyBar ).toHaveBeenCalledTimes( 0 ); + expect( TestComponent ).toHaveBeenCalledTimes( 1 ); + + // ensure expected state was rendered + expect( testInstance.findByType( 'div' ).props ).toEqual( { + children: 'foo', + } ); + + //rerender with non dependency changed + act( () => { + renderer.update( + + + + ); + } ); + + expect( selectSpyFoo ).toHaveBeenCalledTimes( 2 ); + expect( selectSpyBar ).toHaveBeenCalledTimes( 0 ); + expect( TestComponent ).toHaveBeenCalledTimes( 2 ); + + // ensure expected state was rendered + expect( testInstance.findByType( 'div' ).props ).toEqual( { + children: 'foo', + } ); + + // rerender with dependency changed + // rerender with non dependency changed + act( () => { + renderer.update( + + + + ); + } ); + + expect( selectSpyFoo ).toHaveBeenCalledTimes( 2 ); + expect( selectSpyBar ).toHaveBeenCalledTimes( 1 ); + expect( TestComponent ).toHaveBeenCalledTimes( 3 ); + + // ensure expected state was rendered + expect( testInstance.findByType( 'div' ).props ).toEqual( { + children: 'bar', + } ); + } ); +} ); + diff --git a/packages/data/src/components/with-dispatch/test/index.js b/packages/data/src/components/with-dispatch/test/index.js index 98f225fad2c40..4bde9b30810ac 100644 --- a/packages/data/src/components/with-dispatch/test/index.js +++ b/packages/data/src/components/with-dispatch/test/index.js @@ -8,7 +8,7 @@ import TestRenderer from 'react-test-renderer'; */ import withDispatch from '../'; import { createRegistry } from '../../../registry'; -import RegistryProvider from '../../registry-provider'; +import { RegistryProvider } from '../../registry-provider'; describe( 'withDispatch', () => { let registry; diff --git a/packages/data/src/components/with-select/index.js b/packages/data/src/components/with-select/index.js index d2fd4ae462b3b..f9659e7e865a8 100644 --- a/packages/data/src/components/with-select/index.js +++ b/packages/data/src/components/with-select/index.js @@ -1,18 +1,12 @@ /** * WordPress dependencies */ -import { Component } from '@wordpress/element'; -import { isShallowEqualObjects } from '@wordpress/is-shallow-equal'; -import { createHigherOrderComponent } from '@wordpress/compose'; -import { createQueue } from '@wordpress/priority-queue'; +import { createHigherOrderComponent, pure } from '@wordpress/compose'; /** * Internal dependencies */ -import { RegistryConsumer } from '../registry-provider'; -import { AsyncModeConsumer } from '../async-mode-provider'; - -const renderQueue = createQueue(); +import useSelect from '../use-select'; /** * Higher-order component used to inject state-derived props using registered @@ -46,163 +40,27 @@ const renderQueue = createQueue(); * // * // * ``` - * In the above example, when `HammerPriceDisplay` is rendered into an application, it will pass the price into the underlying `PriceDisplay` component and update automatically if the price of a hammer ever changes in the store. + * In the above example, when `HammerPriceDisplay` is rendered into an + * application, it will pass the price into the underlying `PriceDisplay` + * component and update automatically if the price of a hammer ever changes in + * the store. * * @return {Component} Enhanced component with merged state data props. */ -const withSelect = ( mapSelectToProps ) => createHigherOrderComponent( ( WrappedComponent ) => { - /** - * Default merge props. A constant value is used as the fallback since it - * can be more efficiently shallow compared in case component is repeatedly - * rendered without its own merge props. - * - * @type {Object} - */ - const DEFAULT_MERGE_PROPS = {}; - - /** - * Given a props object, returns the next merge props by mapSelectToProps. - * - * @param {Object} props Props to pass as argument to mapSelectToProps. - * - * @return {Object} Props to merge into rendered wrapped element. - */ - function getNextMergeProps( props ) { - return ( - mapSelectToProps( props.registry.select, props.ownProps, props.registry ) || - DEFAULT_MERGE_PROPS - ); - } - - class ComponentWithSelect extends Component { - constructor( props ) { - super( props ); - - this.onStoreChange = this.onStoreChange.bind( this ); - - this.subscribe( props.registry ); - - this.mergeProps = getNextMergeProps( props ); - } - - componentDidMount() { - this.canRunSelection = true; - - // A state change may have occurred between the constructor and - // mount of the component (e.g. during the wrapped component's own - // constructor), in which case selection should be rerun. - if ( this.hasQueuedSelection ) { - this.hasQueuedSelection = false; - this.onStoreChange(); - } - } - - componentWillUnmount() { - this.canRunSelection = false; - this.unsubscribe(); - renderQueue.flush( this ); - } - - shouldComponentUpdate( nextProps, nextState ) { - // Cycle subscription if registry changes. - const hasRegistryChanged = nextProps.registry !== this.props.registry; - const hasSyncRenderingChanged = nextProps.isAsync !== this.props.isAsync; - - if ( hasRegistryChanged ) { - this.unsubscribe(); - this.subscribe( nextProps.registry ); - } - - if ( hasSyncRenderingChanged ) { - renderQueue.flush( this ); - } - - // Treat a registry change as equivalent to `ownProps`, to reflect - // `mergeProps` to rendered component if and only if updated. - const hasPropsChanged = ( - hasRegistryChanged || - ! isShallowEqualObjects( this.props.ownProps, nextProps.ownProps ) - ); - - // Only render if props have changed or merge props have been updated - // from the store subscriber. - if ( this.state === nextState && ! hasPropsChanged && ! hasSyncRenderingChanged ) { - return false; - } - - if ( hasPropsChanged || hasSyncRenderingChanged ) { - const nextMergeProps = getNextMergeProps( nextProps ); - if ( ! isShallowEqualObjects( this.mergeProps, nextMergeProps ) ) { - // If merge props change as a result of the incoming props, - // they should be reflected as such in the upcoming render. - // While side effects are discouraged in lifecycle methods, - // this component is used heavily, and prior efforts to use - // `getDerivedStateFromProps` had demonstrated miserable - // performance. - this.mergeProps = nextMergeProps; - } - - // Regardless whether merge props are changing, fall through to - // incur the render since the component will need to receive - // the changed `ownProps`. - } - - return true; +const withSelect = ( mapSelectToProps ) => createHigherOrderComponent( + ( WrappedComponent ) => pure( + ( ownProps ) => { + const mapSelect = + ( select, registry ) => mapSelectToProps( + select, + ownProps, + registry + ); + const mergeProps = useSelect( mapSelect ); + return ; } - - onStoreChange() { - if ( ! this.canRunSelection ) { - this.hasQueuedSelection = true; - return; - } - - const nextMergeProps = getNextMergeProps( this.props ); - if ( isShallowEqualObjects( this.mergeProps, nextMergeProps ) ) { - return; - } - - this.mergeProps = nextMergeProps; - - // Schedule an update. Merge props are not assigned to state since - // derivation of merge props from incoming props occurs within - // shouldComponentUpdate, where setState is not allowed. setState - // is used here instead of forceUpdate because forceUpdate bypasses - // shouldComponentUpdate altogether, which isn't desireable if both - // state and props change within the same render. Unfortunately, - // this requires that next merge props are generated twice. - this.setState( {} ); - } - - subscribe( registry ) { - this.unsubscribe = registry.subscribe( () => { - if ( this.props.isAsync ) { - renderQueue.add( this, this.onStoreChange ); - } else { - this.onStoreChange(); - } - } ); - } - - render() { - return ; - } - } - - return ( ownProps ) => ( - - { ( isAsync ) => ( - - { ( registry ) => ( - - ) } - - ) } - - ); -}, 'withSelect' ); + ), + '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 index f42926e02266f..3b2049826c63b 100644 --- a/packages/data/src/components/with-select/test/index.js +++ b/packages/data/src/components/with-select/test/index.js @@ -1,7 +1,7 @@ /** * External dependencies */ -import TestRenderer from 'react-test-renderer'; +import TestRenderer, { act } from 'react-test-renderer'; /** * WordPress dependencies @@ -15,7 +15,7 @@ import { Component } from '@wordpress/element'; import withSelect from '../'; import withDispatch from '../../with-dispatch'; import { createRegistry } from '../../../registry'; -import RegistryProvider from '../../registry-provider'; +import { RegistryProvider } from '../../registry-provider'; describe( 'withSelect', () => { let registry; @@ -47,15 +47,19 @@ describe( 'withSelect', () => { ) ); const DataBoundComponent = withSelect( mapSelectToProps )( OriginalComponent ); - - const testRenderer = TestRenderer.create( - - - - ); + let testRenderer; + act( () => { + testRenderer = TestRenderer.create( + + + + ); + } ); const testInstance = testRenderer.root; - - expect( mapSelectToProps ).toHaveBeenCalledTimes( 1 ); + // Expected two times: + // - Once on initial render. + // - Once on effect before subscription set. + expect( mapSelectToProps ).toHaveBeenCalledTimes( 2 ); expect( OriginalComponent ).toHaveBeenCalledTimes( 1 ); // Wrapper is the enhanced component. Find props on the rendered child. @@ -100,31 +104,46 @@ describe( 'withSelect', () => { withDispatch( mapDispatchToProps ), ] )( OriginalComponent ); - const testRenderer = TestRenderer.create( - - - - ); + let testRenderer; + act( () => { + testRenderer = TestRenderer.create( + + + + ); + } ); const testInstance = testRenderer.root; expect( OriginalComponent ).toHaveBeenCalledTimes( 1 ); - expect( mapSelectToProps ).toHaveBeenCalledTimes( 1 ); + // 2 times: + // - 1 on initial render + // - 1 on effect before subscription set. + expect( mapSelectToProps ).toHaveBeenCalledTimes( 2 ); expect( mapDispatchToProps ).toHaveBeenCalledTimes( 1 ); // Simulate a click on the button - testInstance.findByType( 'button' ).props.onClick(); + act( () => { + testInstance.findByType( 'button' ).props.onClick(); + } ); expect( testInstance.findByType( 'button' ).props.children ).toBe( 1 ); // 2 times = // 1. Initial mount // 2. When click handler is called expect( mapDispatchToProps ).toHaveBeenCalledTimes( 2 ); - expect( mapSelectToProps ).toHaveBeenCalledTimes( 2 ); + // 4 times + // - 1 on initial render + // - 1 on effect before subscription set. + // - 1 on click triggering subscription firing. + // - 1 on rerender. + expect( mapSelectToProps ).toHaveBeenCalledTimes( 4 ); + // verifies component only renders twice. expect( OriginalComponent ).toHaveBeenCalledTimes( 2 ); } ); - it( 'should rerun if had dispatched action during mount', () => { - registry.registerStore( 'counter', { + describe( 'expected behaviour when dispatching actions during mount', () => { + const testRegistry = createRegistry(); + testRegistry.registerStore( 'counter', { reducer: ( state = 0, action ) => { if ( action.type === 'increment' ) { return state + 1; @@ -140,6 +159,10 @@ describe( 'withSelect', () => { }, } ); + // @todo, Should we allow this behaviour? Side-effects + // on mount are discouraged in React (breaks Suspense and React Async Mode) + // leaving in place for now under the assumption there's current usage + // of withSelect in GB that expects support. class OriginalComponent extends Component { constructor( props ) { super( ...arguments ); @@ -156,10 +179,10 @@ describe( 'withSelect', () => { } } - jest.spyOn( OriginalComponent.prototype, 'render' ); + const renderSpy = jest.spyOn( OriginalComponent.prototype, 'render' ); - const mapSelectToProps = jest.fn().mockImplementation( ( _select, ownProps ) => ( { - count: _select( 'counter' ).getCount( ownProps.offset ), + const mapSelectToProps = jest.fn().mockImplementation( ( _select ) => ( { + count: _select( 'counter' ).getCount(), } ) ); const mapDispatchToProps = jest.fn().mockImplementation( ( _dispatch ) => ( { @@ -171,16 +194,39 @@ describe( 'withSelect', () => { withDispatch( mapDispatchToProps ), ] )( OriginalComponent ); - const testRenderer = TestRenderer.create( - + let testRenderer, testInstance; + const createTestRenderer = () => TestRenderer.create( + ); - const testInstance = testRenderer.root; - - expect( testInstance.findByType( 'div' ).props.children ).toBe( 2 ); - expect( mapSelectToProps ).toHaveBeenCalledTimes( 2 ); - expect( OriginalComponent.prototype.render ).toHaveBeenCalledTimes( 2 ); + act( () => { + testRenderer = createTestRenderer(); + } ); + testInstance = testRenderer.root; + it( 'should rerun if had dispatched action during mount', () => { + expect( testInstance.findByType( 'div' ).props.children ).toBe( 2 ); + // Expected 3 times because: + // - 1 on initial render + // - 1 on effect before subscription set. + // - 1 for the rerender because of the mapOutput change detected. + expect( mapSelectToProps ).toHaveBeenCalledTimes( 3 ); + expect( renderSpy ).toHaveBeenCalledTimes( 2 ); + } ); + it( 'should rerun on unmount and mount', () => { + act( () => { + testRenderer.unmount(); + testRenderer = createTestRenderer(); + } ); + testInstance = testRenderer.root; + expect( testInstance.findByType( 'div' ).props.children ).toBe( 4 ); + // Expected an additional 3 times because of the unmount and remount: + // - 1 on initial render + // - 1 on effect before subscription set. + // - once for the rerender because of the mapOutput change detected. + expect( mapSelectToProps ).toHaveBeenCalledTimes( 6 ); + expect( renderSpy ).toHaveBeenCalledTimes( 4 ); + } ); } ); it( 'should rerun selection on props changes', () => { @@ -207,24 +253,32 @@ describe( 'withSelect', () => { const DataBoundComponent = withSelect( mapSelectToProps )( OriginalComponent ); - const testRenderer = TestRenderer.create( - - - - ); + let testRenderer; + act( () => { + testRenderer = TestRenderer.create( + + + + ); + } ); const testInstance = testRenderer.root; - expect( mapSelectToProps ).toHaveBeenCalledTimes( 1 ); + // 2 times: + // - 1 on initial render + // - 1 on effect before subscription set. + expect( mapSelectToProps ).toHaveBeenCalledTimes( 2 ); expect( OriginalComponent ).toHaveBeenCalledTimes( 1 ); - testRenderer.update( - - - - ); + act( () => { + testRenderer.update( + + + + ); + } ); expect( testInstance.findByType( 'div' ).props.children ).toBe( 10 ); - expect( mapSelectToProps ).toHaveBeenCalledTimes( 2 ); + expect( mapSelectToProps ).toHaveBeenCalledTimes( 3 ); expect( OriginalComponent ).toHaveBeenCalledTimes( 2 ); } ); @@ -246,26 +300,34 @@ describe( 'withSelect', () => { const Parent = ( props ) => ; - const testRenderer = TestRenderer.create( - - - - ); + let testRenderer; + act( () => { + testRenderer = TestRenderer.create( + + + + ); + } ); - expect( mapSelectToProps ).toHaveBeenCalledTimes( 1 ); + // 2 times: + // - 1 on initial render + // - 1 on effect before subscription set. + expect( mapSelectToProps ).toHaveBeenCalledTimes( 2 ); expect( OriginalComponent ).toHaveBeenCalledTimes( 1 ); - testRenderer.update( - - - - ); + act( () => { + testRenderer.update( + + + + ); + } ); - expect( mapSelectToProps ).toHaveBeenCalledTimes( 1 ); + expect( mapSelectToProps ).toHaveBeenCalledTimes( 2 ); expect( OriginalComponent ).toHaveBeenCalledTimes( 1 ); } ); - it( 'should not run selection if state has changed but merge props the same', () => { + it( 'should not rerender if state has changed but merge props the same', () => { registry.registerStore( 'demo', { reducer: () => ( {} ), selectors: { @@ -284,18 +346,23 @@ describe( 'withSelect', () => { const DataBoundComponent = withSelect( mapSelectToProps )( OriginalComponent ); - TestRenderer.create( - - - - ); + act( () => { + TestRenderer.create( + + + + ); + } ); - expect( mapSelectToProps ).toHaveBeenCalledTimes( 1 ); + // 2 times: + // - 1 on initial render + // - 1 on effect before subscription set. + expect( mapSelectToProps ).toHaveBeenCalledTimes( 2 ); expect( OriginalComponent ).toHaveBeenCalledTimes( 1 ); registry.dispatch( 'demo' ).update(); - expect( mapSelectToProps ).toHaveBeenCalledTimes( 2 ); + expect( mapSelectToProps ).toHaveBeenCalledTimes( 3 ); expect( OriginalComponent ).toHaveBeenCalledTimes( 1 ); } ); @@ -315,22 +382,30 @@ describe( 'withSelect', () => { withSelect( mapSelectToProps ), ] )( OriginalComponent ); - const testRenderer = TestRenderer.create( - - - - ); + let testRenderer; + act( () => { + testRenderer = TestRenderer.create( + + + + ); + } ); - expect( mapSelectToProps ).toHaveBeenCalledTimes( 1 ); + // 2 times: + // - 1 on initial render + // - 1 on effect before subscription set. + expect( mapSelectToProps ).toHaveBeenCalledTimes( 2 ); expect( OriginalComponent ).toHaveBeenCalledTimes( 1 ); - testRenderer.update( - - - - ); + act( () => { + testRenderer.update( + + + + ); + } ); - expect( mapSelectToProps ).toHaveBeenCalledTimes( 2 ); + expect( mapSelectToProps ).toHaveBeenCalledTimes( 3 ); expect( OriginalComponent ).toHaveBeenCalledTimes( 2 ); } ); @@ -350,18 +425,23 @@ describe( 'withSelect', () => { withSelect( mapSelectToProps ), ] )( OriginalComponent ); - TestRenderer.create( - - - - ); + act( () => { + TestRenderer.create( + + + + ); + } ); - expect( mapSelectToProps ).toHaveBeenCalledTimes( 1 ); + // 2 times: + // - 1 on initial render + // - 1 on effect before subscription set. + expect( mapSelectToProps ).toHaveBeenCalledTimes( 2 ); expect( OriginalComponent ).toHaveBeenCalledTimes( 1 ); store.dispatch( { type: 'dummy' } ); - expect( mapSelectToProps ).toHaveBeenCalledTimes( 1 ); + expect( mapSelectToProps ).toHaveBeenCalledTimes( 2 ); expect( OriginalComponent ).toHaveBeenCalledTimes( 1 ); } ); @@ -384,26 +464,34 @@ describe( 'withSelect', () => { const DataBoundComponent = withSelect( mapSelectToProps )( OriginalComponent ); - const testRenderer = TestRenderer.create( - - - - ); + let testRenderer; + act( () => { + testRenderer = TestRenderer.create( + + + + ); + } ); const testInstance = testRenderer.root; - expect( mapSelectToProps ).toHaveBeenCalledTimes( 1 ); + // 2 times: + // - 1 on initial render + // - 1 on effect before subscription set. + expect( mapSelectToProps ).toHaveBeenCalledTimes( 2 ); expect( OriginalComponent ).toHaveBeenCalledTimes( 1 ); expect( JSON.parse( testInstance.findByType( 'div' ).props.children ) ) .toEqual( { foo: 'OK', propName: 'foo' } ); - testRenderer.update( - - - - ); + act( () => { + testRenderer.update( + + + + ); + } ); - expect( mapSelectToProps ).toHaveBeenCalledTimes( 2 ); + expect( mapSelectToProps ).toHaveBeenCalledTimes( 3 ); expect( OriginalComponent ).toHaveBeenCalledTimes( 2 ); expect( JSON.parse( testInstance.findByType( 'div' ).props.children ) ) .toEqual( { bar: 'OK', propName: 'bar' } ); @@ -431,39 +519,49 @@ describe( 'withSelect', () => { const DataBoundComponent = withSelect( mapSelectToProps )( OriginalComponent ); - const testRenderer = TestRenderer.create( - - - - ); + let testRenderer; + act( () => { + testRenderer = TestRenderer.create( + + + + ); + } ); const testInstance = testRenderer.root; - expect( mapSelectToProps ).toHaveBeenCalledTimes( 1 ); + // 2 times: + // - 1 on initial render + // - 1 on effect before subscription set. + expect( mapSelectToProps ).toHaveBeenCalledTimes( 2 ); expect( OriginalComponent ).toHaveBeenCalledTimes( 1 ); expect( testInstance.findByType( 'div' ).props.children ).toBe( 'Unknown' ); - testRenderer.update( - - - - ); + act( () => { + testRenderer.update( + + + + ); + } ); - expect( mapSelectToProps ).toHaveBeenCalledTimes( 2 ); + expect( mapSelectToProps ).toHaveBeenCalledTimes( 3 ); expect( OriginalComponent ).toHaveBeenCalledTimes( 2 ); expect( testInstance.findByType( 'div' ).props.children ).toBe( 'OK' ); - testRenderer.update( - - - - ); + act( () => { + testRenderer.update( + + + + ); + } ); - expect( mapSelectToProps ).toHaveBeenCalledTimes( 3 ); + expect( mapSelectToProps ).toHaveBeenCalledTimes( 4 ); expect( OriginalComponent ).toHaveBeenCalledTimes( 3 ); expect( testInstance.findByType( 'div' ).props.children ).toBe( 'Unknown' ); } ); - it( 'should run selections on parents before its children', () => { + it( 'should limit unnecessary selections run on children', () => { registry.registerStore( 'childRender', { reducer: ( state = true, action ) => ( action.type === 'TOGGLE_RENDER' ? ! state : state @@ -477,9 +575,9 @@ describe( 'withSelect', () => { } ); const childMapSelectToProps = jest.fn(); - const parentMapSelectToProps = jest.fn().mockImplementation( ( _select ) => ( { - isRenderingChild: _select( 'childRender' ).getValue(), - } ) ); + const parentMapSelectToProps = jest.fn().mockImplementation( ( _select ) => ( + { isRenderingChild: _select( 'childRender' ).getValue() } + ) ); const ChildOriginalComponent = jest.fn().mockImplementation( () =>
); const ParentOriginalComponent = jest.fn().mockImplementation( ( props ) => ( @@ -489,21 +587,32 @@ describe( 'withSelect', () => { const Child = withSelect( childMapSelectToProps )( ChildOriginalComponent ); const Parent = withSelect( parentMapSelectToProps )( ParentOriginalComponent ); - TestRenderer.create( - - - - ); + act( () => { + TestRenderer.create( + + + + ); + } ); - expect( childMapSelectToProps ).toHaveBeenCalledTimes( 1 ); - expect( parentMapSelectToProps ).toHaveBeenCalledTimes( 1 ); + // 2 times: + // - 1 on initial render + // - 1 on effect before subscription set. + expect( childMapSelectToProps ).toHaveBeenCalledTimes( 2 ); + expect( parentMapSelectToProps ).toHaveBeenCalledTimes( 2 ); expect( ChildOriginalComponent ).toHaveBeenCalledTimes( 1 ); expect( ParentOriginalComponent ).toHaveBeenCalledTimes( 1 ); - registry.dispatch( 'childRender' ).toggleRender(); + act( () => { + registry.dispatch( 'childRender' ).toggleRender(); + } ); - expect( childMapSelectToProps ).toHaveBeenCalledTimes( 1 ); - expect( parentMapSelectToProps ).toHaveBeenCalledTimes( 2 ); + // 3 times because + // - 1 on initial render + // - 1 on effect before subscription set. + // - 1 child subscription fires. + expect( childMapSelectToProps ).toHaveBeenCalledTimes( 3 ); + expect( parentMapSelectToProps ).toHaveBeenCalledTimes( 4 ); expect( ChildOriginalComponent ).toHaveBeenCalledTimes( 1 ); expect( ParentOriginalComponent ).toHaveBeenCalledTimes( 2 ); } ); @@ -527,14 +636,20 @@ describe( 'withSelect', () => { const DataBoundComponent = withSelect( mapSelectToProps )( OriginalComponent ); - const testRenderer = TestRenderer.create( - - - - ); + let testRenderer; + act( () => { + testRenderer = TestRenderer.create( + + + + ); + } ); const testInstance = testRenderer.root; - expect( mapSelectToProps ).toHaveBeenCalledTimes( 1 ); + // 2 times: + // - 1 on initial render + // - 1 on effect before subscription set. + expect( mapSelectToProps ).toHaveBeenCalledTimes( 2 ); expect( OriginalComponent ).toHaveBeenCalledTimes( 1 ); expect( testInstance.findByType( 'div' ).props ).toEqual( { @@ -549,13 +664,19 @@ describe( 'withSelect', () => { }, } ); - testRenderer.update( - - - - ); - - expect( mapSelectToProps ).toHaveBeenCalledTimes( 2 ); + act( () => { + testRenderer.update( + + + + ); + } ); + // 4 times: + // - 1 on initial render + // - 1 on effect before subscription set. + // - 1 on re-render + // - 1 on effect before new subscription set (because registry has changed) + expect( mapSelectToProps ).toHaveBeenCalledTimes( 4 ); expect( OriginalComponent ).toHaveBeenCalledTimes( 2 ); expect( testInstance.findByType( 'div' ).props ).toEqual( { diff --git a/packages/data/src/index.js b/packages/data/src/index.js index c6b941d2cec95..46c830289b91d 100644 --- a/packages/data/src/index.js +++ b/packages/data/src/index.js @@ -12,8 +12,15 @@ import * as plugins from './plugins'; export { default as withSelect } from './components/with-select'; export { default as withDispatch } from './components/with-dispatch'; export { default as withRegistry } from './components/with-registry'; -export { default as RegistryProvider, RegistryConsumer } from './components/registry-provider'; -export { default as __experimentalAsyncModeProvider } from './components/async-mode-provider'; +export { + RegistryProvider, + RegistryConsumer, + useRegistry, +} from './components/registry-provider'; +export { default as useSelect } from './components/use-select'; +export { + AsyncModeProvider as __experimentalAsyncModeProvider, +} from './components/async-mode-provider'; export { createRegistry } from './registry'; export { plugins }; export { createRegistrySelector, createRegistryControl } from './factory'; diff --git a/packages/viewport/src/test/if-viewport-matches.js b/packages/viewport/src/test/if-viewport-matches.js index c5eca13c0ea14..4a57039e9c7f1 100644 --- a/packages/viewport/src/test/if-viewport-matches.js +++ b/packages/viewport/src/test/if-viewport-matches.js @@ -1,7 +1,7 @@ /** * External dependencies */ -import TestRenderer from 'react-test-renderer'; +import TestRenderer, { act } from 'react-test-renderer'; /** * WordPress dependencies @@ -20,16 +20,24 @@ describe( 'ifViewportMatches()', () => { it( 'should not render if query does not match', () => { dispatch( 'core/viewport' ).setIsMatching( { '> wide': false } ); const EnhancedComponent = ifViewportMatches( '> wide' )( Component ); - const testRenderer = TestRenderer.create( ); + + let testRenderer; + act( () => { + testRenderer = TestRenderer.create( ); + } ); expect( testRenderer.root.findAllByType( Component ) ).toHaveLength( 0 ); } ); it( 'should render if query does match', () => { - dispatch( 'core/viewport' ).setIsMatching( { '> wide': true } ); + act( () => { + dispatch( 'core/viewport' ).setIsMatching( { '> wide': true } ); + } ); const EnhancedComponent = ifViewportMatches( '> wide' )( Component ); - const testRenderer = TestRenderer.create( ); - + let testRenderer; + act( () => { + testRenderer = TestRenderer.create( ); + } ); expect( testRenderer.root.findAllByType( Component ) ).toHaveLength( 1 ); } ); } );