diff --git a/package-lock.json b/package-lock.json index c53e26c39031ce..3f455fbd437a70 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16041,6 +16041,7 @@ "equivalent-key-map": "^0.2.2", "is-promise": "^4.0.0", "lodash": "^4.17.21", + "memize": "^1.1.0", "redux": "^4.1.2", "turbo-combine-reducers": "^1.0.2", "use-memo-one": "^1.1.1" diff --git a/packages/data/README.md b/packages/data/README.md index 6917e2c3a86978..50651bce9b70f6 100644 --- a/packages/data/README.md +++ b/packages/data/README.md @@ -723,6 +723,62 @@ _Returns_ - `Function`: A custom react hook. +### useQuerySelect + +Like useSelect, but the selectors return objects containing +both the original data AND the resolution info. + +_Related_ + +- useSelect + +_Usage_ + +```js +import { useQuerySelect } from '@wordpress/data'; +import { store as coreDataStore } from '@wordpress/core-data'; + +function PageTitleDisplay( { id } ) { + const { data: page, isResolving } = useQuerySelect( + ( query ) => { + return query( coreDataStore ).getEntityRecord( + 'postType', + 'page', + id + ); + }, + [ id ] + ); + + if ( isResolving ) { + return 'Loading...'; + } + + return page.title; +} + +// Rendered in the application: +// +``` + +In the above example, when `PageTitleDisplay` is rendered into an +application, the page and the resolution details will be retrieved from +the store state using the `mapSelect` callback on `useQuerySelect`. + +If the id prop changes then any page in the state for that id is +retrieved. If the id prop doesn't change and other props are passed in +that do change, the title will not change because the dependency is just +the id. + +_Parameters_ + +- _mapQuerySelect_ `Function`: see useSelect +- _deps_ `Array`: see useSelect + +_Returns_ + +- `QuerySelectResponse`: Queried data. + ### useRegistry A custom react hook exposing the registry context for use. diff --git a/packages/data/package.json b/packages/data/package.json index b0f2dc358549b7..63b2ee03fdc365 100644 --- a/packages/data/package.json +++ b/packages/data/package.json @@ -37,6 +37,7 @@ "equivalent-key-map": "^0.2.2", "is-promise": "^4.0.0", "lodash": "^4.17.21", + "memize": "^1.1.0", "redux": "^4.1.2", "turbo-combine-reducers": "^1.0.2", "use-memo-one": "^1.1.1" diff --git a/packages/data/src/components/use-query-select/index.ts b/packages/data/src/components/use-query-select/index.ts new file mode 100644 index 00000000000000..5179b16fa09f20 --- /dev/null +++ b/packages/data/src/components/use-query-select/index.ts @@ -0,0 +1,107 @@ +/** + * Internal dependencies + */ +import useSelect from '../use-select'; +import { META_SELECTORS } from '../../store'; +import memoize from './memoize'; + +interface QuerySelectResponse { + /** the requested selector return value */ + data: Object; + + /** is the record still being resolved? Via the `getIsResolving` meta-selector */ + isResolving: boolean; + + /** was the resolution started? Via the `hasStartedResolution` meta-selector */ + hasStarted: boolean; + + /** has the resolution finished? Via the `hasFinishedResolution` meta-selector. */ + hasResolved: boolean; +} + +/** + * Like useSelect, but the selectors return objects containing + * both the original data AND the resolution info. + * + * @param {Function} mapQuerySelect see useSelect + * @param {Array} deps see useSelect + * + * @example + * ```js + * import { useQuerySelect } from '@wordpress/data'; + * import { store as coreDataStore } from '@wordpress/core-data'; + * + * function PageTitleDisplay( { id } ) { + * const { data: page, isResolving } = useQuerySelect( ( query ) => { + * return query( coreDataStore ).getEntityRecord( 'postType', 'page', id ) + * }, [ id ] ); + * + * if ( isResolving ) { + * return 'Loading...'; + * } + * + * return page.title; + * } + * + * // Rendered in the application: + * // + * ``` + * + * In the above example, when `PageTitleDisplay` is rendered into an + * application, the page and the resolution details will be retrieved from + * the store state using the `mapSelect` callback on `useQuerySelect`. + * + * If the id prop changes then any page in the state for that id is + * retrieved. If the id prop doesn't change and other props are passed in + * that do change, the title will not change because the dependency is just + * the id. + * @see useSelect + * + * @return {QuerySelectResponse} Queried data. + */ +export default function useQuerySelect( mapQuerySelect, deps ) { + return useSelect( ( select, registry ) => { + const resolve = ( store ) => enrichSelectors( select( store ) ); + return mapQuerySelect( resolve, registry ); + }, deps ); +} + +type QuerySelector = ( ...args ) => QuerySelectResponse; +interface EnrichedSelectors { + [ key: string ]: QuerySelector; +} + +/** + * Transform simple selectors into ones that return an object with the + * original return value AND the resolution info. + * + * @param {Object} selectors Selectors to enrich + * @return {EnrichedSelectors} Enriched selectors + */ +const enrichSelectors = memoize( ( selectors ) => { + const resolvers = {}; + for ( const selectorName in selectors ) { + if ( META_SELECTORS.includes( selectorName ) ) { + continue; + } + Object.defineProperty( resolvers, selectorName, { + get: () => ( ...args ) => { + const { + getIsResolving, + hasStartedResolution, + hasFinishedResolution, + } = selectors; + const isResolving = !! getIsResolving( selectorName, args ); + return { + data: selectors[ selectorName ]( ...args ), + isResolving, + hasStarted: hasStartedResolution( selectorName, args ), + hasResolved: + ! isResolving && + hasFinishedResolution( selectorName, args ), + }; + }, + } ); + } + return resolvers; +} ); diff --git a/packages/data/src/components/use-query-select/memoize.js b/packages/data/src/components/use-query-select/memoize.js new file mode 100644 index 00000000000000..d5d819dc5d772b --- /dev/null +++ b/packages/data/src/components/use-query-select/memoize.js @@ -0,0 +1,7 @@ +/** + * External dependencies + */ +import memoize from 'memize'; + +// re-export due to restrictive esModuleInterop setting +export default memoize; diff --git a/packages/data/src/components/use-query-select/test/use-query-select.js b/packages/data/src/components/use-query-select/test/use-query-select.js new file mode 100644 index 00000000000000..179b44cc243d00 --- /dev/null +++ b/packages/data/src/components/use-query-select/test/use-query-select.js @@ -0,0 +1,189 @@ +/** + * WordPress dependencies + */ +import { createReduxStore } from '@wordpress/data'; + +/** + * External dependencies + */ +import TestRenderer, { act } from 'react-test-renderer'; + +/** + * Internal dependencies + */ +import { createRegistry } from '../../../registry'; +import { RegistryProvider } from '../../registry-provider'; +import useQuerySelect from '../index'; + +describe( 'useQuerySelect', () => { + let registry; + beforeEach( () => { + jest.useFakeTimers(); + + registry = createRegistry(); + registry.registerStore( 'testStore', { + reducer: () => ( { foo: 'bar' } ), + selectors: { + getFoo: ( state ) => state.foo, + testSelector: ( state, key ) => state[ key ], + }, + } ); + } ); + + afterEach( () => { + jest.runOnlyPendingTimers(); + jest.useRealTimers(); + } ); + + const getTestComponent = ( mapSelectSpy, dependencyKey ) => ( props ) => { + const dependencies = props[ dependencyKey ]; + mapSelectSpy.mockImplementation( ( select ) => ( { + results: select( 'testStore' ).testSelector( props.keyName ), + } ) ); + const data = useQuerySelect( mapSelectSpy, [ dependencies ] ); + return
{ data.results.data }
; + }; + + const actRender = ( component ) => { + let renderer; + act( () => { + renderer = TestRenderer.create( + + { component } + + ); + } ); + return renderer; + }; + + it( 'passes the relevant data to the component', () => { + const selectSpy = jest.fn(); + const TestComponent = jest + .fn() + .mockImplementation( getTestComponent( selectSpy, 'keyName' ) ); + const renderer = actRender( ); + 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( 2 ); + + // ensure expected state was rendered + expect( testInstance.findByType( 'div' ).props ).toEqual( { + children: 'bar', + } ); + } ); + + it( 'uses memoized selectors', () => { + const selectors = []; + const TestComponent = jest.fn().mockImplementation( ( props ) => { + useQuerySelect( + function ( query ) { + selectors.push( query( 'testStore' ) ); + selectors.push( query( 'testStore' ) ); + return null; + }, + [ props.keyName ] + ); + return
; + } ); + actRender( ); + + // ensure the selectors were properly memoized + expect( selectors ).toHaveLength( 4 ); + expect( selectors[ 0 ] ).toHaveProperty( 'testSelector' ); + expect( selectors[ 0 ] ).toBe( selectors[ 1 ] ); + expect( selectors[ 1 ] ).toBe( selectors[ 2 ] ); + + // Re-render + actRender( ); + + // ensure we still got the memoized results after re-rendering + expect( selectors ).toHaveLength( 8 ); + expect( selectors[ 3 ] ).toHaveProperty( 'testSelector' ); + expect( selectors[ 5 ] ).toBe( selectors[ 6 ] ); + } ); + + it( 'returns the expected "response" details – no resolvers and arguments', () => { + let querySelectData; + const TestComponent = jest.fn().mockImplementation( () => { + querySelectData = useQuerySelect( function ( query ) { + return query( 'testStore' ).getFoo(); + }, [] ); + return
; + } ); + + actRender( ); + + expect( querySelectData ).toEqual( { + data: 'bar', + isResolving: false, + hasStarted: false, + hasResolved: false, + } ); + } ); + + it( 'returns the expected "response" details – resolvers and arguments', async () => { + registry.register( + createReduxStore( 'resolverStore', { + __experimentalUseThunks: true, + reducer: ( state = { resolvedFoo: 0 }, action ) => { + if ( action?.type === 'RECEIVE_FOO' ) { + return { ...state, resolvedFoo: action.value }; + } + return state; + }, + actions: { + receiveFoo: ( value ) => ( { + type: 'RECEIVE_FOO', + value, + } ), + }, + resolvers: { + getResolvedFoo: () => ( { dispatch } ) => + dispatch.receiveFoo( 5 ), + }, + selectors: { + getResolvedFoo: ( state, arg ) => state.resolvedFoo + arg, + }, + } ) + ); + + let querySelectData; + const TestComponent = jest.fn().mockImplementation( () => { + querySelectData = useQuerySelect( function ( query ) { + return query( 'resolverStore' ).getResolvedFoo( 10 ); + }, [] ); + return
; + } ); + + // Initial render, expect default values + act( () => { + TestRenderer.create( + + + + ); + } ); + expect( querySelectData ).toEqual( { + data: 10, + isResolving: false, + hasStarted: false, + hasResolved: false, + } ); + + await act( async () => { + jest.advanceTimersToNextTimer(); + } ); + + // Re-render, expect resolved data + actRender( ); + expect( querySelectData ).toEqual( { + data: 15, + isResolving: false, + hasStarted: true, + hasResolved: true, + } ); + } ); +} ); diff --git a/packages/data/src/index.js b/packages/data/src/index.js index 0dc98955f212c2..26404b21e7cf7b 100644 --- a/packages/data/src/index.js +++ b/packages/data/src/index.js @@ -20,6 +20,7 @@ export { useRegistry, } from './components/registry-provider'; export { default as useSelect } from './components/use-select'; +export { default as useQuerySelect } from './components/use-query-select'; export { useDispatch } from './components/use-dispatch'; export { AsyncModeProvider } from './components/async-mode-provider'; export { createRegistry } from './registry'; diff --git a/packages/data/src/store/index.js b/packages/data/src/store/index.js index 27fd40ab052b34..22f3535bab25f0 100644 --- a/packages/data/src/store/index.js +++ b/packages/data/src/store/index.js @@ -1,3 +1,19 @@ +export const META_SELECTORS = [ + 'getIsResolving', + 'hasStartedResolution', + 'hasFinishedResolution', + 'isResolving', + 'getCachedResolvers', +]; + +const META_ACTIONS = [ + 'startResolution', + 'finishResolution', + 'invalidateResolution', + 'invalidateResolutionForStore', + 'invalidateResolutionForStoreSelector', +]; + const coreDataStore = { name: 'core/data', instantiate( registry ) { @@ -12,13 +28,7 @@ const coreDataStore = { return { getSelectors() { return Object.fromEntries( - [ - 'getIsResolving', - 'hasStartedResolution', - 'hasFinishedResolution', - 'isResolving', - 'getCachedResolvers', - ].map( ( selectorName ) => [ + META_SELECTORS.map( ( selectorName ) => [ selectorName, getCoreDataSelector( selectorName ), ] ) @@ -27,13 +37,7 @@ const coreDataStore = { getActions() { return Object.fromEntries( - [ - 'startResolution', - 'finishResolution', - 'invalidateResolution', - 'invalidateResolutionForStore', - 'invalidateResolutionForStoreSelector', - ].map( ( actionName ) => [ + META_ACTIONS.map( ( actionName ) => [ actionName, getCoreDataAction( actionName ), ] )