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 ),
] )