-
Notifications
You must be signed in to change notification settings - Fork 4.7k
Add useQuerySelect to core/data #38134
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
5482ded
a621e55
0a66bf7
e4c87cf
cc9dc24
e4c07c1
8557c8d
bd428cc
ab9653b
1d06cf5
9f5f8b4
5ae67d7
4b51ec9
59b8716
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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; | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Since this is a lower-level API, I'd imagine it to include |
||
|
|
||
| /** was the resolution started? Via the `hasStartedResolution` meta-selector */ | ||
| hasStarted: boolean; | ||
|
|
||
| /** has the resolution finished? Via the `hasFinishedResolution` meta-selector. */ | ||
| hasResolved: boolean; | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. As mentioned in #38522 (comment), I'm not sure what's the difference between this and |
||
| } | ||
|
|
||
| /** | ||
| * 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: | ||
| * // <PageTitleDisplay id={ 10 } /> | ||
| * ``` | ||
| * | ||
| * 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 ); | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. IMO, adding const latestMapQuerySelectRef = useLatestRef( mapQuerySelect );
return useSelect( ( select, registry ) => {
const resolve = ( store ) => enrichSelectors( select( store ) );
return latestMapQuerySelectRef.current( resolve, registry );
}, [] );
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Wait, that's just a proxy to |
||
| } | ||
|
|
||
| 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, { | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Any reason why we're using
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It's because we're defining lazy getters that won't be evaluated until they're called for the first time. |
||
| 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; | ||
| } ); | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,7 @@ | ||
| /** | ||
| * External dependencies | ||
| */ | ||
| import memoize from 'memize'; | ||
|
|
||
| // re-export due to restrictive esModuleInterop setting | ||
| export default memoize; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 <div>{ data.results.data }</div>; | ||
| }; | ||
|
|
||
| const actRender = ( component ) => { | ||
| let renderer; | ||
| act( () => { | ||
| renderer = TestRenderer.create( | ||
| <RegistryProvider value={ registry }> | ||
| { component } | ||
| </RegistryProvider> | ||
| ); | ||
| } ); | ||
| 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( <TestComponent keyName="foo" /> ); | ||
| 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 <div />; | ||
| } ); | ||
| actRender( <TestComponent keyName="foo" /> ); | ||
|
|
||
| // 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( <TestComponent keyName="bar" /> ); | ||
|
|
||
| // 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 <div />; | ||
| } ); | ||
|
|
||
| actRender( <TestComponent /> ); | ||
|
|
||
| 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 <div />; | ||
| } ); | ||
|
|
||
| // Initial render, expect default values | ||
| act( () => { | ||
| TestRenderer.create( | ||
| <RegistryProvider value={ registry }> | ||
| <TestComponent /> | ||
| </RegistryProvider> | ||
| ); | ||
| } ); | ||
| expect( querySelectData ).toEqual( { | ||
| data: 10, | ||
| isResolving: false, | ||
| hasStarted: false, | ||
| hasResolved: false, | ||
| } ); | ||
|
|
||
| await act( async () => { | ||
| jest.advanceTimersToNextTimer(); | ||
| } ); | ||
|
|
||
| // Re-render, expect resolved data | ||
| actRender( <TestComponent /> ); | ||
| expect( querySelectData ).toEqual( { | ||
| data: 15, | ||
| isResolving: false, | ||
| hasStarted: true, | ||
| hasResolved: true, | ||
| } ); | ||
| } ); | ||
| } ); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Do we want to change this to generic too?