Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

56 changes: 56 additions & 0 deletions packages/data/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
// <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.

_Parameters_

- _mapQuerySelect_ `Function`: see useSelect
- _deps_ `Array`: see useSelect

_Returns_

- `QuerySelectResponse`: Queried data.

### useRegistry

A custom react hook exposing the registry context for use.
Expand Down
1 change: 1 addition & 0 deletions packages/data/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
107 changes: 107 additions & 0 deletions packages/data/src/components/use-query-select/index.ts
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;
Copy link
Member

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?


/** is the record still being resolved? Via the `getIsResolving` meta-selector */
isResolving: boolean;
Copy link
Member

Choose a reason for hiding this comment

The 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 status as well.


/** was the resolution started? Via the `hasStartedResolution` meta-selector */
hasStarted: boolean;

/** has the resolution finished? Via the `hasFinishedResolution` meta-selector. */
hasResolved: boolean;
Copy link
Member

Choose a reason for hiding this comment

The 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 !!data? We can discuss this in the original thread though.

}

/**
* 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 );
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

IMO, adding deps to a custom hook is a bad idea. I wonder if we could somehow discourage it in this API by always returning the latest mapQuerySelect possible? Something like:

const latestMapQuerySelectRef = useLatestRef( mapQuerySelect );

return useSelect( ( select, registry ) => {
	const resolve = ( store ) => enrichSelectors( select( store ) );
	return latestMapQuerySelectRef.current( resolve, registry );
}, [] );

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wait, that's just a proxy to useSelect, what's wrong with having 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, {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Any reason why we're using Object.defineProperty rather than simply mutating the object? I guess TS won't generate the correct typings for this?

Copy link
Contributor Author

Choose a reason for hiding this comment

The 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;
} );
7 changes: 7 additions & 0 deletions packages/data/src/components/use-query-select/memoize.js
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,
} );
} );
} );
1 change: 1 addition & 0 deletions packages/data/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
Loading