@wordpress/data
Version:
Data module for WordPress.
857 lines (772 loc) • 25 kB
text/typescript
/**
* External dependencies
*/
import { createStore, applyMiddleware } from 'redux';
import type { Store as ReduxStore, StoreEnhancer } 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';
import type {
DataRegistry,
ListenerFunction,
StoreDescriptor,
ReduxStoreConfig,
ActionCreator,
NormalizedResolver,
} from '../types';
export { combineReducers };
/**
* Augment the Redux store with internal properties used by the data module.
*/
interface AugmentedReduxStore extends ReduxStore {
__unstableOriginalGetState: ReduxStore[ 'getState' ];
}
interface ResolversCache {
isRunning: ( selectorName: string, args: unknown[] ) => boolean;
clear: ( selectorName: string, args: unknown[] ) => void;
markAsRunning: ( selectorName: string, args: unknown[] ) => void;
}
interface BindingCache {
get: ( itemName: string ) => ( ( ...args: unknown[] ) => unknown ) | null;
}
interface SelectorLike {
( ...args: any[] ): any;
hasResolver?: boolean;
isRegistrySelector?: boolean;
registry?: DataRegistry;
__unstableNormalizeArgs?: ( args: unknown[] ) => unknown[];
}
const trimUndefinedValues = ( array: unknown[] ): unknown[] => {
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 obj The object to transform.
* @param callback The function to transform each object value.
* @return Transformed object.
*/
const mapValues = < T, U >(
obj: Record< string, T > | undefined,
callback: ( value: T, key: string ) => U
): Record< string, U > =>
Object.fromEntries(
Object.entries( obj ?? {} ).map( ( [ key, value ] ) => [
key,
callback( value, key ),
] )
);
// Convert non serializable types to plain objects
const devToolsReplacer = ( _key: string, state: unknown ): unknown => {
if ( state instanceof Map ) {
return Object.fromEntries( state );
}
if (
typeof window !== 'undefined' &&
state instanceof window.HTMLElement
) {
return null;
}
return state;
};
/**
* Create a cache to track whether resolvers started running or not.
*
* @return Resolvers Cache.
*/
function createResolversCache(): ResolversCache {
const cache: Record< string, EquivalentKeyMap< unknown[], boolean > > = {};
return {
isRunning( selectorName: string, args: unknown[] ): boolean {
return !! (
cache[ selectorName ] &&
cache[ selectorName ].get( trimUndefinedValues( args ) )
);
},
clear( selectorName: string, args: unknown[] ): void {
if ( cache[ selectorName ] ) {
cache[ selectorName ].delete( trimUndefinedValues( args ) );
}
},
markAsRunning( selectorName: string, args: unknown[] ): void {
if ( ! cache[ selectorName ] ) {
cache[ selectorName ] = new EquivalentKeyMap();
}
cache[ selectorName ].set( trimUndefinedValues( args ), true );
},
};
}
function createBindingCache(
getItem: ( name: string ) => ( ( ...args: any[] ) => any ) | undefined,
bindItem: (
item: ( ...args: any[] ) => any,
name: string
) => ( ...args: unknown[] ) => unknown
): BindingCache {
const cache = new WeakMap<
( ...args: any[] ) => any,
( ...args: unknown[] ) => unknown
>();
return {
get( itemName: string ) {
const item = getItem( itemName );
if ( ! item ) {
return null;
}
let boundItem = cache.get( item );
if ( ! boundItem ) {
boundItem = bindItem( item, itemName );
cache.set( item, boundItem );
}
return boundItem;
},
};
}
function createPrivateProxy< T extends Record< string, any > >(
publicItems: T,
privateItems: BindingCache
): T {
return new Proxy( publicItems, {
get: ( target, itemName: string ) =>
privateItems.get( itemName ) || Reflect.get( target, itemName ),
} );
}
/**
* 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,
* },
* } );
* ```
*
* @param key Unique namespace identifier.
* @param options Registered store options, with properties
* describing reducer, actions, selectors,
* and resolvers.
*
* @return Store Object.
*/
export default function createReduxStore< State, Actions, Selectors >(
key: string,
options: ReduxStoreConfig< State, Actions, Selectors >
): StoreDescriptor< ReduxStoreConfig< State, Actions, Selectors > > {
const privateActions: Record< string, ActionCreator > = {};
const privateSelectors: Record< string, SelectorLike > = {};
const privateRegistrationFunctions = {
privateActions,
registerPrivateActions: (
actions: Record< string, ActionCreator >
) => {
Object.assign( privateActions, actions );
},
privateSelectors,
registerPrivateSelectors: (
selectors: Record< string, SelectorLike >
) => {
Object.assign( privateSelectors, selectors );
},
};
const storeDescriptor = {
name: key,
instantiate: ( registry: DataRegistry ) => {
/**
* 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.
*/
const listeners = new Set< ListenerFunction >();
const reducer = options.reducer;
// Object that every thunk function receives as the first argument. It contains the
// `registry`, `dispatch`, `select` and `resolveSelect` fields. Some of them are
// constructed as getters to avoid circular dependencies.
const thunkArgs = {
registry,
get dispatch() {
return thunkDispatch;
},
get select() {
return thunkSelect;
},
get resolveSelect() {
return resolveSelectors;
},
};
const store = instantiateReduxStore(
key,
options,
registry,
thunkArgs
) as AugmentedReduxStore;
// 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();
// Binds an action creator (`action`) to the `store`, making it a callable function.
// These are the functions that are returned by `useDispatch`, for example.
// It always returns a `Promise`, although actions are not always async. That's an
// unfortunate backward compatibility measure.
function bindAction( action: ( ...args: any[] ) => any ) {
return ( ...args: unknown[] ) =>
Promise.resolve( store.dispatch( action( ...args ) ) );
}
/*
* Object with all public actions, both metadata and store actions.
*/
const actions = {
...mapValues(
metadataActions as Record< string, ActionCreator >,
bindAction
),
...mapValues(
options.actions as
| Record< string, ActionCreator >
| undefined,
bindAction
),
};
// Object with both public and private actions. Private actions are accessed through a proxy,
// which looks them up in real time on the `privateActions` object. That's because private
// actions can be registered at any time with `registerPrivateActions`. Also once a private
// action creator is bound to the store, it is cached to give it a stable identity.
const allActions = createPrivateProxy(
actions,
createBindingCache(
( name ) => privateActions[ name ],
bindAction
)
);
// An object that implements the `dispatch` object that is passed to thunk functions.
// It is callable (`dispatch( action )`) and also has methods (`dispatch.foo()`) that
// correspond to bound registered actions, both public and private. Implemented with the proxy
// `get` method, delegating to `allActions`.
const thunkDispatch = new Proxy(
( action: any ) => store.dispatch( action ),
{
get: ( _target, name: string ) => allActions[ name ],
}
);
// To the public `actions` object, add the "locked" `allActions` object. When used,
// `unlock( actions )` will return `allActions`, implementing a way how to get at the private actions.
lock( actions, allActions );
// If we have selector resolvers, convert them to a normalized form.
const resolvers: Record< string, NormalizedResolver > =
options.resolvers
? mapValues(
options.resolvers as Record< string, any >,
mapResolver
)
: {};
// Bind a selector to the store. Call the selector with the current state, correct registry,
// and if there is a resolver, attach the resolver logic to the selector.
function bindSelector(
selector: SelectorLike,
selectorName: string
): SelectorLike {
if ( selector.isRegistrySelector ) {
selector.registry = registry;
}
const boundSelector: SelectorLike = ( ...args: any[] ) => {
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 fulfilling
// the resolver.
boundSelector.__unstableNormalizeArgs =
selector.__unstableNormalizeArgs;
const resolver = resolvers[ selectorName ];
if ( ! resolver ) {
boundSelector.hasResolver = false;
return boundSelector;
}
return mapSelectorWithResolver(
boundSelector,
selectorName,
resolver,
store,
resolversCache,
boundMetadataSelectors
);
}
// Metadata selectors are bound differently: different state (`state.metadata`), no resolvers,
// normalization depending on the target selector.
function bindMetadataSelector(
metaDataSelector: ( ...args: any[] ) => any
): SelectorLike {
const boundSelector: SelectorLike = (
selectorName: string,
selectorArgs: unknown[],
...args: unknown[]
) => {
// Normalize the arguments passed to the target selector.
if ( selectorName ) {
const targetSelector = ( options.selectors as any )?.[
selectorName
];
if ( targetSelector ) {
selectorArgs = normalize(
targetSelector,
selectorArgs
);
}
}
const state = store.__unstableOriginalGetState();
return metaDataSelector(
state.metadata,
selectorName,
selectorArgs,
...args
);
};
boundSelector.hasResolver = false;
return boundSelector;
}
// Perform binding of both metadata and store selectors and combine them in one
// `selectors` object. These are all public selectors of the store.
const boundMetadataSelectors = mapValues(
metadataSelectors as Record<
string,
( ...args: any[] ) => any
>,
bindMetadataSelector
);
const boundSelectors = mapValues(
options.selectors as Record< string, SelectorLike > | undefined,
bindSelector
);
const selectors = {
...boundMetadataSelectors,
...boundSelectors,
};
// Cache of bound private selectors. They are bound only when first accessed, because
// new private selectors can be registered at any time (with `registerPrivateSelectors`).
// Once bound, they are cached to give them a stable identity.
const boundPrivateSelectors = createBindingCache(
( name ) => privateSelectors[ name ],
bindSelector
);
const allSelectors = createPrivateProxy(
selectors,
boundPrivateSelectors
);
// 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 of Object.keys( privateSelectors ) ) {
boundPrivateSelectors.get( selectorName );
}
// An object that implements the `select` object that is passed to thunk functions.
// It is callable (`select( selector )`) and also has methods (`select.foo()`) that
// correspond to bound registered selectors, both public and private. Implemented with the proxy
// `get` method, delegating to `allSelectors`.
const thunkSelect = new Proxy(
( selector: ( state: any ) => any ) =>
selector( store.__unstableOriginalGetState() ),
{
get: ( _target, name: string ) => allSelectors[ name ],
}
);
// To the public `selectors` object, add the "locked" `allSelectors` object. When used,
// `unlock( selectors )` will return `allSelectors`, implementing a way how to get at the private selectors.
lock( selectors, allSelectors );
// For each selector, create a function that calls the selector, waits for resolution and returns
// a promise that resolves when the resolution is finished.
const bindResolveSelector = mapResolveSelector(
store,
resolvers,
boundMetadataSelectors
);
// Now apply this function to all bound selectors, public and private. We are excluding
// metadata selectors because they don't have resolvers.
const resolveSelectors = mapValues(
boundSelectors,
bindResolveSelector
);
const allResolveSelectors = createPrivateProxy(
resolveSelectors,
createBindingCache(
( name ) =>
boundPrivateSelectors.get( name ) as (
...args: any[]
) => any | undefined,
bindResolveSelector
)
);
// Lock the selectors so that `unlock( resolveSelectors )` returns `allResolveSelectors`.
lock( resolveSelectors, allResolveSelectors );
// Now, in a way very similar to `bindResolveSelector`, we create a function that maps
// selectors to functions that throw a suspense promise if not yet resolved.
const bindSuspendSelector = mapSuspendSelector(
store,
boundMetadataSelectors
);
const suspendSelectors = {
...boundMetadataSelectors, // no special suspense behavior
...mapValues( boundSelectors, bindSuspendSelector ),
};
const allSuspendSelectors = createPrivateProxy(
suspendSelectors,
createBindingCache(
( name ) =>
boundPrivateSelectors.get( name ) as (
...args: any[]
) => any | undefined,
bindSuspendSelector
)
);
// Lock the selectors so that `unlock( suspendSelectors )` returns 'allSuspendSelectors`.
lock( suspendSelectors, allSuspendSelectors );
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 = ( listener: ListenerFunction ) => {
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 as unknown as StoreDescriptor<
ReduxStoreConfig< State, Actions, Selectors >
>;
}
/**
* Creates a redux store for a namespace.
*
* @param key Unique namespace identifier.
* @param options Registered store options, with properties
* describing reducer, actions, selectors,
* and resolvers.
* @param registry Registry reference.
* @param thunkArgs Argument object for the thunk middleware.
* @return Newly created redux store.
*/
function instantiateReduxStore(
key: string,
options: ReduxStoreConfig< any, any, any >,
registry: DataRegistry,
thunkArgs: unknown
): ReduxStore {
const controls = {
...options.controls,
...builtinControls,
};
const normalizedControls = mapValues( controls, ( control: any ) =>
control.isRegistryControl ? control( registry ) : control
);
const middlewares = [
createResolversCacheMiddleware( registry, key ),
promise,
createReduxRoutineMiddleware( normalizedControls ),
createThunkMiddleware( thunkArgs ),
];
const enhancers: StoreEnhancer[] = [ applyMiddleware( ...middlewares ) ];
if (
typeof window !== 'undefined' &&
( window as any ).__REDUX_DEVTOOLS_EXTENSION__
) {
enhancers.push(
( window as any ).__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 } as any,
compose( ...enhancers ) as unknown as StoreEnhancer
);
}
/**
* Maps selectors to functions that return a resolution promise for them.
*
* @param store The redux store the selectors are bound to.
* @param resolvers The normalized resolvers for the store.
* @param boundMetadataSelectors The bound metadata selectors.
*
* @return Function that maps selectors to resolvers.
*/
function mapResolveSelector(
store: ReduxStore,
resolvers: Record< string, NormalizedResolver >,
boundMetadataSelectors: Record< string, SelectorLike >
) {
return ( selector: SelectorLike, selectorName: string ) => {
// 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: unknown[] ) => selector( ...args );
}
return ( ...args: unknown[] ) =>
new Promise( ( resolve, reject ) => {
const resolver = resolvers[ selectorName ];
const hasFinished = () => {
return (
boundMetadataSelectors.hasFinishedResolution(
selectorName,
args
) ||
( typeof resolver.isFulfilled === 'function' &&
resolver.isFulfilled( store.getState(), ...args ) )
);
};
const finalize = ( result: unknown ) => {
const hasFailed =
boundMetadataSelectors.hasResolutionFailed(
selectorName,
args
);
if ( hasFailed ) {
const error = boundMetadataSelectors.getResolutionError(
selectorName,
args
);
reject( error );
} else {
resolve( result );
}
};
const getResult = () => selector( ...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 store The redux store the selectors select from.
* @param boundMetadataSelectors The bound metadata selectors.
*
* @return Function that maps selectors to their suspending versions.
*/
function mapSuspendSelector(
store: ReduxStore,
boundMetadataSelectors: Record< string, SelectorLike >
) {
return ( selector: SelectorLike, selectorName: string ) => {
// Selector without a resolver doesn't have any extra suspense behavior.
if ( ! selector.hasResolver ) {
return selector;
}
return ( ...args: unknown[] ) => {
const result = selector( ...args );
if (
boundMetadataSelectors.hasFinishedResolution(
selectorName,
args
)
) {
if (
boundMetadataSelectors.hasResolutionFailed(
selectorName,
args
)
) {
throw boundMetadataSelectors.getResolutionError(
selectorName,
args
);
}
return result;
}
throw new Promise< void >( ( resolve ) => {
const unsubscribe = store.subscribe( () => {
if (
boundMetadataSelectors.hasFinishedResolution(
selectorName,
args
)
) {
resolve();
unsubscribe();
}
} );
} );
};
};
}
/**
* Convert a resolver to a normalized form, an object with `fulfill` method and
* optional methods like `isFulfilled`.
*
* @param resolver Resolver to convert
*/
function mapResolver( resolver: any ): NormalizedResolver {
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 selector The selector function to be bound.
* @param selectorName The selector name.
* @param resolver Resolver to call.
* @param store The redux store to which the resolvers should be mapped.
* @param resolversCache Resolvers Cache.
* @param boundMetadataSelectors The bound metadata selectors.
*/
function mapSelectorWithResolver(
selector: SelectorLike,
selectorName: string,
resolver: NormalizedResolver,
store: AugmentedReduxStore,
resolversCache: ResolversCache,
boundMetadataSelectors: Record< string, SelectorLike >
): SelectorLike {
function fulfillSelector( args: unknown[] ): void {
if (
resolversCache.isRunning( selectorName, args ) ||
boundMetadataSelectors.hasStartedResolution( selectorName, args ) ||
( typeof resolver.isFulfilled === 'function' &&
resolver.isFulfilled( store.getState(), ...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: SelectorLike = ( ...args: unknown[] ) => {
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 selector The selector potentially with a normalization method property.
* @param args selector arguments to normalize.
* @return Potentially normalized arguments.
*/
function normalize( selector: SelectorLike, args: unknown[] ): unknown[] {
if (
selector.__unstableNormalizeArgs &&
typeof selector.__unstableNormalizeArgs === 'function' &&
args?.length
) {
return selector.__unstableNormalizeArgs( args );
}
return args;
}