@wordpress/data
Version:
Data module for WordPress.
295 lines (252 loc) • 10.9 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();
/**
* @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;
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) {
// 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.
lastMapResultValid = false;
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 === null || unsub === void 0 ? void 0 : 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 (!subscriber) {
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]);
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://reactjs.org/docs/hooks-rules.html).
*
* @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`);
}
/* eslint-disable react-hooks/rules-of-hooks */
// `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);
/* eslint-enable react-hooks/rules-of-hooks */
}
/**
* A variant of the `useSelect` hook that has the same API, but will throw a
* suspense Promise if any of the called selectors is in an unresolved state.
*
* @param {Function} 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.
*
* @return {Object} Data object returned by the `mapSelect` function.
*/
export function useSuspenseSelect(mapSelect, deps) {
return useMappingSelect(true, mapSelect, deps);
}
//# sourceMappingURL=index.js.map