fix: prevent asset conflicts between React and Grid.js versions

Add coexistence checks to all enqueue methods to prevent loading
both React and Grid.js assets simultaneously.

Changes:
- ReactAdmin.php: Only enqueue React assets when ?react=1
- Init.php: Skip Grid.js when React active on admin pages
- Form.php, Coupon.php, Access.php: Restore classic assets when ?react=0
- Customer.php, Product.php, License.php: Add coexistence checks

Now the toggle between Classic and React versions works correctly.

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
dwindown
2026-04-18 17:02:14 +07:00
parent bd9cdac02e
commit e8fbfb14c1
74973 changed files with 6658406 additions and 71 deletions

View File

@@ -0,0 +1,47 @@
/**
* WordPress dependencies
*/
import { createContext } from '@wordpress/element';
export const Context = createContext( false );
const { Consumer, Provider } = Context;
export const AsyncModeConsumer = Consumer;
/**
* Context Provider Component used to switch the data module component rerendering
* between Sync and Async modes.
*
* @example
*
* ```js
* import { useSelect, AsyncModeProvider } from '@wordpress/data';
* import { store as blockEditorStore } from '@wordpress/block-editor';
*
* function BlockCount() {
* const count = useSelect( ( select ) => {
* return select( blockEditorStore ).getBlockCount()
* }, [] );
*
* return count;
* }
*
* function App() {
* return (
* <AsyncModeProvider value={ true }>
* <BlockCount />
* </AsyncModeProvider>
* );
* }
* ```
*
* In this example, the BlockCount component is rerendered asynchronously.
* It means if a more critical task is being performed (like typing in an input),
* the rerendering is delayed until the browser becomes IDLE.
* It is possible to nest multiple levels of AsyncModeProvider to fine-tune the rendering behavior.
*
* @param {boolean} props.value Enable Async Mode.
* @return {Component} The component to be rendered.
*/
export default Provider;

View File

@@ -0,0 +1,2 @@
export { default as useAsyncMode } from './use-async-mode';
export { default as AsyncModeProvider, AsyncModeConsumer } from './context';

View File

@@ -0,0 +1,13 @@
/**
* WordPress dependencies
*/
import { useContext } from '@wordpress/element';
/**
* Internal dependencies
*/
import { Context } from './context';
export default function useAsyncMode() {
return useContext( Context );
}

View File

@@ -0,0 +1,55 @@
/**
* WordPress dependencies
*/
import { createContext } from '@wordpress/element';
/**
* Internal dependencies
*/
import defaultRegistry from '../../default-registry';
export const Context = createContext( defaultRegistry );
const { Consumer, Provider } = Context;
/**
* A custom react Context consumer exposing the provided `registry` to
* children components. Used along with the RegistryProvider.
*
* You can read more about the react context api here:
* https://reactjs.org/docs/context.html#contextprovider
*
* @example
* ```js
* import {
* RegistryProvider,
* RegistryConsumer,
* createRegistry
* } from '@wordpress/data';
*
* const registry = createRegistry( {} );
*
* const App = ( { props } ) => {
* return <RegistryProvider value={ registry }>
* <div>Hello There</div>
* <RegistryConsumer>
* { ( registry ) => (
* <ComponentUsingRegistry
* { ...props }
* registry={ registry }
* ) }
* </RegistryConsumer>
* </RegistryProvider>
* }
* ```
*/
export const RegistryConsumer = Consumer;
/**
* A custom Context provider for exposing the provided `registry` to children
* components via a consumer.
*
* See <a name="#RegistryConsumer">RegistryConsumer</a> documentation for
* example.
*/
export default Provider;

View File

@@ -0,0 +1,2 @@
export { default as RegistryProvider, RegistryConsumer } from './context';
export { default as useRegistry } from './use-registry';

View File

@@ -0,0 +1,52 @@
/**
* WordPress dependencies
*/
import { useContext } from '@wordpress/element';
/**
* Internal dependencies
*/
import { Context } from './context';
/**
* A custom react hook exposing the registry context for use.
*
* This exposes the `registry` value provided via the
* <a href="#RegistryProvider">Registry Provider</a> to a component implementing
* this hook.
*
* It acts similarly to the `useContext` react hook.
*
* Note: Generally speaking, `useRegistry` is a low level hook that in most cases
* won't be needed for implementation. Most interactions with the `@wordpress/data`
* API can be performed via the `useSelect` hook, or the `withSelect` and
* `withDispatch` higher order components.
*
* @example
* ```js
* import {
* RegistryProvider,
* createRegistry,
* useRegistry,
* } from '@wordpress/data';
*
* const registry = createRegistry( {} );
*
* const SomeChildUsingRegistry = ( props ) => {
* const registry = useRegistry();
* // ...logic implementing the registry in other react hooks.
* };
*
*
* const ParentProvidingRegistry = ( props ) => {
* return <RegistryProvider value={ registry }>
* <SomeChildUsingRegistry { ...props } />
* </RegistryProvider>
* };
* ```
*
* @return {Function} A custom react hook exposing the registry context value.
*/
export default function useRegistry() {
return useContext( Context );
}

View File

@@ -0,0 +1,2 @@
export { default as useDispatch } from './use-dispatch';
export { default as useDispatchWithMap } from './use-dispatch-with-map';

View File

@@ -0,0 +1,130 @@
/**
* External dependencies
*/
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
/**
* Internal dependencies
*/
import useDispatch from '../use-dispatch';
import createReduxStore from '../../../redux-store';
import { createRegistry } from '../../../registry';
import { RegistryProvider } from '../../registry-provider';
describe( 'useDispatch', () => {
const counterStore = {
reducer: ( state = 0, action ) => {
if ( action.type === 'INC' ) {
return state + 1;
}
return state;
},
actions: {
inc: () => ( { type: 'INC' } ),
},
selectors: {
get: ( state ) => state,
},
};
it( 'returns dispatch function from store with no store name provided', async () => {
const user = userEvent.setup();
const registry = createRegistry();
registry.registerStore( 'demo', counterStore );
const TestComponent = () => {
const dispatch = useDispatch();
return <button onClick={ () => dispatch( 'demo' ).inc() } />;
};
render(
<RegistryProvider value={ registry }>
<TestComponent />
</RegistryProvider>
);
await user.click( screen.getByRole( 'button' ) );
expect( registry.select( 'demo' ).get() ).toBe( 1 );
} );
it( 'returns expected action creators from store for given store descriptor', async () => {
const user = userEvent.setup();
const store = createReduxStore( 'demoStore', counterStore );
const registry = createRegistry();
registry.register( store );
const TestComponent = () => {
const { inc } = useDispatch( store );
return <button onClick={ inc } />;
};
render(
<RegistryProvider value={ registry }>
<TestComponent />
</RegistryProvider>
);
await user.click( screen.getByRole( 'button' ) );
expect( registry.select( store ).get() ).toBe( 1 );
} );
it( 'returns expected action creators from store for given storeName', async () => {
const user = userEvent.setup();
const registry = createRegistry();
registry.registerStore( 'demoStore', counterStore );
const TestComponent = () => {
const { inc } = useDispatch( 'demoStore' );
return <button onClick={ inc } />;
};
render(
<RegistryProvider value={ registry }>
<TestComponent />
</RegistryProvider>
);
await user.click( screen.getByRole( 'button' ) );
expect( registry.select( 'demoStore' ).get() ).toBe( 1 );
} );
it( 'returns dispatch from correct registry if registries change', async () => {
const user = userEvent.setup();
const firstRegistry = createRegistry();
firstRegistry.registerStore( 'demo', counterStore );
const secondRegistry = createRegistry();
secondRegistry.registerStore( 'demo', counterStore );
const TestComponent = () => {
const dispatch = useDispatch();
return <button onClick={ () => dispatch( 'demo' ).inc() } />;
};
const { rerender } = render(
<RegistryProvider value={ firstRegistry }>
<TestComponent />
</RegistryProvider>
);
await user.click( screen.getByRole( 'button' ) );
expect( firstRegistry.select( 'demo' ).get() ).toBe( 1 );
expect( secondRegistry.select( 'demo' ).get() ).toBe( 0 );
rerender(
<RegistryProvider value={ secondRegistry }>
<TestComponent />
</RegistryProvider>
);
await user.click( screen.getByRole( 'button' ) );
expect( firstRegistry.select( 'demo' ).get() ).toBe( 1 );
expect( secondRegistry.select( 'demo' ).get() ).toBe( 1 );
} );
} );

View File

@@ -0,0 +1,61 @@
/**
* WordPress dependencies
*/
import { useMemo, useRef } from '@wordpress/element';
import { useIsomorphicLayoutEffect } from '@wordpress/compose';
/**
* Internal dependencies
*/
import useRegistry from '../registry-provider/use-registry';
/**
* Custom react hook for returning aggregate dispatch actions using the provided
* dispatchMap.
*
* Currently this is an internal api only and is implemented by `withDispatch`
*
* @param {Function} dispatchMap Receives the `registry.dispatch` function as
* the first argument and the `registry` object
* as the second argument. Should return an
* object mapping props to functions.
* @param {Array} deps An array of dependencies for the hook.
* @return {Object} An object mapping props to functions created by the passed
* in dispatchMap.
*/
const useDispatchWithMap = ( dispatchMap, deps ) => {
const registry = useRegistry();
const currentDispatchMap = useRef( dispatchMap );
useIsomorphicLayoutEffect( () => {
currentDispatchMap.current = dispatchMap;
} );
return useMemo( () => {
const currentDispatchProps = currentDispatchMap.current(
registry.dispatch,
registry
);
return Object.fromEntries(
Object.entries( currentDispatchProps ).map(
( [ propName, dispatcher ] ) => {
if ( typeof dispatcher !== 'function' ) {
// eslint-disable-next-line no-console
console.warn(
`Property ${ propName } returned from dispatchMap in useDispatchWithMap must be a function.`
);
}
return [
propName,
( ...args ) =>
currentDispatchMap
.current( registry.dispatch, registry )
[ propName ]( ...args ),
];
}
)
);
}, [ registry, ...deps ] );
};
export default useDispatchWithMap;

View File

@@ -0,0 +1,68 @@
/**
* Internal dependencies
*/
import useRegistry from '../registry-provider/use-registry';
/**
* @typedef {import('../../types').StoreDescriptor<StoreConfig>} StoreDescriptor
* @template {import('../../types').AnyConfig} StoreConfig
*/
/**
* @typedef {import('../../types').UseDispatchReturn<StoreNameOrDescriptor>} UseDispatchReturn
* @template StoreNameOrDescriptor
*/
/**
* A custom react hook returning the current registry dispatch actions creators.
*
* Note: The component using this hook must be within the context of a
* RegistryProvider.
*
* @template {undefined | string | StoreDescriptor<any>} StoreNameOrDescriptor
* @param {StoreNameOrDescriptor} [storeNameOrDescriptor] Optionally provide the name of the
* store or its descriptor from which to
* retrieve action creators. If not
* provided, the registry.dispatch
* function is returned instead.
*
* @example
* This illustrates a pattern where you may need to retrieve dynamic data from
* the server via the `useSelect` hook to use in combination with the dispatch
* action.
*
* ```jsx
* import { useCallback } from 'react';
* import { useDispatch, useSelect } from '@wordpress/data';
* import { store as myCustomStore } from 'my-custom-store';
*
* function Button( { onClick, children } ) {
* return <button type="button" onClick={ onClick }>{ children }</button>
* }
*
* const SaleButton = ( { children } ) => {
* const { stockNumber } = useSelect(
* ( select ) => select( myCustomStore ).getStockNumber(),
* []
* );
* const { startSale } = useDispatch( myCustomStore );
* const onClick = useCallback( () => {
* const discountPercent = stockNumber > 50 ? 10: 20;
* startSale( discountPercent );
* }, [ stockNumber ] );
* return <Button onClick={ onClick }>{ children }</Button>
* }
*
* // Rendered somewhere in the application:
* //
* // <SaleButton>Start Sale!</SaleButton>
* ```
* @return {UseDispatchReturn<StoreNameOrDescriptor>} A custom react hook.
*/
const useDispatch = ( storeNameOrDescriptor ) => {
const { dispatch } = useRegistry();
return storeNameOrDescriptor === void 0
? dispatch
: dispatch( storeNameOrDescriptor );
};
export default useDispatch;

View File

@@ -0,0 +1,342 @@
/**
* 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;
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 ( process.env.NODE_ENV === 'development' ) {
if ( ! didWarnUnstableReference ) {
const secondMapResult = mapSelect( select, registry );
if ( ! isShallowEqual( mapResult, secondMapResult ) ) {
// eslint-disable-next-line no-console
console.warn(
`The 'useSelect' hook returns different values when called with the same state and parameters. This can lead to unnecessary rerenders.`
);
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.
// eslint-disable-next-line react-hooks/exhaustive-deps
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 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 );
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,235 @@
/**
* External dependencies
*/
import { render, screen } from '@testing-library/react';
/**
* WordPress dependencies
*/
import {
createRegistry,
createReduxStore,
useSuspenseSelect,
RegistryProvider,
} from '@wordpress/data';
import { Component, Suspense } from '@wordpress/element';
function createRegistryWithStore() {
const initialState = {
prefix: 'pre-',
token: null,
data: null,
fails: true,
};
const reducer = ( state = initialState, action ) => {
switch ( action.type ) {
case 'RECEIVE_TOKEN':
return { ...state, token: action.token };
case 'RECEIVE_DATA':
return { ...state, data: action.data };
default:
return state;
}
};
const selectors = {
getPrefix: ( state ) => state.prefix,
getToken: ( state ) => state.token,
getData: ( state, token ) => {
if ( ! token ) {
throw 'missing token in selector';
}
return state.data;
},
getThatFails: ( state ) => state.fails,
};
const sleep = ( ms ) => new Promise( ( r ) => setTimeout( () => r(), ms ) );
const resolvers = {
getToken:
() =>
async ( { dispatch } ) => {
await sleep( 10 );
dispatch( { type: 'RECEIVE_TOKEN', token: 'token' } );
},
getData:
( token ) =>
async ( { dispatch } ) => {
await sleep( 10 );
if ( ! token ) {
throw 'missing token in resolver';
}
dispatch( { type: 'RECEIVE_DATA', data: 'therealdata' } );
},
getThatFails: () => async () => {
await sleep( 10 );
throw 'resolution failed';
},
};
const store = createReduxStore( 'test', {
reducer,
selectors,
resolvers,
} );
const registry = createRegistry();
registry.register( store );
return { registry, store };
}
describe( 'useSuspenseSelect', () => {
it( 'renders after suspending a few times', async () => {
const { registry, store } = createRegistryWithStore();
let attempts = 0;
let renders = 0;
const UI = () => {
attempts++;
const { result } = useSuspenseSelect( ( select ) => {
const prefix = select( store ).getPrefix();
const token = select( store ).getToken();
const data = select( store ).getData( token );
return { result: prefix + data };
}, [] );
renders++;
return <div aria-label="loaded">{ result }</div>;
};
const App = () => (
<RegistryProvider value={ registry }>
<Suspense fallback="loading">
<UI />
</Suspense>
</RegistryProvider>
);
render( <App /> );
await screen.findByLabelText( 'loaded' );
// Verify there were 3 attempts to render. Suspended twice because of
// `getToken` and `getData` selectors not being resolved, and then finally
// rendered after all data got loaded.
expect( attempts ).toBe( 3 );
expect( renders ).toBe( 1 );
} );
it( 'shows error when resolution fails', async () => {
const { registry, store } = createRegistryWithStore();
const UI = () => {
const { token } = useSuspenseSelect( ( select ) => {
// Call a selector whose resolution fails. The `useSuspenseSelect`
// is then supposed to throw the resolution error.
return { token: select( store ).getThatFails() };
}, [] );
return <div aria-label="loaded">{ token }</div>;
};
class Error extends Component {
state = { error: null };
static getDerivedStateFromError( error ) {
return { error };
}
render() {
const { children } = this.props;
if ( this.state.error ) {
return <div aria-label="error">{ this.state.error }</div>;
}
return children;
}
}
const App = () => (
<RegistryProvider value={ registry }>
<Error>
<Suspense fallback="loading">
<UI />
</Suspense>
</Error>
</RegistryProvider>
);
render( <App /> );
const label = await screen.findByLabelText( 'error' );
expect( label ).toHaveTextContent( 'resolution failed' );
expect( console ).toHaveErrored();
} );
it( 'independent resolutions do not cause unrelated rerenders', async () => {
const store = createReduxStore( 'test', {
reducer: ( state = {}, action ) => {
switch ( action.type ) {
case 'RECEIVE':
return { ...state, [ action.endpoint ]: action.data };
default:
return state;
}
},
selectors: {
getData: ( state, endpoint ) => state[ endpoint ],
},
resolvers: {
getData:
( endpoint ) =>
async ( { dispatch } ) => {
const delay = endpoint === 'slow' ? 30 : 10;
await new Promise( ( r ) =>
setTimeout( () => r(), delay )
);
dispatch( {
type: 'RECEIVE',
endpoint,
data: endpoint,
} );
},
},
} );
const registry = createRegistry();
registry.register( store );
const FastUI = jest.fn( () => {
const data = useSuspenseSelect(
( select ) => select( store ).getData( 'fast' ),
[]
);
return <div aria-label="fast loaded">{ data }</div>;
} );
const SlowUI = jest.fn( () => {
const data = useSuspenseSelect(
( select ) => select( store ).getData( 'slow' ),
[]
);
return <div aria-label="slow loaded">{ data }</div>;
} );
const App = () => (
<RegistryProvider value={ registry }>
<Suspense fallback="fast loading">
<FastUI />
</Suspense>
<Suspense fallback="slow loading">
<SlowUI />
</Suspense>
</RegistryProvider>
);
render( <App /> );
const fastLabel = await screen.findByLabelText( 'fast loaded' );
expect( fastLabel ).toHaveTextContent( 'fast' );
const slowLabel = await screen.findByLabelText( 'slow loaded' );
expect( slowLabel ).toHaveTextContent( 'slow' );
expect( FastUI ).toHaveBeenCalledTimes( 2 );
expect( SlowUI ).toHaveBeenCalledTimes( 2 );
} );
} );

View File

@@ -0,0 +1,105 @@
/**
* WordPress dependencies
*/
import { createHigherOrderComponent } from '@wordpress/compose';
/**
* Internal dependencies
*/
import { useDispatchWithMap } from '../use-dispatch';
/** @typedef {import('react').ComponentType} ComponentType */
/**
* Higher-order component used to add dispatch props using registered action
* creators.
*
* @param {Function} mapDispatchToProps A function of returning an object of
* prop names where value is a
* dispatch-bound action creator, or a
* function to be called with the
* component's props and returning an
* action creator.
*
* @example
* ```jsx
* function Button( { onClick, children } ) {
* return <button type="button" onClick={ onClick }>{ children }</button>;
* }
*
* import { withDispatch } from '@wordpress/data';
* import { store as myCustomStore } from 'my-custom-store';
*
* const SaleButton = withDispatch( ( dispatch, ownProps ) => {
* const { startSale } = dispatch( myCustomStore );
* const { discountPercent } = ownProps;
*
* return {
* onClick() {
* startSale( discountPercent );
* },
* };
* } )( Button );
*
* // Rendered in the application:
* //
* // <SaleButton discountPercent="20">Start Sale!</SaleButton>
* ```
*
* @example
* In the majority of cases, it will be sufficient to use only two first params
* passed to `mapDispatchToProps` as illustrated in the previous example.
* However, there might be some very advanced use cases where using the
* `registry` object might be used as a tool to optimize the performance of
* your component. Using `select` function from the registry might be useful
* when you need to fetch some dynamic data from the store at the time when the
* event is fired, but at the same time, you never use it to render your
* component. In such scenario, you can avoid using the `withSelect` higher
* order component to compute such prop, which might lead to unnecessary
* re-renders of your component caused by its frequent value change.
* Keep in mind, that `mapDispatchToProps` must return an object with functions
* only.
*
* ```jsx
* function Button( { onClick, children } ) {
* return <button type="button" onClick={ onClick }>{ children }</button>;
* }
*
* import { withDispatch } from '@wordpress/data';
* import { store as myCustomStore } from 'my-custom-store';
*
* const SaleButton = withDispatch( ( dispatch, ownProps, { select } ) => {
* // Stock number changes frequently.
* const { getStockNumber } = select( myCustomStore );
* const { startSale } = dispatch( myCustomStore );
* return {
* onClick() {
* const discountPercent = getStockNumber() > 50 ? 10 : 20;
* startSale( discountPercent );
* },
* };
* } )( Button );
*
* // Rendered in the application:
* //
* // <SaleButton>Start Sale!</SaleButton>
* ```
*
* _Note:_ It is important that the `mapDispatchToProps` function always
* returns an object with the same keys. For example, it should not contain
* conditions under which a different value would be returned.
*
* @return {ComponentType} Enhanced component with merged dispatcher props.
*/
const withDispatch = ( mapDispatchToProps ) =>
createHigherOrderComponent(
( WrappedComponent ) => ( ownProps ) => {
const mapDispatch = ( dispatch, registry ) =>
mapDispatchToProps( dispatch, ownProps, registry );
const dispatchProps = useDispatchWithMap( mapDispatch, [] );
return <WrappedComponent { ...ownProps } { ...dispatchProps } />;
},
'withDispatch'
);
export default withDispatch;

View File

@@ -0,0 +1,171 @@
/**
* External dependencies
*/
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
/**
* WordPress dependencies
*/
import { memo } from '@wordpress/element';
/**
* Internal dependencies
*/
import withDispatch from '../';
import { createRegistry } from '../../../registry';
import { RegistryProvider } from '../../registry-provider';
describe( 'withDispatch', () => {
const storeOptions = {
reducer: ( state = 0, action ) => {
if ( action.type === 'inc' ) {
return state + action.count;
}
return state;
},
actions: {
increment: ( count = 1 ) => ( { type: 'inc', count } ),
},
selectors: {
getCount: ( state ) => state,
},
};
it( 'passes the relevant data to the component', async () => {
const user = userEvent.setup();
const registry = createRegistry();
registry.registerStore( 'counter', storeOptions );
const ButtonSpy = jest.fn( ( { onClick } ) => (
<button onClick={ onClick } />
) );
const Button = memo( ButtonSpy );
const Component = withDispatch( ( dispatch, ownProps ) => {
const { count } = ownProps;
return {
increment: () => {
const actionReturnedFromDispatch = Promise.resolve(
dispatch( 'counter' ).increment( count )
);
return expect(
actionReturnedFromDispatch
).resolves.toEqual( {
type: 'inc',
count,
} );
},
};
} )( ( props ) => <Button onClick={ props.increment } /> );
const { rerender } = render(
<RegistryProvider value={ registry }>
<Component count={ 0 } />
</RegistryProvider>
);
// Verify that dispatch respects props at the time of being invoked by
// changing props after the initial mount.
rerender(
<RegistryProvider value={ registry }>
<Component count={ 2 } />
</RegistryProvider>
);
// Function value reference should not have changed in props update.
// The spy method is only called during inital render.
expect( ButtonSpy ).toHaveBeenCalledTimes( 1 );
await user.click( screen.getByRole( 'button' ) );
expect( registry.select( 'counter' ).getCount() ).toBe( 2 );
} );
it( 'calls dispatch on the correct registry if updated', async () => {
const user = userEvent.setup();
const firstRegistry = createRegistry();
firstRegistry.registerStore( 'demo', storeOptions );
const secondRegistry = createRegistry();
secondRegistry.registerStore( 'demo', storeOptions );
const Component = withDispatch( ( dispatch ) => {
return {
increment() {
dispatch( 'demo' ).increment();
},
};
} )( ( props ) => <button onClick={ props.increment } /> );
const { rerender } = render(
<RegistryProvider value={ firstRegistry }>
<Component />
</RegistryProvider>
);
await user.click( screen.getByRole( 'button' ) );
expect( firstRegistry.select( 'demo' ).getCount() ).toBe( 1 );
expect( secondRegistry.select( 'demo' ).getCount() ).toBe( 0 );
rerender(
<RegistryProvider value={ secondRegistry }>
<Component />
</RegistryProvider>
);
await user.click( screen.getByRole( 'button' ) );
expect( firstRegistry.select( 'demo' ).getCount() ).toBe( 1 );
expect( secondRegistry.select( 'demo' ).getCount() ).toBe( 1 );
} );
it( 'always calls select with the latest state in the handler passed to the component', async () => {
const user = userEvent.setup();
const registry = createRegistry();
registry.registerStore( 'counter', storeOptions );
const Component = withDispatch( ( dispatch, ownProps, { select } ) => {
return {
update: () => {
const currentCount = select( 'counter' ).getCount();
dispatch( 'counter' ).increment( currentCount + 1 );
},
};
} )( ( props ) => <button onClick={ props.update } /> );
render(
<RegistryProvider value={ registry }>
<Component />
</RegistryProvider>
);
await user.click( screen.getByRole( 'button' ) );
// expectedValue = 2 * currentValue + 1.
expect( registry.select( 'counter' ).getCount() ).toBe( 1 );
await user.click( screen.getByRole( 'button' ) );
expect( registry.select( 'counter' ).getCount() ).toBe( 3 );
await user.click( screen.getByRole( 'button' ) );
expect( registry.select( 'counter' ).getCount() ).toBe( 7 );
} );
it( 'warns when mapDispatchToProps returns non-function property', () => {
const registry = createRegistry();
const Component = withDispatch( () => {
return {
count: 3,
};
} )( () => null );
render(
<RegistryProvider value={ registry }>
<Component />
</RegistryProvider>
);
expect( console ).toHaveWarnedWith(
'Property count returned from dispatchMap in useDispatchWithMap must be a function.'
);
} );
} );

View File

@@ -0,0 +1,30 @@
/**
* WordPress dependencies
*/
import { createHigherOrderComponent } from '@wordpress/compose';
/**
* Internal dependencies
*/
import { RegistryConsumer } from '../registry-provider';
/**
* Higher-order component which renders the original component with the current
* registry context passed as its `registry` prop.
*
* @param {Component} OriginalComponent Original component.
*
* @return {Component} Enhanced component.
*/
const withRegistry = createHigherOrderComponent(
( OriginalComponent ) => ( props ) => (
<RegistryConsumer>
{ ( registry ) => (
<OriginalComponent { ...props } registry={ registry } />
) }
</RegistryConsumer>
),
'withRegistry'
);
export default withRegistry;

View File

@@ -0,0 +1,65 @@
/**
* WordPress dependencies
*/
import { createHigherOrderComponent, pure } from '@wordpress/compose';
/**
* Internal dependencies
*/
import useSelect from '../use-select';
/** @typedef {import('react').ComponentType} ComponentType */
/**
* Higher-order component used to inject state-derived props using registered
* selectors.
*
* @param {Function} mapSelectToProps Function called on every state change,
* expected to return object of props to
* merge with the component's own props.
*
* @example
* ```js
* import { withSelect } from '@wordpress/data';
* import { store as myCustomStore } from 'my-custom-store';
*
* function PriceDisplay( { price, currency } ) {
* return new Intl.NumberFormat( 'en-US', {
* style: 'currency',
* currency,
* } ).format( price );
* }
*
* const HammerPriceDisplay = withSelect( ( select, ownProps ) => {
* const { getPrice } = select( myCustomStore );
* const { currency } = ownProps;
*
* return {
* price: getPrice( 'hammer', currency ),
* };
* } )( PriceDisplay );
*
* // Rendered in the application:
* //
* // <HammerPriceDisplay currency="USD" />
* ```
* In the above example, when `HammerPriceDisplay` is rendered into an
* application, it will pass the price into the underlying `PriceDisplay`
* component and update automatically if the price of a hammer ever changes in
* the store.
*
* @return {ComponentType} Enhanced component with merged state data props.
*/
const withSelect = ( mapSelectToProps ) =>
createHigherOrderComponent(
( WrappedComponent ) =>
pure( ( ownProps ) => {
const mapSelect = ( select, registry ) =>
mapSelectToProps( select, ownProps, registry );
const mergeProps = useSelect( mapSelect );
return <WrappedComponent { ...ownProps } { ...mergeProps } />;
} ),
'withSelect'
);
export default withSelect;

View File

@@ -0,0 +1,617 @@
/**
* External dependencies
*/
import { act, render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
/**
* WordPress dependencies
*/
import { compose } from '@wordpress/compose';
import { Component } from '@wordpress/element';
/**
* Internal dependencies
*/
import withSelect from '../';
import withDispatch from '../../with-dispatch';
import { createRegistry } from '../../../registry';
import { RegistryProvider } from '../../registry-provider';
describe( 'withSelect', () => {
it( 'passes the relevant data to the component', () => {
const registry = createRegistry();
registry.registerStore( 'reactReducer', {
reducer: () => ( { reactKey: 'reactState' } ),
selectors: {
reactSelector: ( state, key ) => state[ key ],
},
} );
// In normal circumstances, the fact that we have to add an arbitrary
// prefix to the variable name would be concerning, and perhaps an
// argument that we ought to expect developer to use select from the
// `@wordpress/data` export. But in-fact, this serves as a good deterrent for
// including both `withSelect` and `select` in the same scope, which
// shouldn't occur for a typical component, and if it did might wrongly
// encourage the developer to use `select` within the component itself.
const mapSelectToProps = jest.fn( ( _select, ownProps ) => ( {
data: _select( 'reactReducer' ).reactSelector( ownProps.keyName ),
} ) );
const OriginalComponent = jest.fn( ( props ) => (
<div role="status">{ props.data }</div>
) );
const DataBoundComponent =
withSelect( mapSelectToProps )( OriginalComponent );
render(
<RegistryProvider value={ registry }>
<DataBoundComponent keyName="reactKey" />
</RegistryProvider>
);
expect( mapSelectToProps ).toHaveBeenCalledTimes( 1 );
expect( OriginalComponent ).toHaveBeenCalledTimes( 1 );
// Wrapper is the enhanced component.
expect( screen.getByRole( 'status' ) ).toHaveTextContent(
'reactState'
);
} );
it( 'should rerun selection on state changes', async () => {
const user = userEvent.setup();
const registry = createRegistry();
registry.registerStore( 'counter', {
reducer: ( state = 0, action ) => {
if ( action.type === 'increment' ) {
return state + 1;
}
return state;
},
selectors: {
getCount: ( state ) => state,
},
actions: {
increment: () => ( { type: 'increment' } ),
},
} );
const mapSelectToProps = jest.fn( ( _select ) => ( {
count: _select( 'counter' ).getCount(),
} ) );
const mapDispatchToProps = jest.fn( ( _dispatch ) => ( {
increment: _dispatch( 'counter' ).increment,
} ) );
const OriginalComponent = jest.fn( ( props ) => (
<button onClick={ props.increment }>{ props.count }</button>
) );
const DataBoundComponent = compose( [
withSelect( mapSelectToProps ),
withDispatch( mapDispatchToProps ),
] )( OriginalComponent );
render(
<RegistryProvider value={ registry }>
<DataBoundComponent />
</RegistryProvider>
);
expect( OriginalComponent ).toHaveBeenCalledTimes( 1 );
expect( mapSelectToProps ).toHaveBeenCalledTimes( 1 );
expect( mapDispatchToProps ).toHaveBeenCalledTimes( 1 );
// Simulate a click on the button.
const button = screen.getByRole( 'button' );
await user.click( button );
expect( button ).toHaveTextContent( '1' );
// 1. Initial mount
// 2. When click handler is called.
expect( mapDispatchToProps ).toHaveBeenCalledTimes( 2 );
// 3 times
// - 1 on initial render
// - 1 on click triggering subscription firing.
// - 1 on rerender.
expect( mapSelectToProps ).toHaveBeenCalledTimes( 3 );
// Verifies component only renders twice.
expect( OriginalComponent ).toHaveBeenCalledTimes( 2 );
} );
describe( 'expected behaviour when dispatching actions during mount', () => {
const testRegistry = createRegistry();
testRegistry.registerStore( 'counter', {
reducer: ( state = 0, action ) => {
if ( action.type === 'increment' ) {
return state + 1;
}
return state;
},
selectors: {
getCount: ( state ) => state,
},
actions: {
increment: () => ( { type: 'increment' } ),
},
} );
// @todo Should we allow this behaviour? Side-effects
// on mount are discouraged in React (breaks Suspense and React Async Mode)
// leaving in place for now under the assumption there's current usage
// of withSelect in GB that expects support.
class OriginalComponent extends Component {
constructor( props ) {
super( ...arguments );
props.increment();
}
componentDidMount() {
this.props.increment();
}
render() {
return <div role="status">{ this.props.count }</div>;
}
}
const renderSpy = jest.spyOn( OriginalComponent.prototype, 'render' );
const mapSelectToProps = jest.fn( ( _select ) => ( {
count: _select( 'counter' ).getCount(),
} ) );
const mapDispatchToProps = jest.fn( ( _dispatch ) => ( {
increment: _dispatch( 'counter' ).increment,
} ) );
const DataBoundComponent = compose( [
withSelect( mapSelectToProps ),
withDispatch( mapDispatchToProps ),
] )( OriginalComponent );
it( 'should rerun if had dispatched action during mount', () => {
const { unmount } = render(
<RegistryProvider value={ testRegistry }>
<DataBoundComponent />
</RegistryProvider>
);
expect( screen.getByRole( 'status' ) ).toHaveTextContent( '2' );
// Expected 3 times because:
// - 1 on initial render
// - 1 on effect before subscription set.
// - 1 for the rerender because of the mapOutput change detected.
expect( mapSelectToProps ).toHaveBeenCalledTimes( 3 );
expect( renderSpy ).toHaveBeenCalledTimes( 2 );
unmount();
} );
it( 'should rerun on unmount and mount', () => {
render(
<RegistryProvider value={ testRegistry }>
<DataBoundComponent />
</RegistryProvider>
);
expect( screen.getByRole( 'status' ) ).toHaveTextContent( '4' );
// Expected an additional 3 times because of the unmount and remount:
// - 1 on initial render
// - 1 on effect before subscription set.
// - once for the rerender because of the mapOutput change detected.
expect( mapSelectToProps ).toHaveBeenCalledTimes( 6 );
expect( renderSpy ).toHaveBeenCalledTimes( 4 );
} );
} );
it( 'should rerun selection on props changes', () => {
const registry = createRegistry();
registry.registerStore( 'counter', {
reducer: ( state = 0, action ) => {
if ( action.type === 'increment' ) {
return state + 1;
}
return state;
},
selectors: {
getCount: ( state, offset ) => state + offset,
},
} );
const mapSelectToProps = jest.fn( ( _select, ownProps ) => ( {
count: _select( 'counter' ).getCount( ownProps.offset ),
} ) );
const OriginalComponent = jest.fn( ( props ) => (
<div role="status">{ props.count }</div>
) );
const DataBoundComponent =
withSelect( mapSelectToProps )( OriginalComponent );
const { rerender } = render(
<RegistryProvider value={ registry }>
<DataBoundComponent offset={ 0 } />
</RegistryProvider>
);
expect( mapSelectToProps ).toHaveBeenCalledTimes( 1 );
expect( OriginalComponent ).toHaveBeenCalledTimes( 1 );
rerender(
<RegistryProvider value={ registry }>
<DataBoundComponent offset={ 10 } />
</RegistryProvider>
);
expect( screen.getByRole( 'status' ) ).toHaveTextContent( '10' );
expect( mapSelectToProps ).toHaveBeenCalledTimes( 2 );
expect( OriginalComponent ).toHaveBeenCalledTimes( 2 );
} );
it( 'should not run selection if props have not changed', () => {
const registry = createRegistry();
registry.registerStore( 'unchanging', {
reducer: ( state = {} ) => state,
selectors: {
getState: ( state ) => state,
},
} );
const mapSelectToProps = jest.fn();
const OriginalComponent = jest.fn( () => <div /> );
const DataBoundComponent = compose( [
withSelect( mapSelectToProps ),
] )( OriginalComponent );
const Parent = ( props ) => (
<DataBoundComponent propName={ props.propName } />
);
const { rerender } = render(
<RegistryProvider value={ registry }>
<Parent propName="foo" />
</RegistryProvider>
);
expect( mapSelectToProps ).toHaveBeenCalledTimes( 1 );
expect( OriginalComponent ).toHaveBeenCalledTimes( 1 );
rerender(
<RegistryProvider value={ registry }>
<Parent propName="foo" />
</RegistryProvider>
);
expect( mapSelectToProps ).toHaveBeenCalledTimes( 1 );
expect( OriginalComponent ).toHaveBeenCalledTimes( 1 );
} );
it( 'should not rerender if state has changed but merge props the same', async () => {
const registry = createRegistry();
registry.registerStore( 'demo', {
reducer: () => ( {} ),
selectors: {
getUnchangingValue: () => 10,
},
actions: {
update: () => ( { type: 'update' } ),
},
} );
const mapSelectToProps = jest.fn( ( _select ) => ( {
value: _select( 'demo' ).getUnchangingValue(),
} ) );
const OriginalComponent = jest.fn( () => <div /> );
const DataBoundComponent =
withSelect( mapSelectToProps )( OriginalComponent );
render(
<RegistryProvider value={ registry }>
<DataBoundComponent />
</RegistryProvider>
);
expect( mapSelectToProps ).toHaveBeenCalledTimes( 1 );
expect( OriginalComponent ).toHaveBeenCalledTimes( 1 );
await act( async () => registry.dispatch( 'demo' ).update() );
expect( mapSelectToProps ).toHaveBeenCalledTimes( 2 );
expect( OriginalComponent ).toHaveBeenCalledTimes( 1 );
} );
it( 'should render if props have changed but not state', () => {
const registry = createRegistry();
registry.registerStore( 'unchanging', {
reducer: ( state = {} ) => state,
selectors: {
getState: ( state ) => state,
},
} );
const mapSelectToProps = jest.fn();
const OriginalComponent = jest.fn( () => <div /> );
const DataBoundComponent = compose( [
withSelect( mapSelectToProps ),
] )( OriginalComponent );
const { rerender } = render(
<RegistryProvider value={ registry }>
<DataBoundComponent />
</RegistryProvider>
);
expect( mapSelectToProps ).toHaveBeenCalledTimes( 1 );
expect( OriginalComponent ).toHaveBeenCalledTimes( 1 );
rerender(
<RegistryProvider value={ registry }>
<DataBoundComponent propName="foo" />
</RegistryProvider>
);
expect( mapSelectToProps ).toHaveBeenCalledTimes( 2 );
expect( OriginalComponent ).toHaveBeenCalledTimes( 2 );
} );
it( 'should not rerun selection on unchanging state', async () => {
const registry = createRegistry();
const store = registry.registerStore( 'unchanging', {
reducer: ( state = {} ) => state,
selectors: {
getState: ( state ) => state,
},
} );
const mapSelectToProps = jest.fn();
const OriginalComponent = jest.fn( () => <div /> );
const DataBoundComponent = compose( [
withSelect( mapSelectToProps ),
] )( OriginalComponent );
render(
<RegistryProvider value={ registry }>
<DataBoundComponent />
</RegistryProvider>
);
expect( mapSelectToProps ).toHaveBeenCalledTimes( 1 );
expect( OriginalComponent ).toHaveBeenCalledTimes( 1 );
await act( async () => store.dispatch( { type: 'dummy' } ) );
expect( mapSelectToProps ).toHaveBeenCalledTimes( 1 );
expect( OriginalComponent ).toHaveBeenCalledTimes( 1 );
} );
it( 'omits props which are not returned on subsequent mappings', () => {
const registry = createRegistry();
registry.registerStore( 'demo', {
reducer: ( state = 'OK' ) => state,
selectors: {
getValue: ( state ) => state,
},
} );
const mapSelectToProps = jest.fn( ( _select, ownProps ) => {
return {
[ ownProps.propName ]: _select( 'demo' ).getValue(),
};
} );
const OriginalComponent = jest.fn( ( props ) => (
<div role="status">{ JSON.stringify( props ) }</div>
) );
const DataBoundComponent =
withSelect( mapSelectToProps )( OriginalComponent );
const { rerender } = render(
<RegistryProvider value={ registry }>
<DataBoundComponent propName="foo" />
</RegistryProvider>
);
expect( mapSelectToProps ).toHaveBeenCalledTimes( 1 );
expect( OriginalComponent ).toHaveBeenCalledTimes( 1 );
expect( screen.getByRole( 'status' ) ).toHaveTextContent(
JSON.stringify( {
propName: 'foo',
foo: 'OK',
} )
);
rerender(
<RegistryProvider value={ registry }>
<DataBoundComponent propName="bar" />
</RegistryProvider>
);
expect( mapSelectToProps ).toHaveBeenCalledTimes( 2 );
expect( OriginalComponent ).toHaveBeenCalledTimes( 2 );
expect( screen.getByRole( 'status' ) ).toHaveTextContent(
JSON.stringify( {
propName: 'bar',
bar: 'OK',
} )
);
} );
it( 'allows undefined return from mapSelectToProps', () => {
const registry = createRegistry();
registry.registerStore( 'demo', {
reducer: ( state = 'OK' ) => state,
selectors: {
getValue: ( state ) => state,
},
} );
const mapSelectToProps = jest.fn( ( _select, ownProps ) => {
if ( ownProps.pass ) {
return {
count: _select( 'demo' ).getValue(),
};
}
} );
const OriginalComponent = jest.fn( ( props ) => (
<div role="status">{ props.count || 'Unknown' }</div>
) );
const DataBoundComponent =
withSelect( mapSelectToProps )( OriginalComponent );
const { rerender } = render(
<RegistryProvider value={ registry }>
<DataBoundComponent pass={ false } />
</RegistryProvider>
);
expect( mapSelectToProps ).toHaveBeenCalledTimes( 1 );
expect( OriginalComponent ).toHaveBeenCalledTimes( 1 );
expect( screen.getByRole( 'status' ) ).toHaveTextContent( 'Unknown' );
rerender(
<RegistryProvider value={ registry }>
<DataBoundComponent pass />
</RegistryProvider>
);
expect( mapSelectToProps ).toHaveBeenCalledTimes( 2 );
expect( OriginalComponent ).toHaveBeenCalledTimes( 2 );
expect( screen.getByRole( 'status' ) ).toHaveTextContent( 'OK' );
rerender(
<RegistryProvider value={ registry }>
<DataBoundComponent pass={ false } />
</RegistryProvider>
);
expect( mapSelectToProps ).toHaveBeenCalledTimes( 3 );
expect( OriginalComponent ).toHaveBeenCalledTimes( 3 );
expect( screen.getByRole( 'status' ) ).toHaveTextContent( 'Unknown' );
} );
it( 'should limit unnecessary selections run on children', async () => {
const registry = createRegistry();
registry.registerStore( 'childRender', {
reducer: ( state = true, action ) =>
action.type === 'TOGGLE_RENDER' ? ! state : state,
selectors: {
getValue: ( state ) => state,
},
actions: {
toggleRender: () => ( { type: 'TOGGLE_RENDER' } ),
},
} );
const childMapSelectToProps = jest.fn();
const parentMapSelectToProps = jest.fn( ( _select ) => ( {
isRenderingChild: _select( 'childRender' ).getValue(),
} ) );
const ChildOriginalComponent = jest.fn( () => <div /> );
const ParentOriginalComponent = jest.fn( ( props ) => (
<div>{ props.isRenderingChild ? <Child /> : null }</div>
) );
const Child = withSelect( childMapSelectToProps )(
ChildOriginalComponent
);
const Parent = withSelect( parentMapSelectToProps )(
ParentOriginalComponent
);
render(
<RegistryProvider value={ registry }>
<Parent />
</RegistryProvider>
);
expect( childMapSelectToProps ).toHaveBeenCalledTimes( 1 );
expect( parentMapSelectToProps ).toHaveBeenCalledTimes( 1 );
expect( ChildOriginalComponent ).toHaveBeenCalledTimes( 1 );
expect( ParentOriginalComponent ).toHaveBeenCalledTimes( 1 );
// This is intentionally wrapped in an `act()` call.
// eslint-disable-next-line testing-library/no-unnecessary-act
await act( async () => {
registry.dispatch( 'childRender' ).toggleRender();
} );
expect( childMapSelectToProps ).toHaveBeenCalledTimes( 1 );
expect( parentMapSelectToProps ).toHaveBeenCalledTimes( 3 );
expect( ChildOriginalComponent ).toHaveBeenCalledTimes( 1 );
expect( ParentOriginalComponent ).toHaveBeenCalledTimes( 2 );
} );
it( 'should rerun selection on registry change', () => {
const firstRegistry = createRegistry();
firstRegistry.registerStore( 'demo', {
reducer: ( state = 'first' ) => state,
selectors: {
getValue: ( state ) => state,
},
} );
const mapSelectToProps = jest.fn( ( _select ) => ( {
value: _select( 'demo' ).getValue(),
} ) );
const OriginalComponent = jest.fn( ( props ) => (
<div role="status">{ props.value }</div>
) );
const DataBoundComponent =
withSelect( mapSelectToProps )( OriginalComponent );
const { rerender } = render(
<RegistryProvider value={ firstRegistry }>
<DataBoundComponent />
</RegistryProvider>
);
expect( mapSelectToProps ).toHaveBeenCalledTimes( 1 );
expect( OriginalComponent ).toHaveBeenCalledTimes( 1 );
expect( screen.getByRole( 'status' ) ).toHaveTextContent( 'first' );
const secondRegistry = createRegistry();
secondRegistry.registerStore( 'demo', {
reducer: ( state = 'second' ) => state,
selectors: {
getValue: ( state ) => state,
},
} );
rerender(
<RegistryProvider value={ secondRegistry }>
<DataBoundComponent />
</RegistryProvider>
);
// 2 times:
// - 1 on initial render
// - 1 on re-render
expect( mapSelectToProps ).toHaveBeenCalledTimes( 2 );
expect( OriginalComponent ).toHaveBeenCalledTimes( 2 );
expect( screen.getByRole( 'status' ) ).toHaveTextContent( 'second' );
} );
} );

141
node_modules/@wordpress/data/src/controls.js generated vendored Normal file
View File

@@ -0,0 +1,141 @@
/**
* Internal dependencies
*/
import { createRegistryControl } from './factory';
/** @typedef {import('./types').StoreDescriptor} StoreDescriptor */
const SELECT = '@@data/SELECT';
const RESOLVE_SELECT = '@@data/RESOLVE_SELECT';
const DISPATCH = '@@data/DISPATCH';
function isObject( object ) {
return object !== null && typeof object === 'object';
}
/**
* Dispatches a control action for triggering a synchronous registry select.
*
* Note: This control synchronously returns the current selector value, triggering the
* resolution, but not waiting for it.
*
* @param {string|StoreDescriptor} storeNameOrDescriptor Unique namespace identifier for the store
* @param {string} selectorName The name of the selector.
* @param {Array} args Arguments for the selector.
*
* @example
* ```js
* import { controls } from '@wordpress/data';
*
* // Action generator using `select`.
* export function* myAction() {
* const isEditorSideBarOpened = yield controls.select( 'core/edit-post', 'isEditorSideBarOpened' );
* // Do stuff with the result from the `select`.
* }
* ```
*
* @return {Object} The control descriptor.
*/
function select( storeNameOrDescriptor, selectorName, ...args ) {
return {
type: SELECT,
storeKey: isObject( storeNameOrDescriptor )
? storeNameOrDescriptor.name
: storeNameOrDescriptor,
selectorName,
args,
};
}
/**
* Dispatches a control action for triggering and resolving a registry select.
*
* Note: when this control action is handled, it automatically considers
* selectors that may have a resolver. In such case, it will return a `Promise` that resolves
* after the selector finishes resolving, with the final result value.
*
* @param {string|StoreDescriptor} storeNameOrDescriptor Unique namespace identifier for the store
* @param {string} selectorName The name of the selector
* @param {Array} args Arguments for the selector.
*
* @example
* ```js
* import { controls } from '@wordpress/data';
*
* // Action generator using resolveSelect
* export function* myAction() {
* const isSidebarOpened = yield controls.resolveSelect( 'core/edit-post', 'isEditorSideBarOpened' );
* // do stuff with the result from the select.
* }
* ```
*
* @return {Object} The control descriptor.
*/
function resolveSelect( storeNameOrDescriptor, selectorName, ...args ) {
return {
type: RESOLVE_SELECT,
storeKey: isObject( storeNameOrDescriptor )
? storeNameOrDescriptor.name
: storeNameOrDescriptor,
selectorName,
args,
};
}
/**
* Dispatches a control action for triggering a registry dispatch.
*
* @param {string|StoreDescriptor} storeNameOrDescriptor Unique namespace identifier for the store
* @param {string} actionName The name of the action to dispatch
* @param {Array} args Arguments for the dispatch action.
*
* @example
* ```js
* import { controls } from '@wordpress/data-controls';
*
* // Action generator using dispatch
* export function* myAction() {
* yield controls.dispatch( 'core/editor', 'togglePublishSidebar' );
* // do some other things.
* }
* ```
*
* @return {Object} The control descriptor.
*/
function dispatch( storeNameOrDescriptor, actionName, ...args ) {
return {
type: DISPATCH,
storeKey: isObject( storeNameOrDescriptor )
? storeNameOrDescriptor.name
: storeNameOrDescriptor,
actionName,
args,
};
}
export const controls = { select, resolveSelect, dispatch };
export const builtinControls = {
[ SELECT ]: createRegistryControl(
( registry ) =>
( { storeKey, selectorName, args } ) =>
registry.select( storeKey )[ selectorName ]( ...args )
),
[ RESOLVE_SELECT ]: createRegistryControl(
( registry ) =>
( { storeKey, selectorName, args } ) => {
const method = registry.select( storeKey )[ selectorName ]
.hasResolver
? 'resolveSelect'
: 'select';
return registry[ method ]( storeKey )[ selectorName ](
...args
);
}
),
[ DISPATCH ]: createRegistryControl(
( registry ) =>
( { storeKey, actionName, args } ) =>
registry.dispatch( storeKey )[ actionName ]( ...args )
),
};

11
node_modules/@wordpress/data/src/create-selector.js generated vendored Normal file
View File

@@ -0,0 +1,11 @@
/**
* Creates a memoized selector that caches the computed values according to the array of "dependants"
* and the selector parameters, and recomputes the values only when any of them changes.
*
* @see The documentation for the `rememo` package from which the `createSelector` function is reexported.
*
* @param {Function} selector Selector function that calculates a value from state and parameters.
* @param {Function} getDependants Function that returns an array of "dependant" objects.
* @return {Function} A memoized version of `selector` that caches the calculated return values.
*/
export { default as createSelector } from 'rememo';

6
node_modules/@wordpress/data/src/default-registry.js generated vendored Normal file
View File

@@ -0,0 +1,6 @@
/**
* Internal dependencies
*/
import { createRegistry } from './registry';
export default createRegistry();

32
node_modules/@wordpress/data/src/dispatch.ts generated vendored Normal file
View File

@@ -0,0 +1,32 @@
/**
* Internal dependencies
*/
import type { AnyConfig, StoreDescriptor, DispatchReturn } from './types';
import defaultRegistry from './default-registry';
/**
* Given a store descriptor, returns an object of the store's action creators.
* Calling an action creator will cause it to be dispatched, updating the state value accordingly.
*
* Note: Action creators returned by the dispatch will return a promise when
* they are called.
*
* @param storeNameOrDescriptor The store descriptor. The legacy calling convention of passing
* the store name is also supported.
*
* @example
* ```js
* import { dispatch } from '@wordpress/data';
* import { store as myCustomStore } from 'my-custom-store';
*
* dispatch( myCustomStore ).setPrice( 'hammer', 9.75 );
* ```
* @return Object containing the action creators.
*/
export function dispatch<
StoreNameOrDescriptor extends StoreDescriptor< AnyConfig > | string,
>(
storeNameOrDescriptor: StoreNameOrDescriptor
): DispatchReturn< StoreNameOrDescriptor > {
return defaultRegistry.dispatch( storeNameOrDescriptor );
}

95
node_modules/@wordpress/data/src/factory.js generated vendored Normal file
View File

@@ -0,0 +1,95 @@
/**
* Creates a selector function that takes additional curried argument with the
* registry `select` function. While a regular selector has signature
* ```js
* ( state, ...selectorArgs ) => ( result )
* ```
* that allows to select data from the store's `state`, a registry selector
* has signature:
* ```js
* ( select ) => ( state, ...selectorArgs ) => ( result )
* ```
* that supports also selecting from other registered stores.
*
* @example
* ```js
* import { store as coreStore } from '@wordpress/core-data';
* import { store as editorStore } from '@wordpress/editor';
*
* const getCurrentPostId = createRegistrySelector( ( select ) => ( state ) => {
* return select( editorStore ).getCurrentPostId();
* } );
*
* const getPostEdits = createRegistrySelector( ( select ) => ( state ) => {
* // calling another registry selector just like any other function
* const postType = getCurrentPostType( state );
* const postId = getCurrentPostId( state );
* return select( coreStore ).getEntityRecordEdits( 'postType', postType, postId );
* } );
* ```
*
* Note how the `getCurrentPostId` selector can be called just like any other function,
* (it works even inside a regular non-registry selector) and we don't need to pass the
* registry as argument. The registry binding happens automatically when registering the selector
* with a store.
*
* @param {Function} registrySelector Function receiving a registry `select`
* function and returning a state selector.
*
* @return {Function} Registry selector that can be registered with a store.
*/
export function createRegistrySelector( registrySelector ) {
const selectorsByRegistry = new WeakMap();
// Create a selector function that is bound to the registry referenced by `selector.registry`
// and that has the same API as a regular selector. Binding it in such a way makes it
// possible to call the selector directly from another selector.
const wrappedSelector = ( ...args ) => {
let selector = selectorsByRegistry.get( wrappedSelector.registry );
// We want to make sure the cache persists even when new registry
// instances are created. For example patterns create their own editors
// with their own core/block-editor stores, so we should keep track of
// the cache for each registry instance.
if ( ! selector ) {
selector = registrySelector( wrappedSelector.registry.select );
selectorsByRegistry.set( wrappedSelector.registry, selector );
}
return selector( ...args );
};
/**
* Flag indicating that the selector is a registry selector that needs the correct registry
* reference to be assigned to `selector.registry` to make it work correctly.
* be mapped as a registry selector.
*
* @type {boolean}
*/
wrappedSelector.isRegistrySelector = true;
return wrappedSelector;
}
/**
* Creates a control function that takes additional curried argument with the `registry` object.
* While a regular control has signature
* ```js
* ( action ) => ( iteratorOrPromise )
* ```
* where the control works with the `action` that it's bound to, a registry control has signature:
* ```js
* ( registry ) => ( action ) => ( iteratorOrPromise )
* ```
* A registry control is typically used to select data or dispatch an action to a registered
* store.
*
* When registering a control created with `createRegistryControl` with a store, the store
* knows which calling convention to use when executing the control.
*
* @param {Function} registryControl Function receiving a registry object and returning a control.
*
* @return {Function} Registry control that can be registered with a store.
*/
export function createRegistryControl( registryControl ) {
registryControl.isRegistryControl = true;
return registryControl;
}

191
node_modules/@wordpress/data/src/index.js generated vendored Normal file
View File

@@ -0,0 +1,191 @@
/**
* Internal dependencies
*/
import defaultRegistry from './default-registry';
import * as plugins from './plugins';
import { combineReducers as combineReducersModule } from './redux-store';
/** @typedef {import('./types').StoreDescriptor} StoreDescriptor */
export { default as withSelect } from './components/with-select';
export { default as withDispatch } from './components/with-dispatch';
export { default as withRegistry } from './components/with-registry';
export {
RegistryProvider,
RegistryConsumer,
useRegistry,
} from './components/registry-provider';
export {
default as useSelect,
useSuspenseSelect,
} from './components/use-select';
export { useDispatch } from './components/use-dispatch';
export { AsyncModeProvider } from './components/async-mode-provider';
export { createRegistry } from './registry';
export { createRegistrySelector, createRegistryControl } from './factory';
export { createSelector } from './create-selector';
export { controls } from './controls';
export { default as createReduxStore } from './redux-store';
export { dispatch } from './dispatch';
export { select } from './select';
/**
* Object of available plugins to use with a registry.
*
* @see [use](#use)
*
* @type {Object}
*/
export { plugins };
/**
* The combineReducers helper function turns an object whose values are different
* reducing functions into a single reducing function you can pass to registerReducer.
*
* @type {import('./types').combineReducers}
* @param {Object} reducers An object whose values correspond to different reducing
* functions that need to be combined into one.
*
* @example
* ```js
* import { combineReducers, createReduxStore, register } from '@wordpress/data';
*
* const prices = ( state = {}, action ) => {
* return action.type === 'SET_PRICE' ?
* {
* ...state,
* [ action.item ]: action.price,
* } :
* state;
* };
*
* const discountPercent = ( state = 0, action ) => {
* return action.type === 'START_SALE' ?
* action.discountPercent :
* state;
* };
*
* const store = createReduxStore( 'my-shop', {
* reducer: combineReducers( {
* prices,
* discountPercent,
* } ),
* } );
* register( store );
* ```
*
* @return {Function} A reducer that invokes every reducer inside the reducers
* object, and constructs a state object with the same shape.
*/
export const combineReducers = combineReducersModule;
/**
* Given a store descriptor, returns an object containing the store's selectors pre-bound to state
* so that you only need to supply additional arguments, and modified so that they return promises
* that resolve to their eventual values, after any resolvers have ran.
*
* @param {StoreDescriptor|string} storeNameOrDescriptor The store descriptor. The legacy calling
* convention of passing the store name is
* also supported.
*
* @example
* ```js
* import { resolveSelect } from '@wordpress/data';
* import { store as myCustomStore } from 'my-custom-store';
*
* resolveSelect( myCustomStore ).getPrice( 'hammer' ).then(console.log)
* ```
*
* @return {Object} Object containing the store's promise-wrapped selectors.
*/
export const resolveSelect = defaultRegistry.resolveSelect;
/**
* Given a store descriptor, returns an object containing the store's selectors pre-bound to state
* so that you only need to supply additional arguments, and modified so that they throw promises
* in case the selector is not resolved yet.
*
* @param {StoreDescriptor|string} storeNameOrDescriptor The store descriptor. The legacy calling
* convention of passing the store name is
* also supported.
*
* @return {Object} Object containing the store's suspense-wrapped selectors.
*/
export const suspendSelect = defaultRegistry.suspendSelect;
/**
* Given a listener function, the function will be called any time the state value
* of one of the registered stores has changed. If you specify the optional
* `storeNameOrDescriptor` parameter, the listener function will be called only
* on updates on that one specific registered store.
*
* This function returns an `unsubscribe` function used to stop the subscription.
*
* @param {Function} listener Callback function.
* @param {string|StoreDescriptor?} storeNameOrDescriptor Optional store name.
*
* @example
* ```js
* import { subscribe } from '@wordpress/data';
*
* const unsubscribe = subscribe( () => {
* // You could use this opportunity to test whether the derived result of a
* // selector has subsequently changed as the result of a state update.
* } );
*
* // Later, if necessary...
* unsubscribe();
* ```
*/
export const subscribe = defaultRegistry.subscribe;
/**
* Registers a generic store instance.
*
* @deprecated Use `register( storeDescriptor )` instead.
*
* @param {string} name Store registry name.
* @param {Object} store Store instance (`{ getSelectors, getActions, subscribe }`).
*/
export const registerGenericStore = defaultRegistry.registerGenericStore;
/**
* Registers a standard `@wordpress/data` store.
*
* @deprecated Use `register` instead.
*
* @param {string} storeName Unique namespace identifier for the store.
* @param {Object} options Store description (reducer, actions, selectors, resolvers).
*
* @return {Object} Registered store object.
*/
export const registerStore = defaultRegistry.registerStore;
/**
* Extends a registry to inherit functionality provided by a given plugin. A
* plugin is an object with properties aligning to that of a registry, merged
* to extend the default registry behavior.
*
* @param {Object} plugin Plugin object.
*/
export const use = defaultRegistry.use;
/**
* Registers a standard `@wordpress/data` store descriptor.
*
* @example
* ```js
* import { createReduxStore, register } from '@wordpress/data';
*
* const store = createReduxStore( 'demo', {
* reducer: ( state = 'OK' ) => state,
* selectors: {
* getValue: ( state ) => state,
* },
* } );
* register( store );
* ```
*
* @param {StoreDescriptor} store Store descriptor.
*/
export const register = defaultRegistry.register;

10
node_modules/@wordpress/data/src/lock-unlock.js generated vendored Normal file
View File

@@ -0,0 +1,10 @@
/**
* WordPress dependencies
*/
import { __dangerousOptInToUnstableAPIsOnlyForCoreModules } from '@wordpress/private-apis';
export const { lock, unlock } =
__dangerousOptInToUnstableAPIsOnlyForCoreModules(
'I know using unstable features means my theme or plugin will inevitably break in the next version of WordPress.',
'@wordpress/data'
);

16
node_modules/@wordpress/data/src/plugins/README.md generated vendored Normal file
View File

@@ -0,0 +1,16 @@
# Data Plugins
Included here are a set of default plugin integrations for the WordPress data module.
## Usage
For any of the plugins included here as directories, call the `use` method to include its behaviors in the registry.
```js
// npm Usage
import { plugins, use } from '@wordpress/data';
use( plugins.persistence );
// WordPress Globals Usage
wp.data.use( wp.data.plugins.persistence );
```

1
node_modules/@wordpress/data/src/plugins/index.js generated vendored Normal file
View File

@@ -0,0 +1 @@
export { default as persistence } from './persistence';

View File

@@ -0,0 +1,35 @@
# Persistence Plugin
The persistence plugin enhances a registry to enable registered stores to opt in to persistent storage.
By default, persistence occurs by [`localStorage`](https://developer.mozilla.org/en-US/docs/Web/API/Window/localStorage). In environments where `localStorage` is not available, it will gracefully fall back to an in-memory object storage which will not persist between sessions. You can provide your own storage implementation by providing the [`storage` option](#options). Unless set otherwise, state will be persisted on the `WP_DATA` key in storage.
## Usage
Call the `use` method on the default or your own registry to include the persistence plugin:
```js
wp.data.use( wp.data.plugins.persistence, { storageKey: 'example' } );
```
Then, when registering a store, set a `persist` property as `true` (persist all state) or an array of state keys to persist.
```js
wp.data.registerStore( 'my-plugin', {
// ...
persist: [ 'preferences' ],
} );
```
## Options
### `storage`
Persistent storage implementation. This must at least implement `getItem` and `setItem` of the Web Storage API.
See: https://developer.mozilla.org/en-US/docs/Web/API/Storage
### `storageKey`
The key on which to set in persistent storage.

View File

@@ -0,0 +1,227 @@
/**
* External dependencies
*/
import { isPlainObject } from 'is-plain-object';
import deepmerge from 'deepmerge';
/**
* Internal dependencies
*/
import defaultStorage from './storage/default';
import { combineReducers } from '../../';
/** @typedef {import('../../registry').WPDataRegistry} WPDataRegistry */
/** @typedef {import('../../registry').WPDataPlugin} WPDataPlugin */
/**
* @typedef {Object} WPDataPersistencePluginOptions Persistence plugin options.
*
* @property {Storage} storage Persistent storage implementation. This must
* at least implement `getItem` and `setItem` of
* the Web Storage API.
* @property {string} storageKey Key on which to set in persistent storage.
*/
/**
* Default plugin storage.
*
* @type {Storage}
*/
const DEFAULT_STORAGE = defaultStorage;
/**
* Default plugin storage key.
*
* @type {string}
*/
const DEFAULT_STORAGE_KEY = 'WP_DATA';
/**
* Higher-order reducer which invokes the original reducer only if state is
* inequal from that of the action's `nextState` property, otherwise returning
* the original state reference.
*
* @param {Function} reducer Original reducer.
*
* @return {Function} Enhanced reducer.
*/
export const withLazySameState = ( reducer ) => ( state, action ) => {
if ( action.nextState === state ) {
return state;
}
return reducer( state, action );
};
/**
* Creates a persistence interface, exposing getter and setter methods (`get`
* and `set` respectively).
*
* @param {WPDataPersistencePluginOptions} options Plugin options.
*
* @return {Object} Persistence interface.
*/
export function createPersistenceInterface( options ) {
const { storage = DEFAULT_STORAGE, storageKey = DEFAULT_STORAGE_KEY } =
options;
let data;
/**
* Returns the persisted data as an object, defaulting to an empty object.
*
* @return {Object} Persisted data.
*/
function getData() {
if ( data === undefined ) {
// If unset, getItem is expected to return null. Fall back to
// empty object.
const persisted = storage.getItem( storageKey );
if ( persisted === null ) {
data = {};
} else {
try {
data = JSON.parse( persisted );
} catch ( error ) {
// Similarly, should any error be thrown during parse of
// the string (malformed JSON), fall back to empty object.
data = {};
}
}
}
return data;
}
/**
* Merges an updated reducer state into the persisted data.
*
* @param {string} key Key to update.
* @param {*} value Updated value.
*/
function setData( key, value ) {
data = { ...data, [ key ]: value };
storage.setItem( storageKey, JSON.stringify( data ) );
}
return {
get: getData,
set: setData,
};
}
/**
* Data plugin to persist store state into a single storage key.
*
* @param {WPDataRegistry} registry Data registry.
* @param {?WPDataPersistencePluginOptions} pluginOptions Plugin options.
*
* @return {WPDataPlugin} Data plugin.
*/
function persistencePlugin( registry, pluginOptions ) {
const persistence = createPersistenceInterface( pluginOptions );
/**
* Creates an enhanced store dispatch function, triggering the state of the
* given store name to be persisted when changed.
*
* @param {Function} getState Function which returns current state.
* @param {string} storeName Store name.
* @param {?Array<string>} keys Optional subset of keys to save.
*
* @return {Function} Enhanced dispatch function.
*/
function createPersistOnChange( getState, storeName, keys ) {
let getPersistedState;
if ( Array.isArray( keys ) ) {
// Given keys, the persisted state should by produced as an object
// of the subset of keys. This implementation uses combineReducers
// to leverage its behavior of returning the same object when none
// of the property values changes. This allows a strict reference
// equality to bypass a persistence set on an unchanging state.
const reducers = keys.reduce(
( accumulator, key ) =>
Object.assign( accumulator, {
[ key ]: ( state, action ) => action.nextState[ key ],
} ),
{}
);
getPersistedState = withLazySameState(
combineReducers( reducers )
);
} else {
getPersistedState = ( state, action ) => action.nextState;
}
let lastState = getPersistedState( undefined, {
nextState: getState(),
} );
return () => {
const state = getPersistedState( lastState, {
nextState: getState(),
} );
if ( state !== lastState ) {
persistence.set( storeName, state );
lastState = state;
}
};
}
return {
registerStore( storeName, options ) {
if ( ! options.persist ) {
return registry.registerStore( storeName, options );
}
// Load from persistence to use as initial state.
const persistedState = persistence.get()[ storeName ];
if ( persistedState !== undefined ) {
let initialState = options.reducer( options.initialState, {
type: '@@WP/PERSISTENCE_RESTORE',
} );
if (
isPlainObject( initialState ) &&
isPlainObject( persistedState )
) {
// If state is an object, ensure that:
// - Other keys are left intact when persisting only a
// subset of keys.
// - New keys in what would otherwise be used as initial
// state are deeply merged as base for persisted value.
initialState = deepmerge( initialState, persistedState, {
isMergeableObject: isPlainObject,
} );
} else {
// If there is a mismatch in object-likeness of default
// initial or persisted state, defer to persisted value.
initialState = persistedState;
}
options = {
...options,
initialState,
};
}
const store = registry.registerStore( storeName, options );
store.subscribe(
createPersistOnChange(
store.getState,
storeName,
options.persist
)
);
return store;
},
};
}
persistencePlugin.__unstableMigrate = () => {};
export default persistencePlugin;

View File

@@ -0,0 +1,19 @@
/**
* Internal dependencies
*/
import objectStorage from './object';
let storage;
try {
// Private Browsing in Safari 10 and earlier will throw an error when
// attempting to set into localStorage. The test here is intentional in
// causing a thrown error as condition for using fallback object storage.
storage = window.localStorage;
storage.setItem( '__wpDataTestLocalStorage', '' );
storage.removeItem( '__wpDataTestLocalStorage' );
} catch ( error ) {
storage = objectStorage;
}
export default storage;

View File

@@ -0,0 +1,23 @@
let objectStorage;
const storage = {
getItem( key ) {
if ( ! objectStorage || ! objectStorage[ key ] ) {
return null;
}
return objectStorage[ key ];
},
setItem( key, value ) {
if ( ! objectStorage ) {
storage.clear();
}
objectStorage[ key ] = String( value );
},
clear() {
objectStorage = Object.create( null );
},
};
export default storage;

View File

@@ -0,0 +1,41 @@
/**
* Internal dependencies
*/
import objectStorage from '../object';
describe( 'objectStorage', () => {
beforeEach( () => {
objectStorage.clear();
} );
describe( 'getItem', () => {
it( 'should return null if there is no item with key', () => {
const result = objectStorage.getItem( 'foo' );
expect( result ).toBe( null );
} );
it( 'should return the value of assigned item', () => {
objectStorage.setItem( 'foo', 'bar' );
const result = objectStorage.getItem( 'foo' );
expect( result ).toBe( 'bar' );
} );
} );
describe( 'setItem', () => {
it( 'should set item by key for future retrieval', () => {
objectStorage.setItem( 'foo', 'bar' );
const result = objectStorage.getItem( 'foo' );
expect( result ).toBe( 'bar' );
} );
it( 'should assign as string', () => {
objectStorage.setItem( 'foo', null );
const result = objectStorage.getItem( 'foo' );
expect( result ).toBe( 'null' );
} );
} );
} );

View File

@@ -0,0 +1,380 @@
/**
* External dependencies
*/
import deepFreeze from 'deep-freeze';
/**
* Internal dependencies
*/
import plugin, { createPersistenceInterface, withLazySameState } from '../';
import objectStorage from '../storage/object';
import { createRegistry } from '../../../';
describe( 'persistence', () => {
let registry;
beforeAll( () => {
jest.spyOn( objectStorage, 'setItem' );
} );
beforeEach( () => {
objectStorage.clear();
objectStorage.setItem.mockClear();
// TODO: Remove the `use` function in favor of `registerGenericStore`
registry = createRegistry().use( plugin, { storage: objectStorage } );
} );
it( 'should not mutate options', () => {
expect( () => {
const options = Object.freeze( { persist: true, reducer() {} } );
registry.registerStore( 'test', options );
} ).not.toThrow( /object is not extensible/ );
} );
it( 'should load a persisted value as initialState', () => {
registry = createRegistry().use( plugin, {
storage: {
getItem: () => JSON.stringify( { test: { a: 1 } } ),
setItem() {},
},
} );
registry.registerStore( 'test', {
persist: true,
reducer: ( state = {} ) => state,
selectors: {
getState: ( state ) => state,
},
} );
expect( registry.select( 'test' ).getState() ).toEqual( { a: 1 } );
} );
it( 'should load a persisted subset value as initialState', () => {
const DEFAULT_STATE = { a: null, b: null };
registry = createRegistry().use( plugin, {
storage: {
getItem: () => JSON.stringify( { test: { a: 1 } } ),
setItem() {},
},
} );
registry.registerStore( 'test', {
persist: [ 'a' ],
reducer: ( state = DEFAULT_STATE ) => state,
selectors: {
getState: ( state ) => state,
},
} );
expect( registry.select( 'test' ).getState() ).toEqual( {
a: 1,
b: null,
} );
} );
it( 'should merge persisted value with default if object-like', () => {
const DEFAULT_STATE = deepFreeze( {
preferences: { useFoo: true, useBar: true },
} );
registry = createRegistry().use( plugin, {
storage: {
getItem: () =>
JSON.stringify( {
test: {
preferences: {
useFoo: false,
},
},
} ),
setItem() {},
},
} );
registry.registerStore( 'test', {
persist: [ 'preferences' ],
reducer: ( state = DEFAULT_STATE ) => state,
selectors: {
getState: ( state ) => state,
},
} );
expect( registry.select( 'test' ).getState() ).toEqual( {
preferences: {
useFoo: false,
useBar: true,
},
} );
} );
it( 'should defer to persisted state if mismatch of object-like (persisted object-like)', () => {
registry = createRegistry().use( plugin, {
storage: {
getItem: () => JSON.stringify( { test: { persisted: true } } ),
setItem() {},
},
} );
registry.registerStore( 'test', {
persist: true,
reducer: ( state = null ) => state,
selectors: {
getState: ( state ) => state,
},
} );
expect( registry.select( 'test' ).getState() ).toEqual( {
persisted: true,
} );
} );
it( 'should defer to persisted state if mismatch of object-like (initial object-like)', () => {
registry = createRegistry().use( plugin, {
storage: {
getItem: () => JSON.stringify( { test: null } ),
setItem() {},
},
} );
registry.registerStore( 'test', {
persist: true,
reducer: ( state = {} ) => state,
selectors: {
getState: ( state ) => state,
},
} );
expect( registry.select( 'test' ).getState() ).toBe( null );
} );
it( 'should be reasonably tolerant to a non-object persisted state', () => {
registry = createRegistry().use( plugin, {
storage: {
getItem: () =>
JSON.stringify( {
test: 1,
} ),
setItem() {},
},
} );
registry.registerStore( 'test', {
persist: true,
reducer: ( state = null ) => state,
selectors: {
getState: ( state ) => state,
},
} );
expect( registry.select( 'test' ).getState() ).toBe( 1 );
} );
it( 'should not persist if option not passed', () => {
const initialState = { foo: 'bar', baz: 'qux' };
function reducer( state = initialState, action ) {
return action.nextState || state;
}
registry.registerStore( 'test', {
reducer,
actions: {
setState( nextState ) {
return { type: 'SET_STATE', nextState };
},
},
} );
registry.dispatch( 'test' ).setState( { ok: true } );
expect( objectStorage.setItem ).not.toHaveBeenCalled();
} );
it( 'should not persist when state matches initial', () => {
// Caveat: State is compared by strict equality. This doesn't work for
// object types in rehydrating from persistence, since:
// JSON.parse( {} ) !== JSON.parse( {} )
// It's more important for runtime to check equal-ness, which is
// expected to be reflected even for object types by reducer.
const state = 1;
const reducer = () => state;
objectStorage.setItem( 'WP_DATA', JSON.stringify( { test: state } ) );
objectStorage.setItem.mockClear();
registry.registerStore( 'test', {
reducer,
persist: true,
actions: {
doNothing() {
return { type: 'NOTHING' };
},
},
} );
registry.dispatch( 'test' ).doNothing();
expect( objectStorage.setItem ).not.toHaveBeenCalled();
} );
it( 'should persist when state changes', () => {
const initialState = { foo: 'bar', baz: 'qux' };
function reducer( state = initialState, action ) {
return action.nextState || state;
}
registry.registerStore( 'test', {
reducer,
persist: true,
actions: {
setState( nextState ) {
return { type: 'SET_STATE', nextState };
},
},
} );
registry.dispatch( 'test' ).setState( { ok: true } );
expect( objectStorage.setItem ).toHaveBeenCalledWith(
'WP_DATA',
'{"test":{"ok":true}}'
);
} );
it( 'should persist a subset of keys', () => {
const initialState = { foo: 'bar', baz: 'qux' };
function reducer( state = initialState, action ) {
return action.nextState || state;
}
registry.registerStore( 'test', {
reducer,
persist: [ 'foo' ],
actions: {
setState( nextState ) {
return { type: 'SET_STATE', nextState };
},
},
} );
registry.dispatch( 'test' ).setState( { foo: 1, baz: 2 } );
expect( objectStorage.setItem ).toHaveBeenCalledWith(
'WP_DATA',
'{"test":{"foo":1}}'
);
} );
it( 'should not persist an unchanging subset', () => {
const initialState = { foo: 'bar' };
function reducer( state = initialState, action ) {
const { type, key, value } = action;
if ( type === 'SET_KEY_VALUE' ) {
return { ...state, [ key ]: value };
}
return state;
}
registry.registerStore( 'test', {
reducer,
persist: [ 'foo' ],
actions: {
setKeyValue( key, value ) {
return { type: 'SET_KEY_VALUE', key, value };
},
},
} );
registry.dispatch( 'test' ).setKeyValue( 'foo', 1 );
objectStorage.setItem.mockClear();
registry.dispatch( 'test' ).setKeyValue( 'foo', 1 );
expect( objectStorage.setItem ).not.toHaveBeenCalled();
} );
describe( 'createPersistenceInterface', () => {
const storage = objectStorage;
const storageKey = 'FOO';
let get, set;
beforeEach( () => {
( { get, set } = createPersistenceInterface( {
storage,
storageKey,
} ) );
} );
describe( 'get', () => {
it( 'returns an empty object if not set', () => {
const data = get();
expect( data ).toEqual( {} );
} );
it( 'returns the current value', () => {
objectStorage.setItem( storageKey, '{"test":{}}' );
const data = get();
expect( data ).toEqual( { test: {} } );
} );
} );
describe( 'set', () => {
it( 'sets JSON by object', () => {
set( 'test', {} );
expect( objectStorage.setItem ).toHaveBeenCalledWith(
storageKey,
'{"test":{}}'
);
} );
it( 'merges to existing', () => {
set( 'test1', {} );
set( 'test2', {} );
expect( objectStorage.setItem ).toHaveBeenCalledWith(
storageKey,
'{"test1":{}}'
);
expect( objectStorage.setItem ).toHaveBeenCalledWith(
storageKey,
'{"test1":{},"test2":{}}'
);
} );
} );
} );
describe( 'withLazySameState', () => {
it( 'should call the original reducer if action.nextState differs from state', () => {
const reducer = jest
.fn()
.mockImplementation( ( state, action ) => action.nextState );
const enhanced = withLazySameState( reducer );
reducer.mockClear();
const state = enhanced( 1, { nextState: 2 } );
expect( state ).toBe( 2 );
expect( reducer ).toHaveBeenCalled();
} );
it( 'should not call the original reducer if action.nextState equals state', () => {
const reducer = jest
.fn()
.mockImplementation( ( state, action ) => action.nextState );
const enhanced = withLazySameState( reducer );
reducer.mockClear();
const state = enhanced( 1, { nextState: 1 } );
expect( state ).toBe( 1 );
expect( reducer ).not.toHaveBeenCalled();
} );
} );
} );

23
node_modules/@wordpress/data/src/promise-middleware.js generated vendored Normal file
View File

@@ -0,0 +1,23 @@
/**
* External dependencies
*/
import isPromise from 'is-promise';
/**
* Simplest possible promise redux middleware.
*
* @type {import('redux').Middleware}
*/
const promiseMiddleware = () => ( next ) => ( action ) => {
if ( isPromise( action ) ) {
return action.then( ( resolvedAction ) => {
if ( resolvedAction ) {
return next( resolvedAction );
}
} );
}
return next( action );
};
export default promiseMiddleware;

View File

@@ -0,0 +1,17 @@
export function combineReducers( reducers ) {
const keys = Object.keys( reducers );
return function combinedReducer( state = {}, action ) {
const nextState = {};
let hasChanged = false;
for ( const key of keys ) {
const reducer = reducers[ key ];
const prevStateForKey = state[ key ];
const nextStateForKey = reducer( prevStateForKey, action );
nextState[ key ] = nextStateForKey;
hasChanged = hasChanged || nextStateForKey !== prevStateForKey;
}
return hasChanged ? nextState : state;
};
}

663
node_modules/@wordpress/data/src/redux-store/index.js generated vendored Normal file
View File

@@ -0,0 +1,663 @@
/**
* External dependencies
*/
import { createStore, applyMiddleware } from 'redux';
import EquivalentKeyMap from 'equivalent-key-map';
/**
* WordPress dependencies
*/
import createReduxRoutineMiddleware from '@wordpress/redux-routine';
import { compose } from '@wordpress/compose';
/**
* Internal dependencies
*/
import { combineReducers } from './combine-reducers';
import { builtinControls } from '../controls';
import { lock } from '../lock-unlock';
import promise from '../promise-middleware';
import createResolversCacheMiddleware from '../resolvers-cache-middleware';
import createThunkMiddleware from './thunk-middleware';
import metadataReducer from './metadata/reducer';
import * as metadataSelectors from './metadata/selectors';
import * as metadataActions from './metadata/actions';
export { combineReducers };
/** @typedef {import('../types').DataRegistry} DataRegistry */
/** @typedef {import('../types').ListenerFunction} ListenerFunction */
/**
* @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
*/
const trimUndefinedValues = ( array ) => {
const result = [ ...array ];
for ( let i = result.length - 1; i >= 0; i-- ) {
if ( result[ i ] === undefined ) {
result.splice( i, 1 );
}
}
return result;
};
/**
* Creates a new object with the same keys, but with `callback()` called as
* a transformer function on each of the values.
*
* @param {Object} obj The object to transform.
* @param {Function} callback The function to transform each object value.
* @return {Array} Transformed object.
*/
const mapValues = ( obj, callback ) =>
Object.fromEntries(
Object.entries( obj ?? {} ).map( ( [ key, value ] ) => [
key,
callback( value, key ),
] )
);
// Convert non serializable types to plain objects
const devToolsReplacer = ( key, state ) => {
if ( state instanceof Map ) {
return Object.fromEntries( state );
}
if ( state instanceof window.HTMLElement ) {
return null;
}
return state;
};
/**
* Create a cache to track whether resolvers started running or not.
*
* @return {Object} Resolvers Cache.
*/
function createResolversCache() {
const cache = {};
return {
isRunning( selectorName, args ) {
return (
cache[ selectorName ] &&
cache[ selectorName ].get( trimUndefinedValues( args ) )
);
},
clear( selectorName, args ) {
if ( cache[ selectorName ] ) {
cache[ selectorName ].delete( trimUndefinedValues( args ) );
}
},
markAsRunning( selectorName, args ) {
if ( ! cache[ selectorName ] ) {
cache[ selectorName ] = new EquivalentKeyMap();
}
cache[ selectorName ].set( trimUndefinedValues( args ), true );
},
};
}
function createBindingCache( bind ) {
const cache = new WeakMap();
return {
get( item, itemName ) {
let boundItem = cache.get( item );
if ( ! boundItem ) {
boundItem = bind( item, itemName );
cache.set( item, boundItem );
}
return boundItem;
},
};
}
/**
* Creates a data store descriptor for the provided Redux store configuration containing
* properties describing reducer, actions, selectors, controls and resolvers.
*
* @example
* ```js
* import { createReduxStore } from '@wordpress/data';
*
* const store = createReduxStore( 'demo', {
* reducer: ( state = 'OK' ) => state,
* selectors: {
* getValue: ( state ) => state,
* },
* } );
* ```
*
* @template State
* @template {Record<string,import('../types').ActionCreator>} Actions
* @template Selectors
* @param {string} key Unique namespace identifier.
* @param {ReduxStoreConfig<State,Actions,Selectors>} options Registered store options, with properties
* describing reducer, actions, selectors,
* and resolvers.
*
* @return {StoreDescriptor<ReduxStoreConfig<State,Actions,Selectors>>} Store Object.
*/
export default function createReduxStore( key, options ) {
const privateActions = {};
const privateSelectors = {};
const privateRegistrationFunctions = {
privateActions,
registerPrivateActions: ( actions ) => {
Object.assign( privateActions, actions );
},
privateSelectors,
registerPrivateSelectors: ( selectors ) => {
Object.assign( privateSelectors, selectors );
},
};
const storeDescriptor = {
name: key,
instantiate: ( registry ) => {
/**
* Stores listener functions registered with `subscribe()`.
*
* When functions register to listen to store changes with
* `subscribe()` they get added here. Although Redux offers
* its own `subscribe()` function directly, by wrapping the
* subscription in this store instance it's possible to
* optimize checking if the state has changed before calling
* each listener.
*
* @type {Set<ListenerFunction>}
*/
const listeners = new Set();
const reducer = options.reducer;
const thunkArgs = {
registry,
get dispatch() {
return thunkActions;
},
get select() {
return thunkSelectors;
},
get resolveSelect() {
return getResolveSelectors();
},
};
const store = instantiateReduxStore(
key,
options,
registry,
thunkArgs
);
// Expose the private registration functions on the store
// so they can be copied to a sub registry in registry.js.
lock( store, privateRegistrationFunctions );
const resolversCache = createResolversCache();
function bindAction( action ) {
return ( ...args ) =>
Promise.resolve( store.dispatch( action( ...args ) ) );
}
const actions = {
...mapValues( metadataActions, bindAction ),
...mapValues( options.actions, bindAction ),
};
const boundPrivateActions = createBindingCache( bindAction );
const allActions = new Proxy( () => {}, {
get: ( target, prop ) => {
const privateAction = privateActions[ prop ];
return privateAction
? boundPrivateActions.get( privateAction, prop )
: actions[ prop ];
},
} );
const thunkActions = new Proxy( allActions, {
apply: ( target, thisArg, [ action ] ) =>
store.dispatch( action ),
} );
lock( actions, allActions );
const resolvers = options.resolvers
? mapResolvers( options.resolvers )
: {};
function bindSelector( selector, selectorName ) {
if ( selector.isRegistrySelector ) {
selector.registry = registry;
}
const boundSelector = ( ...args ) => {
args = normalize( selector, args );
const state = store.__unstableOriginalGetState();
// Before calling the selector, switch to the correct
// registry.
if ( selector.isRegistrySelector ) {
selector.registry = registry;
}
return selector( state.root, ...args );
};
// Expose normalization method on the bound selector
// in order that it can be called when fullfilling
// the resolver.
boundSelector.__unstableNormalizeArgs =
selector.__unstableNormalizeArgs;
const resolver = resolvers[ selectorName ];
if ( ! resolver ) {
boundSelector.hasResolver = false;
return boundSelector;
}
return mapSelectorWithResolver(
boundSelector,
selectorName,
resolver,
store,
resolversCache
);
}
function bindMetadataSelector( metaDataSelector ) {
const boundSelector = ( ...args ) => {
const state = store.__unstableOriginalGetState();
const originalSelectorName = args && args[ 0 ];
const originalSelectorArgs = args && args[ 1 ];
const targetSelector =
options?.selectors?.[ originalSelectorName ];
// Normalize the arguments passed to the target selector.
if ( originalSelectorName && targetSelector ) {
args[ 1 ] = normalize(
targetSelector,
originalSelectorArgs
);
}
return metaDataSelector( state.metadata, ...args );
};
boundSelector.hasResolver = false;
return boundSelector;
}
const selectors = {
...mapValues( metadataSelectors, bindMetadataSelector ),
...mapValues( options.selectors, bindSelector ),
};
const boundPrivateSelectors = createBindingCache( bindSelector );
// Pre-bind the private selectors that have been registered by the time of
// instantiation, so that registry selectors are bound to the registry.
for ( const [ selectorName, selector ] of Object.entries(
privateSelectors
) ) {
boundPrivateSelectors.get( selector, selectorName );
}
const allSelectors = new Proxy( () => {}, {
get: ( target, prop ) => {
const privateSelector = privateSelectors[ prop ];
return privateSelector
? boundPrivateSelectors.get( privateSelector, prop )
: selectors[ prop ];
},
} );
const thunkSelectors = new Proxy( allSelectors, {
apply: ( target, thisArg, [ selector ] ) =>
selector( store.__unstableOriginalGetState() ),
} );
lock( selectors, allSelectors );
const resolveSelectors = mapResolveSelectors( selectors, store );
const suspendSelectors = mapSuspendSelectors( selectors, store );
const getSelectors = () => selectors;
const getActions = () => actions;
const getResolveSelectors = () => resolveSelectors;
const getSuspendSelectors = () => suspendSelectors;
// We have some modules monkey-patching the store object
// It's wrong to do so but until we refactor all of our effects to controls
// We need to keep the same "store" instance here.
store.__unstableOriginalGetState = store.getState;
store.getState = () => store.__unstableOriginalGetState().root;
// Customize subscribe behavior to call listeners only on effective change,
// not on every dispatch.
const subscribe =
store &&
( ( listener ) => {
listeners.add( listener );
return () => listeners.delete( listener );
} );
let lastState = store.__unstableOriginalGetState();
store.subscribe( () => {
const state = store.__unstableOriginalGetState();
const hasChanged = state !== lastState;
lastState = state;
if ( hasChanged ) {
for ( const listener of listeners ) {
listener();
}
}
} );
// This can be simplified to just { subscribe, getSelectors, getActions }
// Once we remove the use function.
return {
reducer,
store,
actions,
selectors,
resolvers,
getSelectors,
getResolveSelectors,
getSuspendSelectors,
getActions,
subscribe,
};
},
};
// Expose the private registration functions on the store
// descriptor. That's a natural choice since that's where the
// public actions and selectors are stored .
lock( storeDescriptor, privateRegistrationFunctions );
return storeDescriptor;
}
/**
* Creates a redux store for a namespace.
*
* @param {string} key Unique namespace identifier.
* @param {Object} options Registered store options, with properties
* describing reducer, actions, selectors,
* and resolvers.
* @param {DataRegistry} registry Registry reference.
* @param {Object} thunkArgs Argument object for the thunk middleware.
* @return {Object} Newly created redux store.
*/
function instantiateReduxStore( key, options, registry, thunkArgs ) {
const controls = {
...options.controls,
...builtinControls,
};
const normalizedControls = mapValues( controls, ( control ) =>
control.isRegistryControl ? control( registry ) : control
);
const middlewares = [
createResolversCacheMiddleware( registry, key ),
promise,
createReduxRoutineMiddleware( normalizedControls ),
createThunkMiddleware( thunkArgs ),
];
const enhancers = [ applyMiddleware( ...middlewares ) ];
if (
typeof window !== 'undefined' &&
window.__REDUX_DEVTOOLS_EXTENSION__
) {
enhancers.push(
window.__REDUX_DEVTOOLS_EXTENSION__( {
name: key,
instanceId: key,
serialize: {
replacer: devToolsReplacer,
},
} )
);
}
const { reducer, initialState } = options;
const enhancedReducer = combineReducers( {
metadata: metadataReducer,
root: reducer,
} );
return createStore(
enhancedReducer,
{ root: initialState },
compose( enhancers )
);
}
/**
* Maps selectors to functions that return a resolution promise for them
*
* @param {Object} selectors Selectors to map.
* @param {Object} store The redux store the selectors select from.
*
* @return {Object} Selectors mapped to their resolution functions.
*/
function mapResolveSelectors( selectors, store ) {
const {
getIsResolving,
hasStartedResolution,
hasFinishedResolution,
hasResolutionFailed,
isResolving,
getCachedResolvers,
getResolutionState,
getResolutionError,
hasResolvingSelectors,
countSelectorsByStatus,
...storeSelectors
} = selectors;
return mapValues( storeSelectors, ( selector, selectorName ) => {
// If the selector doesn't have a resolver, just convert the return value
// (including exceptions) to a Promise, no additional extra behavior is needed.
if ( ! selector.hasResolver ) {
return async ( ...args ) => selector.apply( null, args );
}
return ( ...args ) => {
return new Promise( ( resolve, reject ) => {
const hasFinished = () =>
selectors.hasFinishedResolution( selectorName, args );
const finalize = ( result ) => {
const hasFailed = selectors.hasResolutionFailed(
selectorName,
args
);
if ( hasFailed ) {
const error = selectors.getResolutionError(
selectorName,
args
);
reject( error );
} else {
resolve( result );
}
};
const getResult = () => selector.apply( null, args );
// Trigger the selector (to trigger the resolver)
const result = getResult();
if ( hasFinished() ) {
return finalize( result );
}
const unsubscribe = store.subscribe( () => {
if ( hasFinished() ) {
unsubscribe();
finalize( getResult() );
}
} );
} );
};
} );
}
/**
* Maps selectors to functions that throw a suspense promise if not yet resolved.
*
* @param {Object} selectors Selectors to map.
* @param {Object} store The redux store the selectors select from.
*
* @return {Object} Selectors mapped to their suspense functions.
*/
function mapSuspendSelectors( selectors, store ) {
return mapValues( selectors, ( selector, selectorName ) => {
// Selector without a resolver doesn't have any extra suspense behavior.
if ( ! selector.hasResolver ) {
return selector;
}
return ( ...args ) => {
const result = selector.apply( null, args );
if ( selectors.hasFinishedResolution( selectorName, args ) ) {
if ( selectors.hasResolutionFailed( selectorName, args ) ) {
throw selectors.getResolutionError( selectorName, args );
}
return result;
}
throw new Promise( ( resolve ) => {
const unsubscribe = store.subscribe( () => {
if (
selectors.hasFinishedResolution( selectorName, args )
) {
resolve();
unsubscribe();
}
} );
} );
};
} );
}
/**
* Convert resolvers to a normalized form, an object with `fulfill` method and
* optional methods like `isFulfilled`.
*
* @param {Object} resolvers Resolver to convert
*/
function mapResolvers( resolvers ) {
return mapValues( resolvers, ( resolver ) => {
if ( resolver.fulfill ) {
return resolver;
}
return {
...resolver, // Copy the enumerable properties of the resolver function.
fulfill: resolver, // Add the fulfill method.
};
} );
}
/**
* Returns a selector with a matched resolver.
* Resolvers are side effects invoked once per argument set of a given selector call,
* used in ensuring that the data needs for the selector are satisfied.
*
* @param {Object} selector The selector function to be bound.
* @param {string} selectorName The selector name.
* @param {Object} resolver Resolver to call.
* @param {Object} store The redux store to which the resolvers should be mapped.
* @param {Object} resolversCache Resolvers Cache.
*/
function mapSelectorWithResolver(
selector,
selectorName,
resolver,
store,
resolversCache
) {
function fulfillSelector( args ) {
const state = store.getState();
if (
resolversCache.isRunning( selectorName, args ) ||
( typeof resolver.isFulfilled === 'function' &&
resolver.isFulfilled( state, ...args ) )
) {
return;
}
const { metadata } = store.__unstableOriginalGetState();
if (
metadataSelectors.hasStartedResolution(
metadata,
selectorName,
args
)
) {
return;
}
resolversCache.markAsRunning( selectorName, args );
setTimeout( async () => {
resolversCache.clear( selectorName, args );
store.dispatch(
metadataActions.startResolution( selectorName, args )
);
try {
const action = resolver.fulfill( ...args );
if ( action ) {
await store.dispatch( action );
}
store.dispatch(
metadataActions.finishResolution( selectorName, args )
);
} catch ( error ) {
store.dispatch(
metadataActions.failResolution( selectorName, args, error )
);
}
}, 0 );
}
const selectorResolver = ( ...args ) => {
args = normalize( selector, args );
fulfillSelector( args );
return selector( ...args );
};
selectorResolver.hasResolver = true;
return selectorResolver;
}
/**
* Applies selector's normalization function to the given arguments
* if it exists.
*
* @param {Object} selector The selector potentially with a normalization method property.
* @param {Array} args selector arguments to normalize.
* @return {Array} Potentially normalized arguments.
*/
function normalize( selector, args ) {
if (
selector.__unstableNormalizeArgs &&
typeof selector.__unstableNormalizeArgs === 'function' &&
args?.length
) {
return selector.__unstableNormalizeArgs( args );
}
return args;
}

View File

@@ -0,0 +1,152 @@
/**
* Returns an action object used in signalling that selector resolution has
* started.
*
* @param {string} selectorName Name of selector for which resolver triggered.
* @param {unknown[]} args Arguments to associate for uniqueness.
*
* @return {{ type: 'START_RESOLUTION', selectorName: string, args: unknown[] }} Action object.
*/
export function startResolution( selectorName, args ) {
return {
type: 'START_RESOLUTION',
selectorName,
args,
};
}
/**
* Returns an action object used in signalling that selector resolution has
* completed.
*
* @param {string} selectorName Name of selector for which resolver triggered.
* @param {unknown[]} args Arguments to associate for uniqueness.
*
* @return {{ type: 'FINISH_RESOLUTION', selectorName: string, args: unknown[] }} Action object.
*/
export function finishResolution( selectorName, args ) {
return {
type: 'FINISH_RESOLUTION',
selectorName,
args,
};
}
/**
* Returns an action object used in signalling that selector resolution has
* failed.
*
* @param {string} selectorName Name of selector for which resolver triggered.
* @param {unknown[]} args Arguments to associate for uniqueness.
* @param {Error|unknown} error The error that caused the failure.
*
* @return {{ type: 'FAIL_RESOLUTION', selectorName: string, args: unknown[], error: Error|unknown }} Action object.
*/
export function failResolution( selectorName, args, error ) {
return {
type: 'FAIL_RESOLUTION',
selectorName,
args,
error,
};
}
/**
* Returns an action object used in signalling that a batch of selector resolutions has
* started.
*
* @param {string} selectorName Name of selector for which resolver triggered.
* @param {unknown[][]} args Array of arguments to associate for uniqueness, each item
* is associated to a resolution.
*
* @return {{ type: 'START_RESOLUTIONS', selectorName: string, args: unknown[][] }} Action object.
*/
export function startResolutions( selectorName, args ) {
return {
type: 'START_RESOLUTIONS',
selectorName,
args,
};
}
/**
* Returns an action object used in signalling that a batch of selector resolutions has
* completed.
*
* @param {string} selectorName Name of selector for which resolver triggered.
* @param {unknown[][]} args Array of arguments to associate for uniqueness, each item
* is associated to a resolution.
*
* @return {{ type: 'FINISH_RESOLUTIONS', selectorName: string, args: unknown[][] }} Action object.
*/
export function finishResolutions( selectorName, args ) {
return {
type: 'FINISH_RESOLUTIONS',
selectorName,
args,
};
}
/**
* Returns an action object used in signalling that a batch of selector resolutions has
* completed and at least one of them has failed.
*
* @param {string} selectorName Name of selector for which resolver triggered.
* @param {unknown[]} args Array of arguments to associate for uniqueness, each item
* is associated to a resolution.
* @param {(Error|unknown)[]} errors Array of errors to associate for uniqueness, each item
* is associated to a resolution.
* @return {{ type: 'FAIL_RESOLUTIONS', selectorName: string, args: unknown[], errors: Array<Error|unknown> }} Action object.
*/
export function failResolutions( selectorName, args, errors ) {
return {
type: 'FAIL_RESOLUTIONS',
selectorName,
args,
errors,
};
}
/**
* Returns an action object used in signalling that we should invalidate the resolution cache.
*
* @param {string} selectorName Name of selector for which resolver should be invalidated.
* @param {unknown[]} args Arguments to associate for uniqueness.
*
* @return {{ type: 'INVALIDATE_RESOLUTION', selectorName: string, args: any[] }} Action object.
*/
export function invalidateResolution( selectorName, args ) {
return {
type: 'INVALIDATE_RESOLUTION',
selectorName,
args,
};
}
/**
* Returns an action object used in signalling that the resolution
* should be invalidated.
*
* @return {{ type: 'INVALIDATE_RESOLUTION_FOR_STORE' }} Action object.
*/
export function invalidateResolutionForStore() {
return {
type: 'INVALIDATE_RESOLUTION_FOR_STORE',
};
}
/**
* Returns an action object used in signalling that the resolution cache for a
* given selectorName should be invalidated.
*
* @param {string} selectorName Name of selector for which all resolvers should
* be invalidated.
*
* @return {{ type: 'INVALIDATE_RESOLUTION_FOR_STORE_SELECTOR', selectorName: string }} Action object.
*/
export function invalidateResolutionForStoreSelector( selectorName ) {
return {
type: 'INVALIDATE_RESOLUTION_FOR_STORE_SELECTOR',
selectorName,
};
}

View File

@@ -0,0 +1,4 @@
declare module 'equivalent-key-map' {
class EquivalentKeyMap< K, V > extends Map< K, V > {}
export = EquivalentKeyMap;
}

View File

@@ -0,0 +1,149 @@
/**
* External dependencies
*/
import EquivalentKeyMap from 'equivalent-key-map';
import type { Reducer } from 'redux';
/**
* Internal dependencies
*/
import { selectorArgsToStateKey, onSubKey } from './utils';
type Action =
| ReturnType< typeof import('./actions').startResolution >
| ReturnType< typeof import('./actions').finishResolution >
| ReturnType< typeof import('./actions').failResolution >
| ReturnType< typeof import('./actions').startResolutions >
| ReturnType< typeof import('./actions').finishResolutions >
| ReturnType< typeof import('./actions').failResolutions >
| ReturnType< typeof import('./actions').invalidateResolution >
| ReturnType< typeof import('./actions').invalidateResolutionForStore >
| ReturnType<
typeof import('./actions').invalidateResolutionForStoreSelector
>;
type StateKey = unknown[] | unknown;
export type StateValue =
| { status: 'resolving' | 'finished' }
| { status: 'error'; error: Error | unknown };
export type Status = StateValue[ 'status' ];
export type State = EquivalentKeyMap< StateKey, StateValue >;
/**
* Reducer function returning next state for selector resolution of
* subkeys, object form:
*
* selectorName -> EquivalentKeyMap<Array,boolean>
*/
const subKeysIsResolved: Reducer< Record< string, State >, Action > = onSubKey<
State,
Action
>( 'selectorName' )( ( state = new EquivalentKeyMap(), action: Action ) => {
switch ( action.type ) {
case 'START_RESOLUTION': {
const nextState = new EquivalentKeyMap( state );
nextState.set( selectorArgsToStateKey( action.args ), {
status: 'resolving',
} );
return nextState;
}
case 'FINISH_RESOLUTION': {
const nextState = new EquivalentKeyMap( state );
nextState.set( selectorArgsToStateKey( action.args ), {
status: 'finished',
} );
return nextState;
}
case 'FAIL_RESOLUTION': {
const nextState = new EquivalentKeyMap( state );
nextState.set( selectorArgsToStateKey( action.args ), {
status: 'error',
error: action.error,
} );
return nextState;
}
case 'START_RESOLUTIONS': {
const nextState = new EquivalentKeyMap( state );
for ( const resolutionArgs of action.args ) {
nextState.set( selectorArgsToStateKey( resolutionArgs ), {
status: 'resolving',
} );
}
return nextState;
}
case 'FINISH_RESOLUTIONS': {
const nextState = new EquivalentKeyMap( state );
for ( const resolutionArgs of action.args ) {
nextState.set( selectorArgsToStateKey( resolutionArgs ), {
status: 'finished',
} );
}
return nextState;
}
case 'FAIL_RESOLUTIONS': {
const nextState = new EquivalentKeyMap( state );
action.args.forEach( ( resolutionArgs, idx ) => {
const resolutionState: StateValue = {
status: 'error',
error: undefined,
};
const error = action.errors[ idx ];
if ( error ) {
resolutionState.error = error;
}
nextState.set(
selectorArgsToStateKey( resolutionArgs as unknown[] ),
resolutionState
);
} );
return nextState;
}
case 'INVALIDATE_RESOLUTION': {
const nextState = new EquivalentKeyMap( state );
nextState.delete( selectorArgsToStateKey( action.args ) );
return nextState;
}
}
return state;
} );
/**
* Reducer function returning next state for selector resolution, object form:
*
* selectorName -> EquivalentKeyMap<Array, boolean>
*
* @param state Current state.
* @param action Dispatched action.
*
* @return Next state.
*/
const isResolved = ( state: Record< string, State > = {}, action: Action ) => {
switch ( action.type ) {
case 'INVALIDATE_RESOLUTION_FOR_STORE':
return {};
case 'INVALIDATE_RESOLUTION_FOR_STORE_SELECTOR': {
if ( action.selectorName in state ) {
const {
[ action.selectorName ]: removedSelector,
...restState
} = state;
return restState;
}
return state;
}
case 'START_RESOLUTION':
case 'FINISH_RESOLUTION':
case 'FAIL_RESOLUTION':
case 'START_RESOLUTIONS':
case 'FINISH_RESOLUTIONS':
case 'FAIL_RESOLUTIONS':
case 'INVALIDATE_RESOLUTION':
return subKeysIsResolved( state, action );
}
return state;
};
export default isResolved;

View File

@@ -0,0 +1,207 @@
/**
* WordPress dependencies
*/
import deprecated from '@wordpress/deprecated';
/**
* Internal dependencies
*/
import { createSelector } from '../../create-selector';
import { selectorArgsToStateKey } from './utils';
/** @typedef {Record<string, import('./reducer').State>} State */
/** @typedef {import('./reducer').StateValue} StateValue */
/** @typedef {import('./reducer').Status} Status */
/**
* Returns the raw resolution state value for a given selector name,
* and arguments set. May be undefined if the selector has never been resolved
* or not resolved for the given set of arguments, otherwise true or false for
* resolution started and completed respectively.
*
* @param {State} state Data state.
* @param {string} selectorName Selector name.
* @param {unknown[]?} args Arguments passed to selector.
*
* @return {StateValue|undefined} isResolving value.
*/
export function getResolutionState( state, selectorName, args ) {
const map = state[ selectorName ];
if ( ! map ) {
return;
}
return map.get( selectorArgsToStateKey( args ) );
}
/**
* Returns an `isResolving`-like value for a given selector name and arguments set.
* Its value is either `undefined` if the selector has never been resolved or has been
* invalidated, or a `true`/`false` boolean value if the resolution is in progress or
* has finished, respectively.
*
* This is a legacy selector that was implemented when the "raw" internal data had
* this `undefined | boolean` format. Nowadays the internal value is an object that
* can be retrieved with `getResolutionState`.
*
* @deprecated
*
* @param {State} state Data state.
* @param {string} selectorName Selector name.
* @param {unknown[]?} args Arguments passed to selector.
*
* @return {boolean | undefined} isResolving value.
*/
export function getIsResolving( state, selectorName, args ) {
deprecated( 'wp.data.select( store ).getIsResolving', {
since: '6.6',
version: '6.8',
alternative: 'wp.data.select( store ).getResolutionState',
} );
const resolutionState = getResolutionState( state, selectorName, args );
return resolutionState && resolutionState.status === 'resolving';
}
/**
* Returns true if resolution has already been triggered for a given
* selector name, and arguments set.
*
* @param {State} state Data state.
* @param {string} selectorName Selector name.
* @param {unknown[]?} args Arguments passed to selector.
*
* @return {boolean} Whether resolution has been triggered.
*/
export function hasStartedResolution( state, selectorName, args ) {
return getResolutionState( state, selectorName, args ) !== undefined;
}
/**
* Returns true if resolution has completed for a given selector
* name, and arguments set.
*
* @param {State} state Data state.
* @param {string} selectorName Selector name.
* @param {unknown[]?} args Arguments passed to selector.
*
* @return {boolean} Whether resolution has completed.
*/
export function hasFinishedResolution( state, selectorName, args ) {
const status = getResolutionState( state, selectorName, args )?.status;
return status === 'finished' || status === 'error';
}
/**
* Returns true if resolution has failed for a given selector
* name, and arguments set.
*
* @param {State} state Data state.
* @param {string} selectorName Selector name.
* @param {unknown[]?} args Arguments passed to selector.
*
* @return {boolean} Has resolution failed
*/
export function hasResolutionFailed( state, selectorName, args ) {
return getResolutionState( state, selectorName, args )?.status === 'error';
}
/**
* Returns the resolution error for a given selector name, and arguments set.
* Note it may be of an Error type, but may also be null, undefined, or anything else
* that can be `throw`-n.
*
* @param {State} state Data state.
* @param {string} selectorName Selector name.
* @param {unknown[]?} args Arguments passed to selector.
*
* @return {Error|unknown} Last resolution error
*/
export function getResolutionError( state, selectorName, args ) {
const resolutionState = getResolutionState( state, selectorName, args );
return resolutionState?.status === 'error' ? resolutionState.error : null;
}
/**
* Returns true if resolution has been triggered but has not yet completed for
* a given selector name, and arguments set.
*
* @param {State} state Data state.
* @param {string} selectorName Selector name.
* @param {unknown[]?} args Arguments passed to selector.
*
* @return {boolean} Whether resolution is in progress.
*/
export function isResolving( state, selectorName, args ) {
return (
getResolutionState( state, selectorName, args )?.status === 'resolving'
);
}
/**
* Returns the list of the cached resolvers.
*
* @param {State} state Data state.
*
* @return {State} Resolvers mapped by args and selectorName.
*/
export function getCachedResolvers( state ) {
return state;
}
/**
* Whether the store has any currently resolving selectors.
*
* @param {State} state Data state.
*
* @return {boolean} True if one or more selectors are resolving, false otherwise.
*/
export function hasResolvingSelectors( state ) {
return Object.values( state ).some( ( selectorState ) =>
/**
* This uses the internal `_map` property of `EquivalentKeyMap` for
* optimization purposes, since the `EquivalentKeyMap` implementation
* does not support a `.values()` implementation.
*
* @see https://github.com/aduth/equivalent-key-map
*/
Array.from( selectorState._map.values() ).some(
( resolution ) => resolution[ 1 ]?.status === 'resolving'
)
);
}
/**
* Retrieves the total number of selectors, grouped per status.
*
* @param {State} state Data state.
*
* @return {Object} Object, containing selector totals by status.
*/
export const countSelectorsByStatus = createSelector(
( state ) => {
const selectorsByStatus = {};
Object.values( state ).forEach( ( selectorState ) =>
/**
* This uses the internal `_map` property of `EquivalentKeyMap` for
* optimization purposes, since the `EquivalentKeyMap` implementation
* does not support a `.values()` implementation.
*
* @see https://github.com/aduth/equivalent-key-map
*/
Array.from( selectorState._map.values() ).forEach(
( resolution ) => {
const currentStatus = resolution[ 1 ]?.status ?? 'error';
if ( ! selectorsByStatus[ currentStatus ] ) {
selectorsByStatus[ currentStatus ] = 0;
}
selectorsByStatus[ currentStatus ]++;
}
)
);
return selectorsByStatus;
},
( state ) => [ state ]
);

View File

@@ -0,0 +1,324 @@
/**
* External dependencies
*/
import deepFreeze from 'deep-freeze';
/**
* Internal dependencies
*/
import reducer from '../reducer';
describe( 'reducer', () => {
it( 'should default to an empty object', () => {
const state = reducer( undefined, {} );
expect( state ).toEqual( {} );
} );
describe( 'single resolution', () => {
it( 'should return with started resolution', () => {
const state = reducer( undefined, {
type: 'START_RESOLUTION',
selectorName: 'getFoo',
args: [],
} );
// { test: { getFoo: EquivalentKeyMap( [] => status: 'resolving } ) } }
expect( state.getFoo.get( [] ) ).toEqual( {
status: 'resolving',
} );
} );
it( 'should return with finished resolution', () => {
const original = reducer( undefined, {
type: 'START_RESOLUTION',
selectorName: 'getFoo',
args: [],
} );
const state = reducer( deepFreeze( original ), {
type: 'FINISH_RESOLUTION',
selectorName: 'getFoo',
args: [],
} );
// { test: { getFoo: EquivalentKeyMap( [] => { status: 'finished' } ) } }
expect( state.getFoo.get( [] ) ).toEqual( {
status: 'finished',
} );
} );
it( 'should remove invalidations', () => {
let state = reducer( undefined, {
type: 'START_RESOLUTION',
selectorName: 'getFoo',
args: [],
} );
state = reducer( deepFreeze( state ), {
type: 'FINISH_RESOLUTION',
selectorName: 'getFoo',
args: [],
} );
state = reducer( deepFreeze( state ), {
type: 'INVALIDATE_RESOLUTION',
selectorName: 'getFoo',
args: [],
} );
// { getFoo: EquivalentKeyMap( [] => undefined ) }
expect( state.getFoo.get( [] ) ).toBe( undefined );
} );
it( 'different arguments should not conflict', () => {
const original = reducer( undefined, {
type: 'START_RESOLUTION',
selectorName: 'getFoo',
args: [ 'post' ],
} );
let state = reducer( deepFreeze( original ), {
type: 'FINISH_RESOLUTION',
selectorName: 'getFoo',
args: [ 'post' ],
} );
state = reducer( deepFreeze( state ), {
type: 'START_RESOLUTION',
selectorName: 'getFoo',
args: [ 'block' ],
} );
// { getFoo: EquivalentKeyMap( [] => { status: 'finished' } ) }
expect( state.getFoo.get( [ 'post' ] ) ).toEqual( {
status: 'finished',
} );
expect( state.getFoo.get( [ 'block' ] ) ).toEqual( {
status: 'resolving',
} );
} );
it(
'should remove invalidation for store level and leave others ' +
'intact',
() => {
const original = reducer( undefined, {
type: 'FINISH_RESOLUTION',
selectorName: 'getFoo',
args: [ 'post' ],
} );
const state = reducer( deepFreeze( original ), {
type: 'INVALIDATE_RESOLUTION_FOR_STORE',
} );
expect( state ).toEqual( {} );
}
);
it(
'should remove invalidation for store and selector name level and ' +
'leave other selectors at store level intact',
() => {
const original = reducer( undefined, {
type: 'FINISH_RESOLUTION',
selectorName: 'getFoo',
args: [ 'post' ],
} );
let state = reducer( deepFreeze( original ), {
type: 'FINISH_RESOLUTION',
selectorName: 'getBar',
args: [ 'postBar' ],
} );
state = reducer( deepFreeze( state ), {
type: 'INVALIDATE_RESOLUTION_FOR_STORE_SELECTOR',
selectorName: 'getBar',
} );
expect( state.getBar ).toBeUndefined();
// { getFoo: EquivalentKeyMap( [] => { status: 'finished' } ) }
expect( state.getFoo.get( [ 'post' ] ) ).toEqual( {
status: 'finished',
} );
}
);
it( 'should normalize args array when dispatching actions', () => {
const started = reducer( undefined, {
type: 'START_RESOLUTION',
selectorName: 'getFoo',
args: [ 1, undefined ],
} );
expect( started.getFoo.get( [ 1 ] ) ).toEqual( {
status: 'resolving',
} );
const finished = reducer( started, {
type: 'FINISH_RESOLUTION',
selectorName: 'getFoo',
args: [ 1, undefined, undefined ],
} );
expect( finished.getFoo.get( [ 1 ] ) ).toEqual( {
status: 'finished',
} );
} );
} );
describe( 'resolution batch', () => {
it( 'should return with started resolutions', () => {
const state = reducer( undefined, {
type: 'START_RESOLUTIONS',
selectorName: 'getFoo',
args: [ [ 'post' ], [ 'block' ] ],
} );
expect( state.getFoo.get( [ 'post' ] ) ).toEqual( {
status: 'resolving',
} );
expect( state.getFoo.get( [ 'block' ] ) ).toEqual( {
status: 'resolving',
} );
} );
it( 'should return with finished resolutions', () => {
const original = reducer( undefined, {
type: 'START_RESOLUTIONS',
selectorName: 'getFoo',
args: [ [ 'post' ], [ 'block' ] ],
} );
const state = reducer( deepFreeze( original ), {
type: 'FINISH_RESOLUTIONS',
selectorName: 'getFoo',
args: [ [ 'post' ], [ 'block' ] ],
} );
expect( state.getFoo.get( [ 'post' ] ) ).toEqual( {
status: 'finished',
} );
expect( state.getFoo.get( [ 'block' ] ) ).toEqual( {
status: 'finished',
} );
} );
it( 'should remove invalidations', () => {
let state = reducer( undefined, {
type: 'START_RESOLUTIONS',
selectorName: 'getFoo',
args: [ [ 'post' ], [ 'block' ] ],
} );
state = reducer( deepFreeze( state ), {
type: 'FINISH_RESOLUTIONS',
selectorName: 'getFoo',
args: [ [ 'post' ], [ 'block' ] ],
} );
state = reducer( deepFreeze( state ), {
type: 'INVALIDATE_RESOLUTION',
selectorName: 'getFoo',
args: [ 'post' ],
} );
expect( state.getFoo.get( [ 'post' ] ) ).toBe( undefined );
expect( state.getFoo.get( [ 'block' ] ) ).toEqual( {
status: 'finished',
} );
} );
it( 'different arguments should not conflict', () => {
const original = reducer( undefined, {
type: 'START_RESOLUTIONS',
selectorName: 'getFoo',
args: [ [ 'post' ] ],
} );
let state = reducer( deepFreeze( original ), {
type: 'FINISH_RESOLUTIONS',
selectorName: 'getFoo',
args: [ [ 'post' ] ],
} );
state = reducer( deepFreeze( state ), {
type: 'START_RESOLUTIONS',
selectorName: 'getFoo',
args: [ [ 'block' ] ],
} );
expect( state.getFoo.get( [ 'post' ] ) ).toEqual( {
status: 'finished',
} );
expect( state.getFoo.get( [ 'block' ] ) ).toEqual( {
status: 'resolving',
} );
} );
it(
'should remove invalidation for store level and leave others ' +
'intact',
() => {
const original = reducer( undefined, {
type: 'FINISH_RESOLUTIONS',
selectorName: 'getFoo',
args: [ [ 'post' ], [ 'block' ] ],
} );
const state = reducer( deepFreeze( original ), {
type: 'INVALIDATE_RESOLUTION_FOR_STORE',
} );
expect( state ).toEqual( {} );
}
);
it(
'should remove invalidation for store and selector name level and ' +
'leave other selectors at store level intact',
() => {
const original = reducer( undefined, {
type: 'FINISH_RESOLUTIONS',
selectorName: 'getFoo',
args: [ [ 'post' ], [ 'block' ] ],
} );
let state = reducer( deepFreeze( original ), {
type: 'FINISH_RESOLUTIONS',
selectorName: 'getBar',
args: [ [ 'postBar' ] ],
} );
state = reducer( deepFreeze( state ), {
type: 'INVALIDATE_RESOLUTION_FOR_STORE_SELECTOR',
selectorName: 'getBar',
} );
expect( state.getBar ).toBeUndefined();
expect( state.getFoo.get( [ 'post' ] ) ).toEqual( {
status: 'finished',
} );
expect( state.getFoo.get( [ 'block' ] ) ).toEqual( {
status: 'finished',
} );
}
);
it( 'should normalize args array when dispatching actions', () => {
const started = reducer( undefined, {
type: 'START_RESOLUTIONS',
selectorName: 'getFoo',
args: [
[ 1, undefined ],
[ 2, undefined, undefined ],
],
} );
expect( started.getFoo.get( [ 1 ] ) ).toEqual( {
status: 'resolving',
} );
expect( started.getFoo.get( [ 2 ] ) ).toEqual( {
status: 'resolving',
} );
const finished = reducer( started, {
type: 'FINISH_RESOLUTIONS',
selectorName: 'getFoo',
args: [
[ 1, undefined, undefined ],
[ 2, undefined ],
],
} );
expect( finished.getFoo.get( [ 1 ] ) ).toEqual( {
status: 'finished',
} );
expect( finished.getFoo.get( [ 2 ] ) ).toEqual( {
status: 'finished',
} );
} );
} );
} );

View File

@@ -0,0 +1,485 @@
/**
* WordPress dependencies
*/
import { createRegistry } from '@wordpress/data';
const getFooSelector = ( state ) => state;
const testStore = {
reducer: ( state = null, action ) => {
if ( action.type === 'RECEIVE' ) {
return action.items;
}
return state;
},
selectors: {
getFoo: getFooSelector,
},
};
async function resolve( registry, selector ) {
try {
await registry.resolveSelect( 'store' )[ selector ]();
} catch ( e ) {}
}
describe( 'getIsResolving', () => {
let registry;
beforeEach( () => {
registry = createRegistry();
registry.registerStore( 'testStore', testStore );
} );
const DEPRECATION_MESSAGE =
'wp.data.select( store ).getIsResolving is deprecated since version 6.6 and will be removed in version 6.8. Please use wp.data.select( store ).getResolutionState instead.';
it( 'should return undefined if no state by reducerKey, selectorName', () => {
const result = registry
.select( 'testStore' )
.getIsResolving( 'getFoo', [] );
expect( result ).toBe( undefined );
expect( console ).toHaveWarnedWith( DEPRECATION_MESSAGE );
} );
it( 'should return undefined if state by reducerKey, selectorName, but not args', () => {
registry.dispatch( 'testStore' ).startResolution( 'getFoo', [] );
const result = registry
.select( 'testStore' )
.getIsResolving( 'getFoo', [ 'bar' ] );
expect( result ).toBe( undefined );
} );
it( 'should return value by reducerKey, selectorName', () => {
registry.dispatch( 'testStore' ).startResolution( 'getFoo', [] );
const result = registry
.select( 'testStore' )
.getIsResolving( 'getFoo', [] );
expect( result ).toBe( true );
} );
it( 'should normalize args and return the right value', () => {
registry.dispatch( 'testStore' ).startResolution( 'getFoo', [] );
const { getIsResolving } = registry.select( 'testStore' );
expect( getIsResolving( 'getFoo' ) ).toBe( true );
expect( getIsResolving( 'getFoo', [ undefined ] ) ).toBe( true );
expect( getIsResolving( 'getFoo', [ undefined, undefined ] ) ).toBe(
true
);
} );
} );
describe( 'hasStartedResolution', () => {
let registry;
beforeEach( () => {
registry = createRegistry();
registry.registerStore( 'testStore', testStore );
} );
it( 'returns false if not has started', () => {
const result = registry
.select( 'testStore' )
.hasStartedResolution( 'getFoo', [] );
expect( result ).toBe( false );
} );
it( 'returns true if has started', () => {
registry.dispatch( 'testStore' ).startResolution( 'getFoo', [] );
const { hasStartedResolution } = registry.select( 'testStore' );
const result = hasStartedResolution( 'getFoo', [] );
expect( result ).toBe( true );
} );
} );
describe( 'hasFinishedResolution', () => {
let registry;
beforeEach( () => {
registry = createRegistry();
registry.registerStore( 'testStore', testStore );
} );
it( 'returns false if not has finished', () => {
registry.dispatch( 'testStore' ).startResolution( 'getFoo', [] );
const { hasFinishedResolution } = registry.select( 'testStore' );
const result = hasFinishedResolution( 'getFoo', [] );
expect( result ).toBe( false );
} );
it( 'returns true if has finished', () => {
registry.dispatch( 'testStore' ).finishResolution( 'getFoo', [] );
const { hasFinishedResolution } = registry.select( 'testStore' );
const result = hasFinishedResolution( 'getFoo', [] );
expect( result ).toBe( true );
} );
} );
describe( 'isResolving', () => {
let registry;
beforeEach( () => {
registry = createRegistry();
registry.registerStore( 'testStore', testStore );
} );
it( 'returns false if not has started', () => {
const { isResolving } = registry.select( 'testStore' );
const result = isResolving( 'getFoo', [] );
expect( result ).toBe( false );
} );
it( 'returns false if has finished', () => {
registry.dispatch( 'testStore' ).startResolution( 'getFoo', [] );
registry.dispatch( 'testStore' ).finishResolution( 'getFoo', [] );
const { isResolving } = registry.select( 'testStore' );
const result = isResolving( 'getFoo', [] );
expect( result ).toBe( false );
} );
it( 'returns true if has started but not finished', () => {
registry.dispatch( 'testStore' ).startResolution( 'getFoo', [] );
const { isResolving } = registry.select( 'testStore' );
const result = isResolving( 'getFoo', [] );
expect( result ).toBe( true );
} );
} );
describe( 'hasResolutionFailed', () => {
let registry;
beforeEach( () => {
registry = createRegistry();
} );
it( 'returns false if the resolution has succeeded', async () => {
registry.registerStore( 'store', {
reducer: ( state = null, action ) => {
if ( action.type === 'RECEIVE' ) {
return action.items;
}
return state;
},
selectors: {
getFoo: ( state ) => state,
},
resolvers: {
getFoo: () => {},
},
} );
expect(
registry.select( 'store' ).hasResolutionFailed( 'getFoo' )
).toBeFalsy();
registry.select( 'store' ).getFoo();
expect(
registry.select( 'store' ).hasResolutionFailed( 'getFoo' )
).toBeFalsy();
} );
it( 'returns true if the resolution has failed', async () => {
registry.registerStore( 'store', {
reducer: ( state = null, action ) => {
if ( action.type === 'RECEIVE' ) {
return action.items;
}
return state;
},
selectors: {
getFoo: ( state ) => state,
},
resolvers: {
getFoo: () => {
throw new Error( 'cannot fetch items' );
},
},
} );
expect(
registry.select( 'store' ).hasResolutionFailed( 'getFoo' )
).toBeFalsy();
await resolve( registry, 'getFoo' );
expect(
registry.select( 'store' ).hasResolutionFailed( 'getFoo' )
).toBeTruthy();
} );
it( 'returns true if the resolution has failed even if the error is falsy', async () => {
registry.registerStore( 'store', {
reducer: ( state = null, action ) => {
if ( action.type === 'RECEIVE' ) {
return action.items;
}
return state;
},
selectors: {
getFoo: ( state ) => state,
},
resolvers: {
getFoo: () => {
throw null;
},
},
} );
expect(
registry.select( 'store' ).hasResolutionFailed( 'getFoo' )
).toBeFalsy();
await resolve( registry, 'getFoo' );
expect(
registry.select( 'store' ).hasResolutionFailed( 'getFoo' )
).toBeTruthy();
} );
} );
describe( 'getResolutionError', () => {
let registry;
let shouldFail;
beforeEach( () => {
shouldFail = false;
registry = createRegistry();
registry.registerStore( 'store', {
reducer: ( state = null, action ) => {
if ( action.type === 'RECEIVE' ) {
return action.items;
}
return state;
},
selectors: {
getFoo: ( state ) => state,
},
resolvers: {
getFoo: () => {
if ( shouldFail ) {
throw new Error( 'cannot fetch items' );
}
},
},
} );
} );
it( 'returns undefined if the resolution has succeeded', async () => {
expect(
registry.select( 'store' ).getResolutionError( 'getFoo' )
).toBeFalsy();
registry.select( 'store' ).getFoo();
expect(
registry.select( 'store' ).getResolutionError( 'getFoo' )
).toBeFalsy();
} );
it( 'returns error if the resolution has failed', async () => {
shouldFail = true;
expect(
registry.select( 'store' ).getResolutionError( 'getFoo' )
).toBeFalsy();
await resolve( registry, 'getFoo' );
expect(
registry.select( 'store' ).getResolutionError( 'getFoo' ).toString()
).toBe( 'Error: cannot fetch items' );
} );
it( 'returns undefined if the failed resolution succeeded after retry', async () => {
shouldFail = true;
expect(
registry.select( 'store' ).getResolutionError( 'getFoo' )
).toBeFalsy();
await resolve( registry, 'getFoo' );
expect(
registry.select( 'store' ).getResolutionError( 'getFoo' )
).toBeTruthy();
registry.dispatch( 'store' ).invalidateResolution( 'getFoo', [] );
expect(
registry.select( 'store' ).getResolutionError( 'getFoo' )
).toBeFalsy();
shouldFail = false;
registry.select( 'store' ).getFoo();
expect(
registry.select( 'store' ).getResolutionError( 'getFoo' )
).toBeFalsy();
} );
} );
describe( 'hasResolvingSelectors', () => {
let registry;
beforeEach( () => {
registry = createRegistry();
registry.registerStore( 'testStore', testStore );
} );
it( 'returns false if no requests have started', () => {
const { hasResolvingSelectors } = registry.select( 'testStore' );
const result = hasResolvingSelectors();
expect( result ).toBe( false );
} );
it( 'returns false if all requests have finished', () => {
registry.dispatch( 'testStore' ).startResolution( 'getFoo', [] );
registry.dispatch( 'testStore' ).finishResolution( 'getFoo', [] );
const { hasResolvingSelectors } = registry.select( 'testStore' );
const result = hasResolvingSelectors();
expect( result ).toBe( false );
} );
it( 'returns true if has started but not finished', () => {
registry.dispatch( 'testStore' ).startResolution( 'getFoo', [] );
const { hasResolvingSelectors } = registry.select( 'testStore' );
const result = hasResolvingSelectors();
expect( result ).toBe( true );
} );
} );
describe( 'countSelectorsByStatus', () => {
let registry;
beforeEach( () => {
registry = createRegistry();
registry.registerStore( 'store', {
reducer: ( state = null, action ) => {
if ( action.type === 'RECEIVE' ) {
return action.items;
}
return state;
},
selectors: {
getFoo: ( state ) => state,
getBar: ( state ) => state,
getBaz: ( state ) => state,
getFailingFoo: ( state ) => state,
getFailingBar: ( state ) => state,
},
resolvers: {
getFailingFoo: () => {
throw new Error( 'error fetching' );
},
getFailingBar: () => {
throw new Error( 'error fetching' );
},
},
} );
} );
it( 'counts selectors properly by status, excluding missing statuses', () => {
registry.dispatch( 'store' ).startResolution( 'getFoo', [] );
registry.dispatch( 'store' ).startResolution( 'getBar', [] );
registry.dispatch( 'store' ).startResolution( 'getBaz', [] );
registry.dispatch( 'store' ).finishResolution( 'getFoo', [] );
registry.dispatch( 'store' ).finishResolution( 'getBaz', [] );
const { countSelectorsByStatus } = registry.select( 'store' );
const result = countSelectorsByStatus();
expect( result ).toEqual( {
finished: 2,
resolving: 1,
} );
} );
it( 'counts errors properly', async () => {
registry.dispatch( 'store' ).startResolution( 'getFoo', [] );
await resolve( registry, 'getFailingFoo' );
await resolve( registry, 'getFailingBar' );
registry.dispatch( 'store' ).finishResolution( 'getFoo', [] );
const { countSelectorsByStatus } = registry.select( 'store' );
const result = countSelectorsByStatus();
expect( result ).toEqual( {
finished: 1,
error: 2,
} );
} );
it( 'applies memoization and returns the same object for the same state', () => {
const { countSelectorsByStatus } = registry.select( 'store' );
expect( countSelectorsByStatus() ).toBe( countSelectorsByStatus() );
registry.dispatch( 'store' ).startResolution( 'getFoo', [] );
registry.dispatch( 'store' ).finishResolution( 'getFoo', [] );
expect( countSelectorsByStatus() ).toBe( countSelectorsByStatus() );
} );
it( 'returns a new object when different state is provided', () => {
const { countSelectorsByStatus } = registry.select( 'store' );
const result1 = countSelectorsByStatus();
registry.dispatch( 'store' ).startResolution( 'getFoo', [] );
registry.dispatch( 'store' ).finishResolution( 'getFoo', [] );
const result2 = countSelectorsByStatus();
expect( result1 ).not.toBe( result2 );
} );
} );
describe( 'Selector arguments normalization', () => {
let registry;
beforeEach( () => {
registry = createRegistry();
registry.registerStore( 'testStore', testStore );
} );
it( 'should call normalization method on target selector if exists', () => {
const normalizationFunction = jest.fn( ( args ) => {
return args.map( Number );
} );
getFooSelector.__unstableNormalizeArgs = normalizationFunction;
registry.dispatch( 'testStore' ).startResolution( 'getFoo', [ 123 ] );
const { getIsResolving, hasStartedResolution, hasFinishedResolution } =
registry.select( 'testStore' );
expect( getIsResolving( 'getFoo', [ '123' ] ) ).toBe( true );
expect( normalizationFunction ).toHaveBeenCalledWith( [ '123' ] );
expect( hasStartedResolution( 'getFoo', [ '123' ] ) ).toBe( true );
expect( normalizationFunction ).toHaveBeenCalledWith( [ '123' ] );
expect( normalizationFunction ).toHaveBeenCalledTimes( 2 );
registry.dispatch( 'testStore' ).finishResolution( 'getFoo', [ 123 ] );
expect( hasFinishedResolution( 'getFoo', [ '123' ] ) ).toBe( true );
expect( normalizationFunction ).toHaveBeenCalledWith( [ '123' ] );
getFooSelector.__unstableNormalizeArgs = undefined;
} );
} );

View File

@@ -0,0 +1,67 @@
/**
* Internal dependencies
*/
import { selectorArgsToStateKey, onSubKey } from '../utils';
describe( 'onSubKey', () => {
function createEnhancedReducer( actionProperty ) {
const enhanceReducer = onSubKey( actionProperty );
return enhanceReducer(
( state, action ) => 'Called by ' + action.caller
);
}
it( 'should default to an empty object', () => {
const reducer = createEnhancedReducer( 'caller' );
const nextState = reducer( undefined, { type: '@@INIT' } );
expect( nextState ).toEqual( {} );
} );
it( 'should ignore actions where property not present', () => {
const state = {};
const reducer = createEnhancedReducer( 'caller' );
const nextState = reducer( state, { type: 'DO_FOO' } );
expect( nextState ).toBe( state );
} );
it( 'should key by action property', () => {
const reducer = createEnhancedReducer( 'caller' );
let state = Object.freeze( {} );
state = reducer( state, { type: 'DO_FOO', caller: 1 } );
state = reducer( state, { type: 'DO_FOO', caller: 2 } );
expect( state ).toEqual( {
1: 'Called by 1',
2: 'Called by 2',
} );
} );
} );
describe( 'selectorArgsToStateKey', () => {
it( 'should default to an empty array', () => {
expect( selectorArgsToStateKey( undefined ) ).toEqual( [] );
} );
it( 'should remove trailing undefined values', () => {
expect( selectorArgsToStateKey( [ 1, 2, undefined ] ) ).toEqual( [
1, 2,
] );
expect(
selectorArgsToStateKey( [ 1, 2, undefined, undefined ] )
).toEqual( [ 1, 2 ] );
} );
it( 'should leave non-trailing undefined values alone', () => {
expect(
selectorArgsToStateKey( [ 1, undefined, 2, undefined ] )
).toEqual( [ 1, undefined, 2 ] );
} );
it( 'should return already normalized array unchanged', () => {
const args = [ 1, 2, 3 ];
expect( selectorArgsToStateKey( args ) ).toBe( args );
} );
} );

View File

@@ -0,0 +1,59 @@
/**
* External dependencies
*/
import type { AnyAction, Reducer } from 'redux';
/**
* Higher-order reducer creator which creates a combined reducer object, keyed
* by a property on the action object.
*
* @param actionProperty Action property by which to key object.
* @return Higher-order reducer.
*/
export const onSubKey =
< TState extends unknown, TAction extends AnyAction >(
actionProperty: string
) =>
(
reducer: Reducer< TState, TAction >
): Reducer< Record< string, TState >, TAction > =>
( state: Record< string, TState > = {}, action ) => {
// Retrieve subkey from action. Do not track if undefined; useful for cases
// where reducer is scoped by action shape.
const key = action[ actionProperty ];
if ( key === undefined ) {
return state;
}
// Avoid updating state if unchanged. Note that this also accounts for a
// reducer which returns undefined on a key which is not yet tracked.
const nextKeyState = reducer( state[ key ], action );
if ( nextKeyState === state[ key ] ) {
return state;
}
return {
...state,
[ key ]: nextKeyState,
};
};
/**
* Normalize selector argument array by defaulting `undefined` value to an empty array
* and removing trailing `undefined` values.
*
* @param args Selector argument array
* @return Normalized state key array
*/
export function selectorArgsToStateKey( args: unknown[] | null | undefined ) {
if ( args === undefined || args === null ) {
return [];
}
const len = args.length;
let idx = len;
while ( idx > 0 && args[ idx - 1 ] === undefined ) {
idx--;
}
return idx === len ? args : args.slice( 0, idx );
}

View File

@@ -0,0 +1,66 @@
/**
* Internal dependencies
*/
import { combineReducers } from '../combine-reducers';
function counterReducer( count = 0, action ) {
if ( action.type === 'INC' ) {
return count + 1;
}
return count;
}
describe( 'combineReducers', () => {
it( 'initializes state', () => {
const reducer = combineReducers( { foo: counterReducer } );
expect( reducer( undefined, { type: 'INIT' } ) ).toEqual( { foo: 0 } );
} );
it( 'dispatches actions to subreducers', () => {
const reducer = combineReducers( { foo: counterReducer } );
expect( reducer( { foo: 1 }, { type: 'INC' } ) ).toEqual( { foo: 2 } );
} );
it( 'returns identical state when there is no change', () => {
const reducer = combineReducers( { foo: counterReducer } );
const state = { foo: 1 };
expect( reducer( state, { type: 'NOOP' } ) ).toBe( state );
} );
it( 'returns identical substate when there is no change', () => {
const reducer = combineReducers( {
foo: counterReducer,
bar: combineReducers( { baz: ( s ) => s } ),
} );
const prevState = { foo: 1, bar: { baz: 1 } };
const nextState = reducer( prevState, { type: 'INC' } );
expect( nextState.foo ).toBe( 2 ); // changed
expect( nextState.bar ).toBe( prevState.bar ); // not changed
} );
it( 'does not mind undefined state', () => {
const reducer = combineReducers( { foo: ( s ) => s } );
// combineReducers from Redux would throw an exception when a reducer returns
// `undefined` state, but we don't mind and treat is as any other value.
expect( reducer( undefined, { type: 'INIT' } ) ).toEqual( {
foo: undefined,
} );
} );
it( 'does not mind unknown state keys', () => {
const reducer = combineReducers( { foo: counterReducer } );
// combineReducers from Redux would warn about the unknown `bar` key as
// there is no reducer for it, but we don't mind and just ignore it.
expect( reducer( { foo: 1, bar: 1 }, { type: 'INC' } ) ).toEqual( {
foo: 2,
} );
} );
it( 'supports a "unit" reducer with no subreducers', () => {
const reducer = combineReducers( {} );
const initialState = reducer( undefined, { type: 'INIT' } );
expect( initialState ).toEqual( {} );
const nextState = reducer( initialState, { type: 'INC' } );
expect( nextState ).toBe( initialState );
} );
} );

View File

@@ -0,0 +1,360 @@
/**
* Internal dependencies
*/
import { createRegistry } from '../../registry';
import { createRegistryControl } from '../../factory';
describe( 'controls', () => {
let registry;
beforeEach( () => {
registry = createRegistry();
} );
describe( 'should call registry-aware controls', () => {
it( 'registers multiple selectors to the public API', () => {
const action1 = jest.fn( () => ( { type: 'NOTHING' } ) );
const action2 = function* () {
yield { type: 'DISPATCH', store: 'store1', action: 'action1' };
};
registry.registerStore( 'store1', {
reducer: () => 'state1',
actions: {
action1,
},
} );
registry.registerStore( 'store2', {
reducer: () => 'state2',
actions: {
action2,
},
controls: {
DISPATCH: createRegistryControl(
( reg ) =>
( { store, action } ) => {
return reg.dispatch( store )[ action ]();
}
),
},
} );
registry.dispatch( 'store2' ).action2();
expect( action1 ).toHaveBeenCalled();
} );
} );
it( 'resolves in expected order', async () => {
const actions = {
standby: () => ( { type: 'STANDBY' } ),
receive: ( items ) => ( { type: 'RECEIVE', items } ),
};
registry.registerStore( 'store', {
reducer: ( state = null, action ) => {
if ( action.type === 'RECEIVE' ) {
return action.items;
}
return state;
},
selectors: {
getItems: ( state ) => state,
},
resolvers: {
*getItems() {
yield actions.standby();
yield actions.receive( [ 1, 2, 3 ] );
},
},
controls: {
STANDBY() {
return new Promise( ( resolve ) =>
process.nextTick( resolve )
);
},
},
} );
return new Promise( ( resolve ) => {
registry.subscribe( () => {
const isFinished = registry
.select( 'store' )
.hasFinishedResolution( 'getItems' );
if ( isFinished ) {
const items = registry.select( 'store' ).getItems();
// eslint-disable-next-line jest/no-conditional-expect
expect( items ).toEqual( [ 1, 2, 3 ] );
}
resolve();
} );
registry.select( 'store' ).getItems();
} );
} );
describe( 'selectors have expected value for the `hasResolver` property', () => {
it( 'when custom store has resolvers defined', () => {
registry.registerStore( 'store', {
reducer: jest.fn(),
selectors: {
getItems: ( state ) => state,
getItem: ( state ) => state,
},
resolvers: {
*getItems() {
yield 'foo';
},
},
} );
expect( registry.select( 'store' ).getItems.hasResolver ).toBe(
true
);
expect( registry.select( 'store' ).getItem.hasResolver ).toBe(
false
);
} );
it( 'when custom store does not have resolvers defined', () => {
registry.registerStore( 'store', {
reducer: jest.fn(),
selectors: {
getItems: ( state ) => state,
},
} );
expect( registry.select( 'store' ).getItems.hasResolver ).toBe(
false
);
} );
} );
describe( 'various action types have expected response and resolve as expected with controls middleware', () => {
const actions = {
*withPromise() {
yield { type: 'SOME_ACTION' };
return yield { type: 'TEST_PROMISE' };
},
*withNormal() {
yield { type: 'SOME_ACTION' };
yield { type: 'SOME_OTHER_ACTION' };
},
*withNonActionLikeValue() {
yield { type: 'SOME_ACTION' };
return 10;
},
normalShouldFail: () => 10,
normal: () => ( { type: 'NORMAL' } ),
};
beforeEach( () => {
registry.registerStore( 'store', {
reducer: () => {},
controls: {
TEST_PROMISE() {
return new Promise( ( resolve ) => resolve( 10 ) );
},
},
actions,
} );
} );
it(
'action generator returning a yielded promise control descriptor ' +
'resolves as expected',
async () => {
const withPromise = registry.dispatch( 'store' ).withPromise();
await expect( withPromise ).resolves.toEqual( 10 );
}
);
it(
'action generator yielding normal action objects resolves as ' +
'expected',
async () => {
const withNormal = registry.dispatch( 'store' ).withNormal();
await expect( withNormal ).resolves.toBeUndefined();
}
);
it( 'action generator returning a non action like value', async () => {
const withNonActionLikeValue = registry
.dispatch( 'store' )
.withNonActionLikeValue();
await expect( withNonActionLikeValue ).resolves.toEqual( 10 );
} );
it(
'normal dispatch action throwing error because no action ' +
'returned',
() => {
const testDispatch = () =>
registry.dispatch( 'store' ).normalShouldFail();
expect( testDispatch ).toThrow(
"Actions must be plain objects. Instead, the actual type was: 'number'"
);
}
);
it( 'returns action object for normal dispatch action', async () => {
await expect(
registry.dispatch( 'store' ).normal()
).resolves.toEqual( { type: 'NORMAL' } );
} );
} );
describe( 'action type resolves as expected with just promise middleware', () => {
const actions = {
normal: () => ( { type: 'NORMAL' } ),
withPromiseAndAction: () =>
new Promise( ( resolve ) =>
resolve( { type: 'WITH_PROMISE' } )
),
withPromiseAndNonAction: () =>
new Promise( ( resolve ) => resolve( 10 ) ),
};
beforeEach( () => {
registry.registerStore( 'store', {
reducer: () => {},
actions,
} );
} );
it( 'normal action returns action object', async () => {
await expect(
registry.dispatch( 'store' ).normal()
).resolves.toEqual( { type: 'NORMAL' } );
} );
it(
'action with promise resolving to action returning ' +
'action object',
async () => {
await expect(
registry.dispatch( 'store' ).withPromiseAndAction()
).resolves.toEqual( {
type: 'WITH_PROMISE',
} );
}
);
it( 'action with promise returning non action throws error', async () => {
const dispatchedAction = registry
.dispatch( 'store' )
.withPromiseAndNonAction();
await expect( dispatchedAction ).rejects.toThrow(
"Actions must be plain objects. Instead, the actual type was: 'number'."
);
} );
} );
} );
describe( 'resolveSelect', () => {
let registry;
let shouldFail;
beforeEach( () => {
shouldFail = false;
registry = createRegistry();
registry.registerStore( 'store', {
reducer: ( state = null ) => {
return state;
},
selectors: {
getItems: () => 'items',
getItemsNoResolver: () => 'items-no-resolver',
},
resolvers: {
getItems: () => {
if ( shouldFail ) {
throw new Error( 'cannot fetch items' );
}
},
},
} );
} );
it( 'resolves when the resolution succeeded', async () => {
shouldFail = false;
const promise = registry.resolveSelect( 'store' ).getItems();
await expect( promise ).resolves.toBe( 'items' );
} );
it( 'rejects when the resolution failed', async () => {
shouldFail = true;
const promise = registry.resolveSelect( 'store' ).getItems();
await expect( promise ).rejects.toEqual(
new Error( 'cannot fetch items' )
);
} );
it( 'resolves when calling a sync selector without resolver', async () => {
const promise = registry.resolveSelect( 'store' ).getItemsNoResolver();
await expect( promise ).resolves.toBe( 'items-no-resolver' );
} );
it( 'returns only store native selectors and excludes all meta ones', () => {
expect( Object.keys( registry.resolveSelect( 'store' ) ) ).toEqual( [
'getItems',
'getItemsNoResolver',
] );
} );
} );
describe( 'normalizing args', () => {
it( 'should call the __unstableNormalizeArgs method of the selector for both the selector and the resolver', async () => {
const registry = createRegistry();
const selector = () => {};
const normalizingFunction = jest.fn( ( ...args ) => args );
selector.__unstableNormalizeArgs = normalizingFunction;
registry.registerStore( 'store', {
reducer: () => {},
selectors: {
getItems: selector,
},
resolvers: {
getItems: () => 'items',
},
} );
registry.select( 'store' ).getItems( 'foo', 'bar' );
expect( normalizingFunction ).toHaveBeenCalledWith( [ 'foo', 'bar' ] );
// Needs to be called twice:
// 1. When the selector is called.
// 2. When the resolver is fullfilled.
expect( normalizingFunction ).toHaveBeenCalledTimes( 2 );
} );
it( 'should not call the __unstableNormalizeArgs method if there are no arguments passed to the selector (and thus the resolver)', async () => {
const registry = createRegistry();
const selector = () => {};
selector.__unstableNormalizeArgs = jest.fn( ( ...args ) => args );
registry.registerStore( 'store', {
reducer: () => {},
selectors: {
getItems: selector,
},
resolvers: {
getItems: () => 'items',
},
} );
// Called with no args so the __unstableNormalizeArgs method should not be called.
registry.select( 'store' ).getItems();
expect( selector.__unstableNormalizeArgs ).not.toHaveBeenCalled();
} );
it( 'should call the __unstableNormalizeArgs method on the selectors without resolvers', async () => {
const registry = createRegistry();
const selector = () => {};
selector.__unstableNormalizeArgs = jest.fn( ( ...args ) => args );
registry.registerStore( 'store', {
reducer: () => {},
selectors: {
getItems: selector,
},
} );
registry.select( 'store' ).getItems( 'foo', 'bar' );
expect( selector.__unstableNormalizeArgs ).toHaveBeenCalledWith( [
'foo',
'bar',
] );
} );
} );

View File

@@ -0,0 +1,9 @@
export default function createThunkMiddleware( args ) {
return () => ( next ) => ( action ) => {
if ( typeof action === 'function' ) {
return action( args );
}
return next( action );
};
}

392
node_modules/@wordpress/data/src/registry.js generated vendored Normal file
View File

@@ -0,0 +1,392 @@
/**
* WordPress dependencies
*/
import deprecated from '@wordpress/deprecated';
/**
* Internal dependencies
*/
import createReduxStore from './redux-store';
import coreDataStore from './store';
import { createEmitter } from './utils/emitter';
import { lock, unlock } from './lock-unlock';
/** @typedef {import('./types').StoreDescriptor} StoreDescriptor */
/**
* @typedef {Object} WPDataRegistry An isolated orchestrator of store registrations.
*
* @property {Function} registerGenericStore Given a namespace key and settings
* object, registers a new generic
* store.
* @property {Function} registerStore Given a namespace key and settings
* object, registers a new namespace
* store.
* @property {Function} subscribe Given a function callback, invokes
* the callback on any change to state
* within any registered store.
* @property {Function} select Given a namespace key, returns an
* object of the store's registered
* selectors.
* @property {Function} dispatch Given a namespace key, returns an
* object of the store's registered
* action dispatchers.
*/
/**
* @typedef {Object} WPDataPlugin An object of registry function overrides.
*
* @property {Function} registerStore registers store.
*/
function getStoreName( storeNameOrDescriptor ) {
return typeof storeNameOrDescriptor === 'string'
? storeNameOrDescriptor
: storeNameOrDescriptor.name;
}
/**
* Creates a new store registry, given an optional object of initial store
* configurations.
*
* @param {Object} storeConfigs Initial store configurations.
* @param {Object?} parent Parent registry.
*
* @return {WPDataRegistry} Data registry.
*/
export function createRegistry( storeConfigs = {}, parent = null ) {
const stores = {};
const emitter = createEmitter();
let listeningStores = null;
/**
* Global listener called for each store's update.
*/
function globalListener() {
emitter.emit();
}
/**
* Subscribe to changes to any data, either in all stores in registry, or
* in one specific store.
*
* @param {Function} listener Listener function.
* @param {string|StoreDescriptor?} storeNameOrDescriptor Optional store name.
*
* @return {Function} Unsubscribe function.
*/
const subscribe = ( listener, storeNameOrDescriptor ) => {
// subscribe to all stores
if ( ! storeNameOrDescriptor ) {
return emitter.subscribe( listener );
}
// subscribe to one store
const storeName = getStoreName( storeNameOrDescriptor );
const store = stores[ storeName ];
if ( store ) {
return store.subscribe( listener );
}
// Trying to access a store that hasn't been registered,
// this is a pattern rarely used but seen in some places.
// We fallback to global `subscribe` here for backward-compatibility for now.
// See https://github.com/WordPress/gutenberg/pull/27466 for more info.
if ( ! parent ) {
return emitter.subscribe( listener );
}
return parent.subscribe( listener, storeNameOrDescriptor );
};
/**
* Calls a selector given the current state and extra arguments.
*
* @param {string|StoreDescriptor} storeNameOrDescriptor Unique namespace identifier for the store
* or the store descriptor.
*
* @return {*} The selector's returned value.
*/
function select( storeNameOrDescriptor ) {
const storeName = getStoreName( storeNameOrDescriptor );
listeningStores?.add( storeName );
const store = stores[ storeName ];
if ( store ) {
return store.getSelectors();
}
return parent?.select( storeName );
}
function __unstableMarkListeningStores( callback, ref ) {
listeningStores = new Set();
try {
return callback.call( this );
} finally {
ref.current = Array.from( listeningStores );
listeningStores = null;
}
}
/**
* Given a store descriptor, returns an object containing the store's selectors pre-bound to
* state so that you only need to supply additional arguments, and modified so that they return
* promises that resolve to their eventual values, after any resolvers have ran.
*
* @param {StoreDescriptor|string} storeNameOrDescriptor The store descriptor. The legacy calling
* convention of passing the store name is
* also supported.
*
* @return {Object} Each key of the object matches the name of a selector.
*/
function resolveSelect( storeNameOrDescriptor ) {
const storeName = getStoreName( storeNameOrDescriptor );
listeningStores?.add( storeName );
const store = stores[ storeName ];
if ( store ) {
return store.getResolveSelectors();
}
return parent && parent.resolveSelect( storeName );
}
/**
* Given a store descriptor, returns an object containing the store's selectors pre-bound to
* state so that you only need to supply additional arguments, and modified so that they throw
* promises in case the selector is not resolved yet.
*
* @param {StoreDescriptor|string} storeNameOrDescriptor The store descriptor. The legacy calling
* convention of passing the store name is
* also supported.
*
* @return {Object} Object containing the store's suspense-wrapped selectors.
*/
function suspendSelect( storeNameOrDescriptor ) {
const storeName = getStoreName( storeNameOrDescriptor );
listeningStores?.add( storeName );
const store = stores[ storeName ];
if ( store ) {
return store.getSuspendSelectors();
}
return parent && parent.suspendSelect( storeName );
}
/**
* Returns the available actions for a part of the state.
*
* @param {string|StoreDescriptor} storeNameOrDescriptor Unique namespace identifier for the store
* or the store descriptor.
*
* @return {*} The action's returned value.
*/
function dispatch( storeNameOrDescriptor ) {
const storeName = getStoreName( storeNameOrDescriptor );
const store = stores[ storeName ];
if ( store ) {
return store.getActions();
}
return parent && parent.dispatch( storeName );
}
//
// Deprecated
// TODO: Remove this after `use()` is removed.
function withPlugins( attributes ) {
return Object.fromEntries(
Object.entries( attributes ).map( ( [ key, attribute ] ) => {
if ( typeof attribute !== 'function' ) {
return [ key, attribute ];
}
return [
key,
function () {
return registry[ key ].apply( null, arguments );
},
];
} )
);
}
/**
* Registers a store instance.
*
* @param {string} name Store registry name.
* @param {Function} createStore Function that creates a store object (getSelectors, getActions, subscribe).
*/
function registerStoreInstance( name, createStore ) {
if ( stores[ name ] ) {
// eslint-disable-next-line no-console
console.error( 'Store "' + name + '" is already registered.' );
return stores[ name ];
}
const store = createStore();
if ( typeof store.getSelectors !== 'function' ) {
throw new TypeError( 'store.getSelectors must be a function' );
}
if ( typeof store.getActions !== 'function' ) {
throw new TypeError( 'store.getActions must be a function' );
}
if ( typeof store.subscribe !== 'function' ) {
throw new TypeError( 'store.subscribe must be a function' );
}
// The emitter is used to keep track of active listeners when the registry
// get paused, that way, when resumed we should be able to call all these
// pending listeners.
store.emitter = createEmitter();
const currentSubscribe = store.subscribe;
store.subscribe = ( listener ) => {
const unsubscribeFromEmitter = store.emitter.subscribe( listener );
const unsubscribeFromStore = currentSubscribe( () => {
if ( store.emitter.isPaused ) {
store.emitter.emit();
return;
}
listener();
} );
return () => {
unsubscribeFromStore?.();
unsubscribeFromEmitter?.();
};
};
stores[ name ] = store;
store.subscribe( globalListener );
// Copy private actions and selectors from the parent store.
if ( parent ) {
try {
unlock( store.store ).registerPrivateActions(
unlock( parent ).privateActionsOf( name )
);
unlock( store.store ).registerPrivateSelectors(
unlock( parent ).privateSelectorsOf( name )
);
} catch ( e ) {
// unlock() throws if store.store was not locked.
// The error indicates there's nothing to do here so let's
// ignore it.
}
}
return store;
}
/**
* Registers a new store given a store descriptor.
*
* @param {StoreDescriptor} store Store descriptor.
*/
function register( store ) {
registerStoreInstance( store.name, () =>
store.instantiate( registry )
);
}
function registerGenericStore( name, store ) {
deprecated( 'wp.data.registerGenericStore', {
since: '5.9',
alternative: 'wp.data.register( storeDescriptor )',
} );
registerStoreInstance( name, () => store );
}
/**
* Registers a standard `@wordpress/data` store.
*
* @param {string} storeName Unique namespace identifier.
* @param {Object} options Store description (reducer, actions, selectors, resolvers).
*
* @return {Object} Registered store object.
*/
function registerStore( storeName, options ) {
if ( ! options.reducer ) {
throw new TypeError( 'Must specify store reducer' );
}
const store = registerStoreInstance( storeName, () =>
createReduxStore( storeName, options ).instantiate( registry )
);
return store.store;
}
function batch( callback ) {
// If we're already batching, just call the callback.
if ( emitter.isPaused ) {
callback();
return;
}
emitter.pause();
Object.values( stores ).forEach( ( store ) => store.emitter.pause() );
callback();
emitter.resume();
Object.values( stores ).forEach( ( store ) => store.emitter.resume() );
}
let registry = {
batch,
stores,
namespaces: stores, // TODO: Deprecate/remove this.
subscribe,
select,
resolveSelect,
suspendSelect,
dispatch,
use,
register,
registerGenericStore,
registerStore,
__unstableMarkListeningStores,
};
//
// TODO:
// This function will be deprecated as soon as it is no longer internally referenced.
function use( plugin, options ) {
if ( ! plugin ) {
return;
}
registry = {
...registry,
...plugin( registry, options ),
};
return registry;
}
registry.register( coreDataStore );
for ( const [ name, config ] of Object.entries( storeConfigs ) ) {
registry.register( createReduxStore( name, config ) );
}
if ( parent ) {
parent.subscribe( globalListener );
}
const registryWithPlugins = withPlugins( registry );
lock( registryWithPlugins, {
privateActionsOf: ( name ) => {
try {
return unlock( stores[ name ].store ).privateActions;
} catch ( e ) {
// unlock() throws an error the store was not locked this means
// there no private actions are available
return {};
}
},
privateSelectorsOf: ( name ) => {
try {
return unlock( stores[ name ].store ).privateSelectors;
} catch ( e ) {
return {};
}
},
} );
return registryWithPlugins;
}

View File

@@ -0,0 +1,48 @@
/** @typedef {import('./registry').WPDataRegistry} WPDataRegistry */
/**
* Creates a middleware handling resolvers cache invalidation.
*
* @param {WPDataRegistry} registry Registry for which to create the middleware.
* @param {string} storeName Name of the store for which to create the middleware.
*
* @return {Function} Middleware function.
*/
const createResolversCacheMiddleware =
( registry, storeName ) => () => ( next ) => ( action ) => {
const resolvers = registry.select( storeName ).getCachedResolvers();
const resolverEntries = Object.entries( resolvers );
resolverEntries.forEach( ( [ selectorName, resolversByArgs ] ) => {
const resolver =
registry.stores[ storeName ]?.resolvers?.[ selectorName ];
if ( ! resolver || ! resolver.shouldInvalidate ) {
return;
}
resolversByArgs.forEach( ( value, args ) => {
// Works around a bug in `EquivalentKeyMap` where `map.delete` merely sets an entry value
// to `undefined` and `map.forEach` then iterates also over these orphaned entries.
if ( value === undefined ) {
return;
}
// resolversByArgs is the map Map([ args ] => boolean) storing the cache resolution status for a given selector.
// If the value is "finished" or "error" it means this resolver has finished its resolution which means we need
// to invalidate it, if it's true it means it's inflight and the invalidation is not necessary.
if ( value.status !== 'finished' && value.status !== 'error' ) {
return;
}
if ( ! resolver.shouldInvalidate( action, ...args ) ) {
return;
}
// Trigger cache invalidation
registry
.dispatch( storeName )
.invalidateResolution( selectorName, args );
} );
} );
return next( action );
};
export default createResolversCacheMiddleware;

30
node_modules/@wordpress/data/src/select.ts generated vendored Normal file
View File

@@ -0,0 +1,30 @@
/**
* Internal dependencies
*/
import type { AnyConfig, CurriedSelectorsOf, StoreDescriptor } from './types';
import defaultRegistry from './default-registry';
/**
* Given a store descriptor, returns an object of the store's selectors.
* The selector functions are been pre-bound to pass the current state automatically.
* As a consumer, you need only pass arguments of the selector, if applicable.
*
*
* @param storeNameOrDescriptor The store descriptor. The legacy calling convention
* of passing the store name is also supported.
*
* @example
* ```js
* import { select } from '@wordpress/data';
* import { store as myCustomStore } from 'my-custom-store';
*
* select( myCustomStore ).getPrice( 'hammer' );
* ```
*
* @return Object containing the store's selectors.
*/
export function select< T extends StoreDescriptor< AnyConfig > >(
storeNameOrDescriptor: string | T
): CurriedSelectorsOf< T > {
return defaultRegistry.select( storeNameOrDescriptor );
}

58
node_modules/@wordpress/data/src/store/index.js generated vendored Normal file
View File

@@ -0,0 +1,58 @@
const coreDataStore = {
name: 'core/data',
instantiate( registry ) {
const getCoreDataSelector =
( selectorName ) =>
( key, ...args ) => {
return registry.select( key )[ selectorName ]( ...args );
};
const getCoreDataAction =
( actionName ) =>
( key, ...args ) => {
return registry.dispatch( key )[ actionName ]( ...args );
};
return {
getSelectors() {
return Object.fromEntries(
[
'getIsResolving',
'hasStartedResolution',
'hasFinishedResolution',
'isResolving',
'getCachedResolvers',
].map( ( selectorName ) => [
selectorName,
getCoreDataSelector( selectorName ),
] )
);
},
getActions() {
return Object.fromEntries(
[
'startResolution',
'finishResolution',
'invalidateResolution',
'invalidateResolutionForStore',
'invalidateResolutionForStoreSelector',
].map( ( actionName ) => [
actionName,
getCoreDataAction( actionName ),
] )
);
},
subscribe() {
// There's no reasons to trigger any listener when we subscribe to this store
// because there's no state stored in this store that need to retrigger selectors
// if a change happens, the corresponding store where the tracking stated live
// would have already triggered a "subscribe" call.
return () => () => {};
},
};
},
};
export default coreDataStore;

173
node_modules/@wordpress/data/src/test/controls.js generated vendored Normal file
View File

@@ -0,0 +1,173 @@
/**
* Internal dependencies
*/
import { createRegistry, controls } from '..';
describe( 'controls', () => {
// Create a registry with store to test select controls.
function createSelectTestRegistry() {
const registry = createRegistry();
// State is initially null and can receive data.
// Typical for fetching data from remote locations.
const reducer = ( state = null, action ) => {
switch ( action.type ) {
case 'RECEIVE':
return action.data;
default:
return state;
}
};
// Select state both without and with a resolver.
const selectors = {
selectorWithoutResolver: ( state ) => state,
selectorWithResolver: ( state ) => state,
};
// The resolver receives data after a little delay.
const resolvers = {
*selectorWithResolver() {
yield new Promise( ( r ) => setTimeout( r, 10 ) );
return { type: 'RECEIVE', data: 'resolved-data' };
},
};
// actions that call the tested controls and return the selected value.
const actions = {
*resolveWithoutResolver() {
const value = yield controls.resolveSelect(
'test/select',
'selectorWithoutResolver'
);
return value;
},
*resolveWithResolver() {
const value = yield controls.resolveSelect(
'test/select',
'selectorWithResolver'
);
return value;
},
*selectWithoutResolver() {
const value = yield controls.select(
'test/select',
'selectorWithoutResolver'
);
return value;
},
*selectWithResolver() {
const value = yield controls.select(
'test/select',
'selectorWithResolver'
);
return value;
},
};
registry.registerStore( 'test/select', {
reducer,
actions,
selectors,
resolvers,
} );
return registry;
}
describe( 'resolveSelect', () => {
it( 'invokes selector without a resolver', async () => {
const registry = createSelectTestRegistry();
const value = await registry
.dispatch( 'test/select' )
.resolveWithoutResolver();
// Returns the state value without waiting for any resolver.
expect( value ).toBe( null );
} );
it( 'resolves selector with a resolver', async () => {
const registry = createSelectTestRegistry();
const value = await registry
.dispatch( 'test/select' )
.resolveWithResolver();
// Waits for the resolver to resolve and returns the resolved data.
// Never returns the initial `null` state.
expect( value ).toBe( 'resolved-data' );
} );
} );
describe( 'select', () => {
it( 'invokes selector without a resolver', async () => {
const registry = createSelectTestRegistry();
const value = await registry
.dispatch( 'test/select' )
.selectWithoutResolver();
// Returns the state value without waiting for any resolver.
expect( value ).toBe( null );
} );
it( 'invokes selector with a resolver', async () => {
const registry = createSelectTestRegistry();
// Check that the action with a control returns the initial state
// without waiting for any resolver.
const value = await registry
.dispatch( 'test/select' )
.selectWithResolver();
expect( value ).toBe( null );
// Check that re-running the action immediately still returns
// the initial state, as the resolution is still running.
const value2 = await registry
.dispatch( 'test/select' )
.selectWithResolver();
expect( value2 ).toBe( null );
} );
} );
describe( 'dispatch', () => {
function createDispatchTestRegistry() {
const registry = createRegistry();
// Store stores a counter that can be incremented.
const reducer = ( state = 0, action ) => {
switch ( action.type ) {
case 'INC':
return state + 1;
default:
return state;
}
};
const actions = {
// increment the counter.
inc() {
return { type: 'INC' };
},
// Increment the counter twice in an async routine with controls.
*doubleInc() {
yield controls.dispatch( 'test/dispatch', 'inc' );
yield controls.dispatch( 'test/dispatch', 'inc' );
},
};
const selectors = {
get: ( state ) => state,
};
registry.registerStore( 'test/dispatch', {
reducer,
actions,
selectors,
} );
return registry;
}
it( 'invokes dispatch action', async () => {
const registry = createDispatchTestRegistry();
expect( registry.select( 'test/dispatch' ).get() ).toBe( 0 );
await registry.dispatch( 'test/dispatch' ).doubleInc();
expect( registry.select( 'test/dispatch' ).get() ).toBe( 2 );
} );
} );
} );

420
node_modules/@wordpress/data/src/test/privateAPIs.js generated vendored Normal file
View File

@@ -0,0 +1,420 @@
/**
* Internal dependencies
*/
import { createRegistry } from '../registry';
import createReduxStore from '../redux-store';
import { unlock } from '../lock-unlock';
import { createRegistrySelector } from '../factory';
describe( 'Private data APIs', () => {
let registry;
beforeEach( () => {
registry = createRegistry();
} );
function getPublicPrice( state ) {
return state.price;
}
function getSecretDiscount( state ) {
return state.secretDiscount;
}
function setSecretDiscount( price ) {
return { type: 'SET_PRIVATE_PRICE', price };
}
function setPublicPrice( price ) {
return { type: 'SET_PUBLIC_PRICE', price };
}
const storeName = 'grocer';
const storeDescriptor = {
selectors: {
getPublicPrice,
getState: ( state ) => state,
},
actions: { setPublicPrice },
reducer: ( state = { price: 1000, secretDiscount: 800 }, action ) => {
if ( action.type === 'SET_PRIVATE_PRICE' ) {
return {
...state,
secretDiscount: action.price,
};
} else if ( action.type === 'SET_PUBLIC_PRICE' ) {
return {
...state,
price: action.price,
};
}
return state;
},
};
function createStore() {
const groceryStore = createReduxStore( storeName, storeDescriptor );
registry.register( groceryStore );
return groceryStore;
}
describe( 'private selectors', () => {
it( 'should expose public selectors by default', () => {
const groceryStore = createStore();
unlock( groceryStore ).registerPrivateSelectors( groceryStore, {
getSecretDiscount,
} );
const publicSelectors = registry.select( groceryStore );
expect( publicSelectors.getPublicPrice ).toEqual(
expect.any( Function )
);
} );
it( 'should not expose private selectors by default', () => {
const groceryStore = createStore();
unlock( groceryStore ).registerPrivateSelectors( {
getSecretDiscount,
} );
const publicSelectors = registry.select( groceryStore );
expect( publicSelectors.getSecretDiscount ).toEqual( undefined );
} );
it( 'should make private selectors available via unlock()', () => {
const groceryStore = createStore();
unlock( groceryStore ).registerPrivateSelectors( {
getSecretDiscount,
} );
const privateSelectors = unlock( registry.select( groceryStore ) );
expect( privateSelectors.getSecretDiscount ).toEqual(
expect.any( Function )
);
// The public selector is still accessible:
expect( privateSelectors.getPublicPrice ).toEqual(
expect.any( Function )
);
} );
it( 'should support combination of private selectors and resolvers', async () => {
const testStore = createReduxStore( 'test', {
reducer: ( state = {}, action ) => {
if ( action.type === 'RECEIVE' ) {
return { ...state, [ action.key ]: action.value };
}
return state;
},
selectors: {
get: ( state, key ) => state[ key ],
},
resolvers: {
get:
( key ) =>
async ( { dispatch } ) => {
const value = await ( 'resolved-' + key );
dispatch( { type: 'RECEIVE', key, value } );
},
},
} );
unlock( testStore ).registerPrivateSelectors( {
peek: ( state, key ) => state[ key ],
} );
registry.register( testStore );
await registry.resolveSelect( testStore ).get( 'x' );
expect( unlock( registry.select( testStore ) ).peek( 'x' ) ).toBe(
'resolved-x'
);
} );
it( 'should give private selectors access to the state', () => {
const groceryStore = createStore();
unlock( groceryStore ).registerPrivateSelectors( {
getSecretDiscount,
} );
const privateSelectors = unlock( registry.select( groceryStore ) );
expect( privateSelectors.getSecretDiscount() ).toEqual( 800 );
} );
it( 'should support public selectors accessed via unlock()', () => {
const groceryStore = createStore();
unlock( groceryStore ).registerPrivateSelectors( {
getSecretDiscount,
} );
const unlockedSelectors = unlock( registry.select( groceryStore ) );
expect( unlockedSelectors.getPublicPrice() ).toEqual( 1000 );
} );
it( 'should return stable references to selectors', () => {
const groceryStore = createStore();
unlock( groceryStore ).registerPrivateSelectors( {
getSecretDiscount,
} );
const select = unlock( registry.select( groceryStore ) );
expect( select.getPublicPrice ).toBe( select.getPublicPrice );
expect( select.getSecretDiscount ).toBe( select.getSecretDiscount );
} );
it( 'should support registerStore', () => {
const groceryStore = registry.registerStore(
storeName,
storeDescriptor
);
unlock( groceryStore ).registerPrivateSelectors( {
getSecretDiscount,
} );
const privateSelectors = unlock( registry.select( storeName ) );
expect( privateSelectors.getSecretDiscount() ).toEqual( 800 );
} );
it( 'should support mixing createReduxStore and registerStore', () => {
createReduxStore( storeName, storeDescriptor );
const groceryStore2 = registry.registerStore(
storeName,
storeDescriptor
);
unlock( groceryStore2 ).registerPrivateSelectors( {
getSecretDiscount,
} );
const privateSelectors = unlock( registry.select( storeName ) );
expect( privateSelectors.getSecretDiscount() ).toEqual( 800 );
} );
it( 'should support sub registries', () => {
const groceryStore = registry.registerStore(
storeName,
storeDescriptor
);
unlock( groceryStore ).registerPrivateSelectors( {
getSecretDiscount,
} );
const subRegistry = createRegistry( {}, registry );
subRegistry.registerStore( storeName, storeDescriptor );
const parentPrivateSelectors = unlock(
registry.select( storeName )
);
expect( parentPrivateSelectors.getSecretDiscount() ).toEqual( 800 );
const subPrivateSelectors = unlock(
subRegistry.select( storeName )
);
expect( subPrivateSelectors.getSecretDiscount() ).toEqual( 800 );
} );
it( 'should support private registry selectors', () => {
const groceryStore = createStore();
const otherStore = createReduxStore( 'other', {
reducer: ( state = {} ) => state,
} );
unlock( otherStore ).registerPrivateSelectors( {
getPrice: createRegistrySelector(
( select ) => () => select( groceryStore ).getPublicPrice()
),
} );
registry.register( otherStore );
const privateSelectors = unlock( registry.select( otherStore ) );
expect( privateSelectors.getPrice() ).toEqual( 1000 );
} );
it( 'should support calling a private registry selector from a public selector', () => {
const groceryStore = createStore();
const getPriceWithShipping = createRegistrySelector(
( select ) => () => select( groceryStore ).getPublicPrice() + 5
);
const store = createReduxStore( 'a', {
reducer: ( state = {} ) => state,
selectors: {
getPriceWithShippingAndTax: ( state ) =>
getPriceWithShipping( state ) * 1.1,
},
} );
unlock( store ).registerPrivateSelectors( {
getPriceWithShipping,
} );
registry.register( store );
expect(
registry.select( store ).getPriceWithShippingAndTax()
).toEqual( 1105.5 );
} );
} );
describe( 'private actions', () => {
it( 'should expose public actions by default', () => {
const groceryStore = createStore();
unlock( groceryStore ).registerPrivateActions( groceryStore, {
setSecretDiscount,
} );
const publicActions = registry.dispatch( groceryStore );
expect( publicActions.setPublicPrice ).toEqual(
expect.any( Function )
);
} );
it( 'should not expose private actions by default', () => {
const groceryStore = createStore();
unlock( groceryStore ).registerPrivateActions( {
setSecretDiscount,
} );
const publicActions = registry.dispatch( groceryStore );
expect( publicActions.setSecretDiscount ).toEqual( undefined );
} );
it( 'should make private actions available via unlock)', () => {
const groceryStore = createStore();
unlock( groceryStore ).registerPrivateActions( {
setSecretDiscount,
} );
const privateActions = unlock( registry.dispatch( groceryStore ) );
expect( privateActions.setSecretDiscount ).toEqual(
expect.any( Function )
);
// The public action is still accessible:
expect( privateActions.setPublicPrice ).toEqual(
expect.any( Function )
);
} );
it( 'should work with both private actions and private selectors at the same time', () => {
const groceryStore = createStore();
unlock( groceryStore ).registerPrivateActions( {
setSecretDiscount,
} );
unlock( groceryStore ).registerPrivateSelectors( {
getSecretDiscount,
} );
const privateActions = unlock( registry.dispatch( groceryStore ) );
const privateSelectors = unlock( registry.select( groceryStore ) );
expect( privateActions.setSecretDiscount ).toEqual(
expect.any( Function )
);
expect( privateSelectors.getSecretDiscount ).toEqual(
expect.any( Function )
);
} );
it( 'should dispatch private actions like regular actions', () => {
const groceryStore = createStore();
unlock( groceryStore ).registerPrivateActions( {
setSecretDiscount,
} );
const privateActions = unlock( registry.dispatch( groceryStore ) );
privateActions.setSecretDiscount( 400 );
expect(
registry.select( groceryStore ).getState().secretDiscount
).toEqual( 400 );
} );
it( 'should return stable references to actions', () => {
const groceryStore = createStore();
unlock( groceryStore ).registerPrivateActions( {
setSecretDiscount,
} );
const disp = unlock( registry.dispatch( groceryStore ) );
expect( disp.setPublicPrice ).toBe( disp.setPublicPrice );
expect( disp.setSecretDiscount ).toBe( disp.setSecretDiscount );
} );
it( 'should dispatch public actions on the unlocked store', () => {
const groceryStore = createStore();
unlock( groceryStore ).registerPrivateActions( {
setSecretDiscount,
} );
const privateActions = unlock( registry.dispatch( groceryStore ) );
privateActions.setPublicPrice( 400 );
expect( registry.select( groceryStore ).getState().price ).toEqual(
400
);
} );
it( 'should dispatch private action thunks like regular actions', () => {
const groceryStore = createStore();
unlock( groceryStore ).registerPrivateActions( {
setSecretDiscountThunk:
( price ) =>
( { dispatch } ) => {
dispatch( { type: 'SET_PRIVATE_PRICE', price } );
},
} );
unlock( groceryStore ).registerPrivateSelectors( {
getSecretDiscount,
} );
const privateActions = unlock( registry.dispatch( groceryStore ) );
privateActions.setSecretDiscountThunk( 100 );
expect(
unlock( registry.select( groceryStore ) ).getSecretDiscount()
).toEqual( 100 );
} );
it( 'should expose unlocked private selectors and actions to thunks', () => {
const groceryStore = createStore();
unlock( groceryStore ).registerPrivateSelectors( {
getSecretDiscount,
} );
unlock( groceryStore ).registerPrivateActions( {
setSecretDiscount,
doubleSecretDiscount() {
return ( { dispatch, select } ) => {
dispatch.setSecretDiscount(
select.getSecretDiscount() * 2
);
};
},
} );
const privateActions = unlock( registry.dispatch( groceryStore ) );
privateActions.setSecretDiscount( 100 );
privateActions.doubleSecretDiscount();
expect(
unlock( registry.select( groceryStore ) ).getSecretDiscount()
).toEqual( 200 );
} );
it( 'should support registerStore', () => {
const groceryStore = registry.registerStore(
storeName,
storeDescriptor
);
unlock( groceryStore ).registerPrivateActions( {
setSecretDiscount,
} );
const privateActions = unlock( registry.dispatch( storeName ) );
privateActions.setSecretDiscount( 400 );
expect(
registry.select( storeName ).getState().secretDiscount
).toEqual( 400 );
} );
it( 'should support sub registries', () => {
const groceryStore = createStore();
unlock( groceryStore ).registerPrivateSelectors( {
getSecretDiscount,
} );
unlock( groceryStore ).registerPrivateActions( {
setSecretDiscount,
} );
const subRegistry = createRegistry( {}, registry );
subRegistry.registerStore( storeName, storeDescriptor );
const parentPrivateActions = unlock(
registry.dispatch( storeName )
);
const parentPrivateSelectors = unlock(
registry.select( storeName )
);
const subPrivateActions = unlock(
subRegistry.dispatch( storeName )
);
const subPrivateSelectors = unlock(
subRegistry.select( storeName )
);
parentPrivateActions.setSecretDiscount( 400 );
subPrivateActions.setSecretDiscount( 478 );
expect( parentPrivateSelectors.getSecretDiscount() ).toEqual( 400 );
expect( subPrivateSelectors.getSecretDiscount() ).toEqual( 478 );
} );
} );
} );

View File

@@ -0,0 +1,150 @@
/**
* Internal dependencies
*/
import { createRegistry } from '../registry';
import { createRegistrySelector } from '../factory';
import { createSelector } from '..';
import createReduxStore from '../redux-store';
const getElementCount = createRegistrySelector( ( select ) => () => {
return select( 'elements' ).getElements().length;
} );
const getFilterValue = ( state ) => state;
const getFilteredElements = createRegistrySelector( ( select ) =>
createSelector(
( state ) => {
const filter = getFilterValue( state );
const elements = select( 'elements' ).getElements();
if ( ! filter ) {
return elements;
}
return elements.filter( ( el ) => el.startsWith( filter ) );
},
( state ) => [
select( 'elements' ).getElements(),
getFilterValue( state ),
]
)
);
const elementsStore = createReduxStore( 'elements', {
reducer( state = [], action ) {
if ( action.type === 'ADD' ) {
return [ ...state, ...action.elements ];
}
return state;
},
actions: {
add: ( ...elements ) => ( { type: 'ADD', elements } ),
},
selectors: {
getElements: ( state ) => state,
},
} );
const uiStore = createReduxStore( 'ui', {
reducer( state = '', action ) {
if ( action.type === 'FILTER' ) {
return action.value;
}
return state;
},
actions: {
filter: ( value ) => ( { type: 'FILTER', value } ),
},
selectors: {
getFilterValue,
getElementCount,
getFilteredElements,
},
} );
describe( 'createRegistrySelector', () => {
it( 'can bind one selector to a registry', () => {
const registry = createRegistry();
registry.register( elementsStore );
registry.register( uiStore );
expect( registry.select( uiStore ).getElementCount() ).toBe( 0 );
registry.dispatch( elementsStore ).add( 'Carbon' );
expect( registry.select( uiStore ).getElementCount() ).toBe( 1 );
} );
it( 'can bind one selector to multiple registries (createRegistrySelector)', () => {
const registry1 = createRegistry();
const registry2 = createRegistry();
registry1.register( elementsStore );
registry1.register( uiStore );
registry2.register( elementsStore );
registry2.register( uiStore );
expect( registry1.select( uiStore ).getElementCount() ).toBe( 0 );
expect( registry2.select( uiStore ).getElementCount() ).toBe( 0 );
registry1.dispatch( elementsStore ).add( 'Carbon' );
expect( registry1.select( uiStore ).getElementCount() ).toBe( 1 );
expect( registry2.select( uiStore ).getElementCount() ).toBe( 0 );
registry2.dispatch( elementsStore ).add( 'Helium' );
expect( registry1.select( uiStore ).getElementCount() ).toBe( 1 );
expect( registry2.select( uiStore ).getElementCount() ).toBe( 1 );
} );
it( 'can bind one selector to multiple registries (createRegistrySelector + createSelector)', () => {
const registry1 = createRegistry();
registry1.register( elementsStore );
registry1.register( uiStore );
registry1.dispatch( elementsStore ).add( 'Carbon' );
const registry2 = createRegistry();
registry2.register( elementsStore );
registry2.register( uiStore );
registry2.dispatch( elementsStore ).add( 'Helium' );
// Expects the `getFilteredElements` to be bound separately and independently to the two registries
expect( registry1.select( uiStore ).getFilteredElements() ).toEqual( [
'Carbon',
] );
expect( registry2.select( uiStore ).getFilteredElements() ).toEqual( [
'Helium',
] );
} );
it( 'can bind a memoized selector to a registry', () => {
const registry = createRegistry();
registry.register( elementsStore );
registry.register( uiStore );
registry.dispatch( elementsStore ).add( 'Carbon', 'Nitrogen' );
registry.dispatch( uiStore ).filter( 'Ca' );
// check that `getFilteredElements` return value is a memoized array
const elements1 = registry.select( uiStore ).getFilteredElements();
expect( elements1 ).toEqual( [ 'Carbon' ] );
const elements2 = registry.select( uiStore ).getFilteredElements();
expect( elements2 ).toBe( elements1 ); // memoized
// check that adding a new element triggers recalculation
registry.dispatch( elementsStore ).add( 'Calcium' );
const elements3 = registry.select( uiStore ).getFilteredElements();
expect( elements3 ).toEqual( [ 'Carbon', 'Calcium' ] );
const elements4 = registry.select( uiStore ).getFilteredElements();
expect( elements4 ).toBe( elements3 ); // memoized
// check that changing the filter triggers recalculation
registry.dispatch( uiStore ).filter( 'Ni' );
const elements5 = registry.select( uiStore ).getFilteredElements();
expect( elements5 ).toEqual( [ 'Nitrogen' ] );
const elements6 = registry.select( uiStore ).getFilteredElements();
expect( elements6 ).toBe( elements5 ); // memoized
} );
} );

872
node_modules/@wordpress/data/src/test/registry.js generated vendored Normal file
View File

@@ -0,0 +1,872 @@
/* eslint jest/expect-expect: ["warn", { "assertFunctionNames": ["expect", "subscribeUntil"] }] */
/**
* Internal dependencies
*/
import { createRegistry } from '../registry';
import { createRegistrySelector } from '../factory';
import createReduxStore from '../redux-store';
import coreDataStore from '../store';
jest.useFakeTimers( { legacyFakeTimers: true } );
describe( 'createRegistry', () => {
let registry;
const unsubscribes = [];
function subscribeWithUnsubscribe( ...args ) {
const unsubscribe = registry.subscribe( ...args );
unsubscribes.push( unsubscribe );
return unsubscribe;
}
function subscribeUntil( predicates ) {
predicates = Array.from( predicates );
return new Promise( ( resolve ) => {
subscribeWithUnsubscribe( () => {
if ( predicates.every( ( predicate ) => predicate() ) ) {
resolve();
}
} );
} );
}
beforeEach( () => {
registry = createRegistry();
} );
afterEach( () => {
let unsubscribe;
while ( ( unsubscribe = unsubscribes.shift() ) ) {
unsubscribe();
}
} );
describe( 'registerGenericStore', () => {
let getSelectors;
let getActions;
let subscribe;
beforeEach( () => {
getSelectors = () => ( {} );
getActions = () => ( {} );
subscribe = () => ( {} );
} );
it( 'should throw if not all required config elements are present', () => {
expect( () =>
registry.registerGenericStore( 'grocer', {} )
).toThrow();
expect( () =>
registry.registerGenericStore( 'grocer', {
getSelectors,
getActions,
} )
).toThrow();
expect( () =>
registry.registerGenericStore( 'grocer', {
getActions,
subscribe,
} )
).toThrow();
expect( console ).toHaveWarned();
} );
describe( 'getSelectors', () => {
it( 'should make selectors available via registry.select', () => {
const items = {
broccoli: { price: 2, quantity: 15 },
lettuce: { price: 1, quantity: 12 },
};
function getPrice( itemName ) {
const item = items[ itemName ];
return item && item.price;
}
function getQuantity( itemName ) {
const item = items[ itemName ];
return item && item.quantity;
}
getSelectors = () => ( { getPrice, getQuantity } );
registry.registerGenericStore( 'grocer', {
getSelectors,
getActions,
subscribe,
} );
expect( registry.select( 'grocer' ).getPrice ).toEqual(
getPrice
);
expect( registry.select( 'grocer' ).getQuantity ).toEqual(
getQuantity
);
} );
} );
describe( 'getActions', () => {
it( 'should make actions available via registry.dispatch', () => {
const dispatch = jest.fn();
function setPrice( itemName, price ) {
return { type: 'SET_PRICE', itemName, price };
}
function setQuantity( itemName, quantity ) {
return { type: 'SET_QUANTITY', itemName, quantity };
}
getActions = () => {
return {
setPrice: ( ...args ) =>
dispatch( setPrice( ...args ) ),
setQuantity: ( ...args ) =>
dispatch( setQuantity( ...args ) ),
};
};
registry.registerGenericStore( 'grocer', {
getSelectors,
getActions,
subscribe,
} );
expect( dispatch ).not.toHaveBeenCalled();
registry.dispatch( 'grocer' ).setPrice( 'broccoli', 3 );
expect( dispatch ).toHaveBeenCalledTimes( 1 );
expect( dispatch ).toHaveBeenCalledWith( {
type: 'SET_PRICE',
itemName: 'broccoli',
price: 3,
} );
registry.dispatch( 'grocer' ).setQuantity( 'lettuce', 8 );
expect( dispatch ).toHaveBeenCalledTimes( 2 );
expect( dispatch ).toHaveBeenCalledWith( {
type: 'SET_QUANTITY',
itemName: 'lettuce',
quantity: 8,
} );
} );
} );
describe( 'subscribe', () => {
it( 'should send out updates to listeners of the registry', () => {
const registryListener = jest.fn();
let listener = () => {};
const storeChanged = () => {
listener();
};
subscribe = ( newListener ) => {
listener = newListener;
};
const unsubscribe = registry.subscribe( registryListener );
registry.registerGenericStore( 'grocer', {
getSelectors,
getActions,
subscribe,
} );
expect( registryListener ).not.toHaveBeenCalled();
storeChanged();
expect( registryListener ).toHaveBeenCalledTimes( 1 );
storeChanged();
expect( registryListener ).toHaveBeenCalledTimes( 2 );
unsubscribe();
storeChanged();
expect( registryListener ).toHaveBeenCalledTimes( 2 );
} );
} );
} );
describe( 'registerStore', () => {
it( 'should be shorthand for reducer, actions, selectors registration', () => {
const store = registry.registerStore( 'butcher', {
reducer( state = {}, action ) {
switch ( action.type ) {
case 'sale':
return {
...state,
[ action.meat ]: state[ action.meat ] / 2,
};
}
return state;
},
initialState: { ribs: 6, chicken: 4 },
selectors: {
getPrice: ( state, meat ) => state[ meat ],
},
actions: {
startSale: ( meat ) => ( { type: 'sale', meat } ),
},
} );
expect( store.getState() ).toEqual( { ribs: 6, chicken: 4 } );
expect( registry.dispatch( 'butcher' ) ).toHaveProperty(
'startSale'
);
expect( registry.select( 'butcher' ) ).toHaveProperty( 'getPrice' );
expect( registry.select( 'butcher' ).getPrice( 'chicken' ) ).toBe(
4
);
expect( registry.select( 'butcher' ).getPrice( 'ribs' ) ).toBe( 6 );
registry.dispatch( 'butcher' ).startSale( 'chicken' );
expect( registry.select( 'butcher' ).getPrice( 'chicken' ) ).toBe(
2
);
expect( registry.select( 'butcher' ).getPrice( 'ribs' ) ).toBe( 6 );
} );
it( 'Should append reducers to the state', () => {
const reducer1 = () => 'chicken';
const reducer2 = () => 'ribs';
const store = registry.registerStore( 'red1', {
reducer: reducer1,
} );
expect( store.getState() ).toEqual( 'chicken' );
const store2 = registry.registerStore( 'red2', {
reducer: reducer2,
} );
expect( store2.getState() ).toEqual( 'ribs' );
} );
it( 'should not do anything for selectors which do not have resolvers', () => {
registry.registerStore( 'demo', {
reducer: ( state = 'OK' ) => state,
selectors: {
getValue: ( state ) => state,
},
resolvers: {},
} );
expect( registry.select( 'demo' ).getValue() ).toBe( 'OK' );
} );
it( 'should behave as a side effect for the given selector, with arguments', () => {
const resolver = jest.fn();
registry.registerStore( 'demo', {
reducer: ( state = 'OK' ) => state,
selectors: {
getValue: ( state ) => state,
},
resolvers: {
getValue: resolver,
},
} );
const value = registry.select( 'demo' ).getValue( 'arg1', 'arg2' );
jest.runAllTimers();
expect( value ).toBe( 'OK' );
expect( resolver ).toHaveBeenCalledWith( 'arg1', 'arg2' );
registry.select( 'demo' ).getValue( 'arg1', 'arg2' );
jest.runAllTimers();
expect( resolver ).toHaveBeenCalledTimes( 1 );
registry.select( 'demo' ).getValue( 'arg3', 'arg4' );
jest.runAllTimers();
expect( resolver ).toHaveBeenCalledTimes( 2 );
} );
it( 'should support the object resolver descriptor', () => {
const resolver = jest.fn();
registry.registerStore( 'demo', {
reducer: ( state = 'OK' ) => state,
selectors: {
getValue: ( state ) => state,
},
resolvers: {
getValue: { fulfill: resolver },
},
} );
const value = registry.select( 'demo' ).getValue( 'arg1', 'arg2' );
jest.runAllTimers();
expect( value ).toBe( 'OK' );
} );
it( 'should use isFulfilled definition before calling the side effect', () => {
const fulfill = jest.fn().mockImplementation( ( state, page ) => {
return { type: 'SET_PAGE', page, result: [] };
} );
const store = registry.registerStore( 'demo', {
reducer: ( state = {}, action ) => {
switch ( action.type ) {
case 'SET_PAGE':
return {
...state,
[ action.page ]: action.result,
};
}
return state;
},
selectors: {
getPage: ( state, page ) => state[ page ],
},
resolvers: {
getPage: {
fulfill,
isFulfilled( state, page ) {
return state.hasOwnProperty( page );
},
},
},
} );
store.dispatch( { type: 'SET_PAGE', page: 4, result: [] } );
registry.select( 'demo' ).getPage( 1 );
jest.runAllTimers();
registry.select( 'demo' ).getPage( 2 );
jest.runAllTimers();
expect( fulfill ).toHaveBeenCalledTimes( 2 );
registry.select( 'demo' ).getPage( 1 );
jest.runAllTimers();
registry.select( 'demo' ).getPage( 2 );
jest.runAllTimers();
registry.select( 'demo' ).getPage( 3, {} );
jest.runAllTimers();
// Expected: First and second page fulfillments already triggered, so
// should only be one more than previous assertion set.
expect( fulfill ).toHaveBeenCalledTimes( 3 );
registry.select( 'demo' ).getPage( 1 );
jest.runAllTimers();
registry.select( 'demo' ).getPage( 2 );
jest.runAllTimers();
registry.select( 'demo' ).getPage( 3, {} );
jest.runAllTimers();
registry.select( 'demo' ).getPage( 4 );
// Expected:
// - Fourth page was pre-filled. Necessary to determine via
// isFulfilled, but fulfillment resolver should not be triggered.
// - Third page arguments are not strictly equal but are equivalent,
// so fulfillment should already be satisfied.
expect( fulfill ).toHaveBeenCalledTimes( 3 );
registry.select( 'demo' ).getPage( 4, {} );
} );
it( 'should resolve action to dispatch', () => {
registry.registerStore( 'demo', {
reducer: ( state = 'NOTOK', action ) => {
return action.type === 'SET_OK' ? 'OK' : state;
},
selectors: {
getValue: ( state ) => state,
},
resolvers: {
getValue: () => ( { type: 'SET_OK' } ),
},
} );
const promise = subscribeUntil( [
() => registry.select( 'demo' ).getValue() === 'OK',
() =>
registry
.select( coreDataStore )
.hasFinishedResolution( 'demo', 'getValue' ),
] );
registry.select( 'demo' ).getValue();
jest.runAllTimers();
return promise;
} );
it( 'should resolve promise action to dispatch', () => {
registry.registerStore( 'demo', {
reducer: ( state = 'NOTOK', action ) => {
return action.type === 'SET_OK' ? 'OK' : state;
},
selectors: {
getValue: ( state ) => state,
},
resolvers: {
getValue: () => Promise.resolve( { type: 'SET_OK' } ),
},
} );
const promise = subscribeUntil( [
() => registry.select( 'demo' ).getValue() === 'OK',
() =>
registry
.select( coreDataStore )
.hasFinishedResolution( 'demo', 'getValue' ),
] );
registry.select( 'demo' ).getValue();
jest.runAllTimers();
return promise;
} );
it( 'should not dispatch resolved promise action on subsequent selector calls', () => {
registry.registerStore( 'demo', {
reducer: ( state = 'NOTOK', action ) => {
return action.type === 'SET_OK' && state === 'NOTOK'
? 'OK'
: 'NOTOK';
},
selectors: {
getValue: ( state ) => state,
},
resolvers: {
getValue: () => Promise.resolve( { type: 'SET_OK' } ),
},
} );
const promise = subscribeUntil(
() => registry.select( 'demo' ).getValue() === 'OK'
);
registry.select( 'demo' ).getValue();
jest.runAllTimers();
registry.select( 'demo' ).getValue();
jest.runAllTimers();
return promise;
} );
it( "should invalidate the resolver's resolution cache", async () => {
registry.registerStore( 'demo', {
reducer: ( state = 'NOTOK', action ) => {
return action.type === 'SET_OK' && state === 'NOTOK'
? 'OK'
: 'NOTOK';
},
selectors: {
getValue: ( state ) => state,
},
resolvers: {
getValue: {
fulfill: () => Promise.resolve( { type: 'SET_OK' } ),
shouldInvalidate: ( action ) =>
action.type === 'INVALIDATE',
},
},
actions: {
invalidate: () => ( { type: 'INVALIDATE' } ),
},
} );
let promise = subscribeUntil(
() => registry.select( 'demo' ).getValue() === 'OK'
);
registry.select( 'demo' ).getValue(); // Triggers resolver switches to OK.
jest.runAllTimers();
await promise;
// Invalidate the cache
registry.dispatch( 'demo' ).invalidate();
promise = subscribeUntil(
() => registry.select( 'demo' ).getValue() === 'NOTOK'
);
registry.select( 'demo' ).getValue(); // Triggers the resolver again and switch to NOTOK.
jest.runAllTimers();
await promise;
} );
} );
describe( 'register', () => {
const store = createReduxStore( 'demo', {
reducer( state = 'OK', action ) {
if ( action.type === 'UPDATE' ) {
return 'UPDATED';
}
return state;
},
actions: {
update: () => ( { type: 'UPDATE' } ),
},
selectors: {
getValue: ( state ) => state,
},
} );
it( 'should work with the store descriptor as param for select', () => {
registry.register( store );
expect( registry.select( store ).getValue() ).toBe( 'OK' );
} );
it( 'should work with the store descriptor as param for dispatch', async () => {
registry.register( store );
expect( registry.select( store ).getValue() ).toBe( 'OK' );
await registry.dispatch( store ).update();
expect( registry.select( store ).getValue() ).toBe( 'UPDATED' );
} );
it( 'should keep the existing store instance on duplicate registration', async () => {
registry.register( store );
await registry.dispatch( store ).update();
expect( registry.select( store ).getValue() ).toBe( 'UPDATED' );
registry.register( store );
// check that the state hasn't been reset back to `OK`, as a re-registration would do
expect( registry.select( store ).getValue() ).toBe( 'UPDATED' );
expect( console ).toHaveErroredWith(
'Store "demo" is already registered.'
);
} );
} );
describe( 'select', () => {
it( 'registers multiple selectors to the public API', () => {
const selector1 = jest.fn( () => 'result1' );
const selector2 = jest.fn( () => 'result2' );
const store = registry.registerStore( 'reducer1', {
reducer: () => 'state1',
selectors: {
selector1,
selector2,
},
} );
expect( registry.select( 'reducer1' ).selector1() ).toEqual(
'result1'
);
expect( selector1 ).toHaveBeenCalledWith( store.getState() );
expect( registry.select( 'reducer1' ).selector2() ).toEqual(
'result2'
);
expect( selector2 ).toHaveBeenCalledWith( store.getState() );
} );
it( 'should run the registry selectors properly', () => {
const selector1 = () => 'result1';
const selector2 = createRegistrySelector(
( select ) => () => select( 'reducer1' ).selector1()
);
registry.registerStore( 'reducer1', {
reducer: () => 'state1',
selectors: {
selector1,
},
} );
registry.registerStore( 'reducer2', {
reducer: () => 'state1',
selectors: {
selector2,
},
} );
expect( registry.select( 'reducer2' ).selector2() ).toEqual(
'result1'
);
} );
it( 'should run the registry selector from a non-registry selector', () => {
const selector1 = () => 'result1';
const selector2 = createRegistrySelector(
( select ) => () => select( 'reducer1' ).selector1()
);
const selector3 = () => selector2();
registry.registerStore( 'reducer1', {
reducer: () => 'state1',
selectors: {
selector1,
},
} );
registry.registerStore( 'reducer2', {
reducer: () => 'state1',
selectors: {
selector2,
selector3,
},
} );
expect( registry.select( 'reducer2' ).selector3() ).toEqual(
'result1'
);
} );
} );
describe( 'subscribe', () => {
it( 'registers multiple selectors to the public API', () => {
let incrementedValue = null;
const store = registry.registerStore( 'myAwesomeReducer', {
reducer: ( state = 0 ) => state + 1,
selectors: {
globalSelector: ( state ) => state,
},
} );
const unsubscribe = registry.subscribe( () => {
incrementedValue = registry
.select( 'myAwesomeReducer' )
.globalSelector();
} );
const action = { type: 'dummy' };
store.dispatch( action ); // Increment the data by => data = 2.
expect( incrementedValue ).toBe( 2 );
store.dispatch( action ); // Increment the data by => data = 3.
expect( incrementedValue ).toBe( 3 );
unsubscribe(); // Store subscribe to changes, the data variable stops upgrading.
store.dispatch( action );
store.dispatch( action );
expect( incrementedValue ).toBe( 3 );
} );
it( 'snapshots listeners on change, avoiding a later listener if subscribed during earlier callback', () => {
const store = registry.registerStore( 'myAwesomeReducer', {
reducer: ( state = 0 ) => state + 1,
} );
const secondListener = jest.fn();
const firstListener = jest.fn( () => {
subscribeWithUnsubscribe( secondListener );
} );
subscribeWithUnsubscribe( firstListener );
store.dispatch( { type: 'dummy' } );
expect( secondListener ).not.toHaveBeenCalled();
} );
it( 'snapshots listeners on change, calling a later listener even if unsubscribed during earlier callback', () => {
const store = registry.registerStore( 'myAwesomeReducer', {
reducer: ( state = 0 ) => state + 1,
} );
const firstListener = jest.fn( () => {
secondUnsubscribe();
} );
const secondListener = jest.fn();
subscribeWithUnsubscribe( firstListener );
const secondUnsubscribe =
subscribeWithUnsubscribe( secondListener );
store.dispatch( { type: 'dummy' } );
expect( secondListener ).toHaveBeenCalled();
} );
it( 'does not call listeners if state has not changed', () => {
const store = registry.registerStore( 'unchanging', {
reducer: ( state = {} ) => state,
} );
const listener = jest.fn();
subscribeWithUnsubscribe( listener );
store.dispatch( { type: 'dummy' } );
expect( listener ).not.toHaveBeenCalled();
} );
} );
describe( 'dispatch', () => {
it( 'registers actions to the public API', async () => {
const increment = ( count = 1 ) => ( { type: 'increment', count } );
const store = registry.registerStore( 'counter', {
reducer: ( state = 0, action ) => {
if ( action.type === 'increment' ) {
return state + action.count;
}
return state;
},
actions: {
increment,
},
} );
// State = 1.
const dispatchResult = await registry
.dispatch( 'counter' )
.increment();
await expect( dispatchResult ).toEqual( {
type: 'increment',
count: 1,
} );
registry.dispatch( 'counter' ).increment( 4 ); // State = 5.
expect( store.getState() ).toBe( 5 );
} );
} );
describe( 'batch', () => {
it( 'should batch callbacks and only run the subscriber once', () => {
const store = registry.registerStore( 'myAwesomeReducer', {
reducer: ( state = 0 ) => state + 1,
} );
const listener = jest.fn();
subscribeWithUnsubscribe( listener );
registry.batch( () => {} );
expect( listener ).not.toHaveBeenCalled();
registry.batch( () => {
store.dispatch( { type: 'dummy' } );
store.dispatch( { type: 'dummy' } );
} );
expect( listener ).toHaveBeenCalledTimes( 1 );
const listener2 = jest.fn();
// useSelect subscribes to the stores differently,
// This test ensures batching works in this case as well.
const unsubscribe = registry.subscribe(
listener2,
'myAwesomeReducer'
);
registry.batch( () => {
store.dispatch( { type: 'dummy' } );
store.dispatch( { type: 'dummy' } );
} );
unsubscribe();
expect( listener2 ).toHaveBeenCalledTimes( 1 );
} );
it( 'should support nested batches', () => {
const store = registry.registerStore( 'myAwesomeReducer', {
reducer: ( state = 0 ) => state + 1,
} );
const listener = jest.fn();
subscribeWithUnsubscribe( listener );
registry.batch( () => {} );
expect( listener ).not.toHaveBeenCalled();
registry.batch( () => {
store.dispatch( { type: 'dummy' } );
registry.batch( () => {
store.dispatch( { type: 'dummy' } );
store.dispatch( { type: 'dummy' } );
} );
store.dispatch( { type: 'dummy' } );
} );
expect( listener ).toHaveBeenCalledTimes( 1 );
} );
} );
describe( 'use', () => {
it( 'should pass through options object to plugin', () => {
const expectedOptions = {};
const anyObject = expect.any( Object );
let actualOptions;
function plugin( _registry, options ) {
// The registry passed to a plugin is not the same as the one
// returned by createRegistry, as the former uses the internal
// representation of the object, the latter applying its
// function proxying.
expect( _registry ).toMatchObject(
Object.fromEntries(
Object.entries( registry ).map( ( [ key ] ) => {
if ( key === 'stores' ) {
return [ key, anyObject ];
}
// TODO: Remove this after namsespaces is removed.
if ( key === 'namespaces' ) {
return [ key, registry.stores ];
}
return [ key, expect.any( Function ) ];
} )
)
);
actualOptions = options;
return {};
}
registry.use( plugin, expectedOptions );
expect( actualOptions ).toBe( expectedOptions );
} );
it( 'should override base method', () => {
function plugin( _registry, options ) {
return { select: () => options.value };
}
registry.use( plugin, { value: 10 } );
expect( registry.select() ).toBe( 10 );
} );
} );
describe( 'parent registry', () => {
it( 'should call parent registry selectors/actions if defined', () => {
const mySelector = jest.fn();
const myAction = jest.fn();
const getSelectors = () => ( { mySelector } );
const getActions = () => ( { myAction } );
const subscribe = () => {};
const myStore = {
name: 'store',
instantiate: () => ( {
getSelectors,
getActions,
subscribe,
} ),
};
registry.register( myStore );
const subRegistry = createRegistry( {}, registry );
subRegistry.select( myStore ).mySelector();
subRegistry.dispatch( myStore ).myAction();
expect( mySelector ).toHaveBeenCalled();
expect( myAction ).toHaveBeenCalled();
} );
it( 'should override existing store in parent registry', () => {
const mySelector = jest.fn();
const myAction = jest.fn();
const getSelectors = () => ( { mySelector } );
const getActions = () => ( { myAction } );
const subscribe = () => {};
registry.register( {
name: 'store',
instantiate: () => ( {
getSelectors,
getActions,
subscribe,
} ),
} );
const subRegistry = createRegistry( {}, registry );
const mySelector2 = jest.fn();
const myAction2 = jest.fn();
const getSelectors2 = () => ( { mySelector: mySelector2 } );
const getActions2 = () => ( { myAction: myAction2 } );
const subscribe2 = () => {};
subRegistry.register( {
name: 'store',
instantiate: () => ( {
getSelectors: getSelectors2,
getActions: getActions2,
subscribe: subscribe2,
} ),
} );
subRegistry.select( 'store' ).mySelector();
subRegistry.dispatch( 'store' ).myAction();
expect( mySelector ).not.toHaveBeenCalled();
expect( myAction ).not.toHaveBeenCalled();
expect( mySelector2 ).toHaveBeenCalled();
expect( myAction2 ).toHaveBeenCalled();
} );
} );
} );

217
node_modules/@wordpress/data/src/types.ts generated vendored Normal file
View File

@@ -0,0 +1,217 @@
/**
* External dependencies
*/
// eslint-disable-next-line no-restricted-imports
import type { combineReducers as reduxCombineReducers } from 'redux';
type MapOf< T > = { [ name: string ]: T };
export type ActionCreator = ( ...args: any[] ) => any | Generator;
export type Resolver = Function | Generator;
export type Selector = Function;
export type AnyConfig = ReduxStoreConfig< any, any, any >;
export interface StoreInstance< Config extends AnyConfig > {
getSelectors: () => SelectorsOf< Config >;
getActions: () => ActionCreatorsOf< Config >;
subscribe: ( listener: () => void ) => () => void;
}
export interface StoreDescriptor< Config extends AnyConfig > {
/**
* Store Name
*/
name: string;
/**
* Creates a store instance
*/
instantiate: ( registry: DataRegistry ) => StoreInstance< Config >;
}
export interface ReduxStoreConfig<
State,
ActionCreators extends MapOf< ActionCreator >,
Selectors,
> {
initialState?: State;
reducer: ( state: any, action: any ) => any;
actions?: ActionCreators;
resolvers?: MapOf< Resolver >;
selectors?: Selectors;
controls?: MapOf< Function >;
}
// Return type for the useSelect() hook.
export type UseSelectReturn< F extends MapSelect | StoreDescriptor< any > > =
F extends MapSelect
? ReturnType< F >
: F extends StoreDescriptor< any >
? CurriedSelectorsOf< F >
: never;
// Return type for the useDispatch() hook.
export type UseDispatchReturn< StoreNameOrDescriptor > =
StoreNameOrDescriptor extends StoreDescriptor< any >
? ActionCreatorsOf< ConfigOf< StoreNameOrDescriptor > >
: StoreNameOrDescriptor extends undefined
? DispatchFunction
: any;
export type DispatchFunction = < StoreNameOrDescriptor >(
store: StoreNameOrDescriptor
) => DispatchReturn< StoreNameOrDescriptor >;
export type DispatchReturn< StoreNameOrDescriptor > =
StoreNameOrDescriptor extends StoreDescriptor< any >
? ActionCreatorsOf< ConfigOf< StoreNameOrDescriptor > >
: unknown;
export type MapSelect = (
select: SelectFunction,
registry: DataRegistry
) => any;
export type SelectFunction = < S >( store: S ) => CurriedSelectorsOf< S >;
/**
* Callback for store's `subscribe()` method that
* runs when the store data has changed.
*/
export type ListenerFunction = () => void;
export type CurriedSelectorsOf< S > = S extends StoreDescriptor<
ReduxStoreConfig< any, any, infer Selectors >
>
? { [ key in keyof Selectors ]: CurriedState< Selectors[ key ] > }
: never;
/**
* Removes the first argument from a function.
*
* By default, it removes the `state` parameter from
* registered selectors since that argument is supplied
* by the editor when calling `select(…)`.
*
* For functions with no arguments, which some selectors
* are free to define, returns the original function.
*
* It is possible to manually provide a custom curried signature
* and avoid the automatic inference. When the
* F generic argument passed to this helper extends the
* SelectorWithCustomCurrySignature type, the F['CurriedSignature']
* property is used verbatim.
*
* This is useful because TypeScript does not correctly remove
* arguments from complex function signatures constrained by
* interdependent generic parameters.
* For more context, see https://github.com/WordPress/gutenberg/pull/41578
*/
type CurriedState< F > = F extends SelectorWithCustomCurrySignature
? F[ 'CurriedSignature' ]
: F extends ( state: any, ...args: infer P ) => infer R
? ( ...args: P ) => R
: F;
/**
* Utility to manually specify curried selector signatures.
*
* It comes handy when TypeScript can't automatically produce the
* correct curried function signature. For example:
*
* ```ts
* type BadlyInferredSignature = CurriedState<
* <K extends string | number>(
* state: any,
* kind: K,
* key: K extends string ? 'one value' : false
* ) => K
* >
* // BadlyInferredSignature evaluates to:
* // (kind: string number, key: false "one value") => string number
* ```
*
* With SelectorWithCustomCurrySignature, we can provide a custom
* signature and avoid relying on TypeScript inference:
* ```ts
* interface MySelectorSignature extends SelectorWithCustomCurrySignature {
* <K extends string | number>(
* state: any,
* kind: K,
* key: K extends string ? 'one value' : false
* ): K;
*
* CurriedSignature: <K extends string | number>(
* kind: K,
* key: K extends string ? 'one value' : false
* ): K;
* }
* type CorrectlyInferredSignature = CurriedState<MySelectorSignature>
* // <K extends string | number>(kind: K, key: K extends string ? 'one value' : false): K;
*
* For even more context, see https://github.com/WordPress/gutenberg/pull/41578
* ```
*/
export interface SelectorWithCustomCurrySignature {
CurriedSignature: Function;
}
export interface DataRegistry {
register: ( store: StoreDescriptor< any > ) => void;
}
export interface DataEmitter {
emit: () => void;
subscribe: ( listener: () => void ) => () => void;
pause: () => void;
resume: () => void;
isPaused: boolean;
}
// Type Helpers.
export type ConfigOf< S > = S extends StoreDescriptor< infer C > ? C : never;
export type ActionCreatorsOf< Config extends AnyConfig > =
Config extends ReduxStoreConfig< any, infer ActionCreators, any >
? PromisifiedActionCreators< ActionCreators >
: never;
// Takes an object containing all action creators for a store and updates the
// return type of each action creator to account for internal registry details --
// for example, dispatched actions are wrapped with a Promise.
export type PromisifiedActionCreators<
ActionCreators extends MapOf< ActionCreator >,
> = {
[ Action in keyof ActionCreators ]: PromisifyActionCreator<
ActionCreators[ Action ]
>;
};
// Wraps action creator return types with a Promise and handles thunks.
export type PromisifyActionCreator< Action extends ActionCreator > = (
...args: Parameters< Action >
) => Promise<
ReturnType< Action > extends ( ..._args: any[] ) => any
? ThunkReturnType< Action >
: ReturnType< Action >
>;
// A thunk is an action creator which returns a function, which can optionally
// return a Promise. The double ReturnType unwraps the innermost function's
// return type, and Awaited gets the type the Promise resolves to. If the return
// type is not a Promise, Awaited returns that original type.
export type ThunkReturnType< Action extends ActionCreator > = Awaited<
ReturnType< ReturnType< Action > >
>;
type SelectorsOf< Config extends AnyConfig > = Config extends ReduxStoreConfig<
any,
any,
infer Selectors
>
? { [ name in keyof Selectors ]: Function }
: never;
export type combineReducers = typeof reduxCombineReducers;

46
node_modules/@wordpress/data/src/utils/emitter.js generated vendored Normal file
View File

@@ -0,0 +1,46 @@
/**
* Create an event emitter.
*
* @return {import("../types").DataEmitter} Emitter.
*/
export function createEmitter() {
let isPaused = false;
let isPending = false;
const listeners = new Set();
const notifyListeners = () =>
// We use Array.from to clone the listeners Set
// This ensures that we don't run a listener
// that was added as a response to another listener.
Array.from( listeners ).forEach( ( listener ) => listener() );
return {
get isPaused() {
return isPaused;
},
subscribe( listener ) {
listeners.add( listener );
return () => listeners.delete( listener );
},
pause() {
isPaused = true;
},
resume() {
isPaused = false;
if ( isPending ) {
isPending = false;
notifyListeners();
}
},
emit() {
if ( isPaused ) {
isPending = true;
return;
}
notifyListeners();
},
};
}