@wordpress/data
Version:
Data module for WordPress.
356 lines (315 loc) • 12.4 kB
JavaScript
/**
* WordPress dependencies
*/
import { createQueue } from '@wordpress/priority-queue';
import {
useRef,
useCallback,
useMemo,
useSyncExternalStore,
useDebugValue,
} from '@wordpress/element';
import isShallowEqual from '@wordpress/is-shallow-equal';
/**
* Internal dependencies
*/
import useRegistry from '../registry-provider/use-registry';
import useAsyncMode from '../async-mode-provider/use-async-mode';
const renderQueue = createQueue();
function warnOnUnstableReference( a, b ) {
if ( ! a || ! b ) {
return;
}
const keys =
typeof a === 'object' && typeof b === 'object'
? Object.keys( a ).filter( ( k ) => a[ k ] !== b[ k ] )
: [];
// eslint-disable-next-line no-console
console.warn(
'The `useSelect` hook returns different values when called with the same state and parameters.\n' +
'This can lead to unnecessary re-renders and performance issues if not fixed.\n\n' +
'Non-equal value keys: %s\n\n',
keys.join( ', ' )
);
}
/**
* @typedef {import('../../types').StoreDescriptor<C>} StoreDescriptor
* @template {import('../../types').AnyConfig} C
*/
/**
* @typedef {import('../../types').ReduxStoreConfig<State,Actions,Selectors>} ReduxStoreConfig
* @template State
* @template {Record<string,import('../../types').ActionCreator>} Actions
* @template Selectors
*/
/** @typedef {import('../../types').MapSelect} MapSelect */
/**
* @typedef {import('../../types').UseSelectReturn<T>} UseSelectReturn
* @template {MapSelect|StoreDescriptor<any>} T
*/
function Store( registry, suspense ) {
const select = suspense ? registry.suspendSelect : registry.select;
const queueContext = {};
let lastMapSelect;
let lastMapResult;
let lastMapResultValid = false;
let lastIsAsync;
let subscriber;
let didWarnUnstableReference;
const storeStatesOnMount = new Map();
function getStoreState( name ) {
// If there's no store property (custom generic store), return an empty
// object. When comparing the state, the empty objects will cause the
// equality check to fail, setting `lastMapResultValid` to false.
return registry.stores[ name ]?.store?.getState?.() ?? {};
}
const createSubscriber = ( stores ) => {
// The set of stores the `subscribe` function is supposed to subscribe to. Here it is
// initialized, and then the `updateStores` function can add new stores to it.
const activeStores = [ ...stores ];
// The `subscribe` function, which is passed to the `useSyncExternalStore` hook, could
// be called multiple times to establish multiple subscriptions. That's why we need to
// keep a set of active subscriptions;
const activeSubscriptions = new Set();
function subscribe( listener ) {
// Maybe invalidate the value right after subscription was created.
// React will call `getValue` after subscribing, to detect store
// updates that happened in the interval between the `getValue` call
// during render and creating the subscription, which is slightly
// delayed. We need to ensure that this second `getValue` call will
// compute a fresh value only if any of the store states have
// changed in the meantime.
if ( lastMapResultValid ) {
for ( const name of activeStores ) {
if (
storeStatesOnMount.get( name ) !== getStoreState( name )
) {
lastMapResultValid = false;
}
}
}
storeStatesOnMount.clear();
const onStoreChange = () => {
// Invalidate the value on store update, so that a fresh value is computed.
lastMapResultValid = false;
listener();
};
const onChange = () => {
if ( lastIsAsync ) {
renderQueue.add( queueContext, onStoreChange );
} else {
onStoreChange();
}
};
const unsubs = [];
function subscribeStore( storeName ) {
unsubs.push( registry.subscribe( onChange, storeName ) );
}
for ( const storeName of activeStores ) {
subscribeStore( storeName );
}
activeSubscriptions.add( subscribeStore );
return () => {
activeSubscriptions.delete( subscribeStore );
for ( const unsub of unsubs.values() ) {
// The return value of the subscribe function could be undefined if the store is a custom generic store.
unsub?.();
}
// Cancel existing store updates that were already scheduled.
renderQueue.cancel( queueContext );
};
}
// Check if `newStores` contains some stores we're not subscribed to yet, and add them.
function updateStores( newStores ) {
for ( const newStore of newStores ) {
if ( activeStores.includes( newStore ) ) {
continue;
}
// New `subscribe` calls will subscribe to `newStore`, too.
activeStores.push( newStore );
// Add `newStore` to existing subscriptions.
for ( const subscription of activeSubscriptions ) {
subscription( newStore );
}
}
}
return { subscribe, updateStores };
};
return ( mapSelect, isAsync ) => {
function updateValue() {
// If the last value is valid, and the `mapSelect` callback hasn't changed,
// then we can safely return the cached value. The value can change only on
// store update, and in that case value will be invalidated by the listener.
if ( lastMapResultValid && mapSelect === lastMapSelect ) {
return lastMapResult;
}
const listeningStores = { current: null };
const mapResult = registry.__unstableMarkListeningStores(
() => mapSelect( select, registry ),
listeningStores
);
if ( globalThis.SCRIPT_DEBUG ) {
if ( ! didWarnUnstableReference ) {
const secondMapResult = mapSelect( select, registry );
if ( ! isShallowEqual( mapResult, secondMapResult ) ) {
warnOnUnstableReference( mapResult, secondMapResult );
didWarnUnstableReference = true;
}
}
}
if ( ! subscriber ) {
for ( const name of listeningStores.current ) {
storeStatesOnMount.set( name, getStoreState( name ) );
}
subscriber = createSubscriber( listeningStores.current );
} else {
subscriber.updateStores( listeningStores.current );
}
// If the new value is shallow-equal to the old one, keep the old one so
// that we don't trigger unwanted updates that do a `===` check.
if ( ! isShallowEqual( lastMapResult, mapResult ) ) {
lastMapResult = mapResult;
}
lastMapSelect = mapSelect;
lastMapResultValid = true;
}
function getValue() {
// Update the value in case it's been invalidated or `mapSelect` has changed.
updateValue();
return lastMapResult;
}
// When transitioning from async to sync mode, cancel existing store updates
// that have been scheduled, and invalidate the value so that it's freshly
// computed. It might have been changed by the update we just cancelled.
if ( lastIsAsync && ! isAsync ) {
lastMapResultValid = false;
renderQueue.cancel( queueContext );
}
updateValue();
lastIsAsync = isAsync;
// Return a pair of functions that can be passed to `useSyncExternalStore`.
return { subscribe: subscriber.subscribe, getValue };
};
}
function _useStaticSelect( storeName ) {
return useRegistry().select( storeName );
}
function _useMappingSelect( suspense, mapSelect, deps ) {
const registry = useRegistry();
const isAsync = useAsyncMode();
const store = useMemo(
() => Store( registry, suspense ),
[ registry, suspense ]
);
// These are "pass-through" dependencies from the parent hook,
// and the parent should catch any hook rule violations.
const selector = useCallback( mapSelect, deps );
const { subscribe, getValue } = store( selector, isAsync );
const result = useSyncExternalStore( subscribe, getValue, getValue );
useDebugValue( result );
return result;
}
/**
* Custom react hook for retrieving props from registered selectors.
*
* In general, this custom React hook follows the
* [rules of hooks](https://react.dev/reference/rules/rules-of-hooks).
*
* @template {MapSelect | StoreDescriptor<any>} T
* @param {T} 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.
* When a store key is passed, all selectors for the store will be
* returned. This is only meant for usage of these selectors in event
* callbacks, not for data needed to create the element tree.
* @param {unknown[]} deps If provided, this memoizes the mapSelect so the same `mapSelect` is
* invoked on every state change unless the dependencies change.
*
* @example
* ```js
* import { useSelect } from '@wordpress/data';
* import { store as myCustomStore } from 'my-custom-store';
*
* function HammerPriceDisplay( { currency } ) {
* const price = useSelect( ( select ) => {
* return select( myCustomStore ).getPrice( 'hammer', currency );
* }, [ currency ] );
* return new Intl.NumberFormat( 'en-US', {
* style: 'currency',
* currency,
* } ).format( price );
* }
*
* // Rendered in the application:
* // <HammerPriceDisplay currency="USD" />
* ```
*
* 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.
*
* When data is only used in an event callback, the data should not be retrieved
* on render, so it may be useful to get the selectors function instead.
*
* **Don't use `useSelect` this way when calling the selectors in the render
* function because your component won't re-render on a data change.**
*
* ```js
* import { useSelect } from '@wordpress/data';
* import { store as myCustomStore } from 'my-custom-store';
*
* function Paste( { children } ) {
* const { getSettings } = useSelect( myCustomStore );
* function onPaste() {
* // Do something with the settings.
* const settings = getSettings();
* }
* return <div onPaste={ onPaste }>{ children }</div>;
* }
* ```
* @return {UseSelectReturn<T>} A custom react hook.
*/
export default function useSelect( mapSelect, deps ) {
// On initial call, on mount, determine the mode of this `useSelect` call
// and then never allow it to change on subsequent updates.
const staticSelectMode = typeof mapSelect !== 'function';
const staticSelectModeRef = useRef( staticSelectMode );
if ( staticSelectMode !== staticSelectModeRef.current ) {
const prevMode = staticSelectModeRef.current ? 'static' : 'mapping';
const nextMode = staticSelectMode ? 'static' : 'mapping';
throw new Error(
`Switching useSelect from ${ prevMode } to ${ nextMode } is not allowed`
);
}
// `staticSelectMode` is not allowed to change during the hook instance's,
// lifetime, so the rules of hooks are not really violated.
return staticSelectMode
? _useStaticSelect( mapSelect )
: _useMappingSelect( false, mapSelect, deps );
}
/**
* A variant of the `useSelect` hook that has the same API, but is a compatible
* Suspense-enabled data source.
*
* @template {MapSelect} T
* @param {T} mapSelect Function called on every state change. The
* returned value is exposed to the component
* using this hook. The function receives the
* `registry.suspendSelect` method as the first
* argument and the `registry` as the second one.
* @param {Array} deps A dependency array used to memoize the `mapSelect`
* so that the same `mapSelect` is invoked on every
* state change unless the dependencies change.
*
* @throws {Promise} A suspense Promise that is thrown if any of the called
* selectors is in an unresolved state.
*
* @return {ReturnType<T>} Data object returned by the `mapSelect` function.
*/
export function useSuspenseSelect( mapSelect, deps ) {
return _useMappingSelect( true, mapSelect, deps );
}