UNPKG

@wordpress/interactivity

Version:

Package that provides a standard and simple way to handle the frontend interactivity of Gutenberg blocks.

293 lines (274 loc) 9.13 kB
/** * Internal dependencies */ import { proxifyState, proxifyStore, deepMerge } from './proxies'; /** * External dependencies */ import { getNamespace } from './namespaces'; import { isPlainObject } from './utils'; export const stores = new Map(); const rawStores = new Map(); const storeLocks = new Map(); const storeConfigs = new Map(); const serverStates = new Map(); /** * Gets the defined config for the store with the passed namespace. * * @param namespace Store's namespace from which to retrieve the config. * @return Defined config for the given namespace. */ export const getConfig = ( namespace?: string ) => storeConfigs.get( namespace || getNamespace() ) || {}; /** * Gets the part of the state defined and updated from the server. * * The object returned is read-only, and includes the state defined in PHP with * `wp_interactivity_state()`. When using `actions.navigate()`, this object is * updated to reflect the changes in its properties, without affecting the state * returned by `store()`. Directives can subscribe to those changes to update * the state if needed. * * @example * ```js * const { state } = store('myStore', { * callbacks: { * updateServerState() { * const serverState = getServerState(); * // Override some property with the new value that came from the server. * state.overridableProp = serverState.overridableProp; * }, * }, * }); * ``` * * @param namespace Store's namespace from which to retrieve the server state. * @return The server state for the given namespace. */ export const getServerState = ( namespace?: string ) => { const ns = namespace || getNamespace(); if ( ! serverStates.has( ns ) ) { serverStates.set( ns, proxifyState( ns, {}, { readOnly: true } ) ); } return serverStates.get( ns ); }; interface StoreOptions { /** * Property to block/unblock private store namespaces. * * If the passed value is `true`, it blocks the given namespace, making it * accessible only through the returned variables of the `store()` call. In * the case a lock string is passed, it also blocks the namespace, but can * be unblocked for other `store()` calls using the same lock string. * * @example * ``` * // The store can only be accessed where the `state` const can. * const { state } = store( 'myblock/private', { ... }, { lock: true } ); * ``` * * @example * ``` * // Other modules knowing `SECRET_LOCK_STRING` can access the namespace. * const { state } = store( * 'myblock/private', * { ... }, * { lock: 'SECRET_LOCK_STRING' } * ); * ``` */ lock?: boolean | string; } export type AsyncAction< T > = Generator< any, T, unknown >; export type TypeYield< T extends ( ...args: any[] ) => Promise< any > > = Awaited< ReturnType< T > >; type Prettify< T > = { [ K in keyof T ]: T[ K ] } & {}; type DeepPartial< T > = T extends object ? { [ P in keyof T ]?: DeepPartial< T[ P ] > } : T; type DeepPartialState< T extends { state: object } > = Omit< T, 'state' > & { state?: DeepPartial< T[ 'state' ] >; }; type ConvertGeneratorToPromise< T > = T extends ( ...args: infer A ) => Generator< any, infer R, any > ? ( ...args: A ) => Promise< R > : never; type ConvertGeneratorsToPromises< T > = { [ K in keyof T ]: T[ K ] extends ( ...args: any[] ) => any ? ConvertGeneratorToPromise< T[ K ] > extends never ? T[ K ] : ConvertGeneratorToPromise< T[ K ] > : T[ K ] extends object ? Prettify< ConvertGeneratorsToPromises< T[ K ] > > : T[ K ]; }; type ConvertPromiseToGenerator< T > = T extends ( ...args: infer A ) => Promise< infer R > ? ( ...args: A ) => Generator< any, R, any > : never; type ConvertPromisesToGenerators< T > = { [ K in keyof T ]: T[ K ] extends ( ...args: any[] ) => any ? ConvertPromiseToGenerator< T[ K ] > extends never ? T[ K ] : ConvertPromiseToGenerator< T[ K ] > : T[ K ] extends object ? Prettify< ConvertPromisesToGenerators< T[ K ] > > : T[ K ]; }; export const universalUnlock = 'I acknowledge that using a private store means my plugin will inevitably break on the next store release.'; /** * Extends the Interactivity API global store adding the passed properties to * the given namespace. It also returns stable references to the namespace * content. * * These props typically consist of `state`, which is the reactive part of the * store ― which means that any directive referencing a state property will be * re-rendered anytime it changes ― and function properties like `actions` and * `callbacks`, mostly used for event handlers. These props can then be * referenced by any directive to make the HTML interactive. * * @example * ```js * const { state } = store( 'counter', { * state: { * value: 0, * get double() { return state.value * 2; }, * }, * actions: { * increment() { * state.value += 1; * }, * }, * } ); * ``` * * The code from the example above allows blocks to subscribe and interact with * the store by using directives in the HTML, e.g.: * * ```html * <div data-wp-interactive="counter"> * <button * data-wp-text="state.double" * data-wp-on--click="actions.increment" * > * 0 * </button> * </div> * ``` * @param namespace The store namespace to interact with. * @param storePart Properties to add to the store namespace. * @param options Options for the given namespace. * * @return A reference to the namespace content. */ // Overload for when the types are inferred. export function store< T extends object >( namespace: string, storePart: T, options?: StoreOptions ): Prettify< ConvertGeneratorsToPromises< T > >; // Overload for when types are passed via generics and they contain state. export function store< T extends { state: object } >( namespace: string, storePart?: ConvertPromisesToGenerators< DeepPartialState< T > >, options?: StoreOptions ): Prettify< ConvertGeneratorsToPromises< T > >; // Overload for when types are passed via generics and they don't contain state. export function store< T extends object >( namespace: string, storePart?: ConvertPromisesToGenerators< T >, options?: StoreOptions ): Prettify< ConvertGeneratorsToPromises< T > >; // Overload for when types are divided into multiple parts. export function store< T extends object >( namespace: string, storePart: ConvertPromisesToGenerators< DeepPartial< T > >, options?: StoreOptions ): Prettify< ConvertGeneratorsToPromises< T > >; export function store( namespace: string, { state = {}, ...block }: any = {}, { lock = false }: StoreOptions = {} ) { if ( ! stores.has( namespace ) ) { // Lock the store if the passed lock is different from the universal // unlock. Once the lock is set (either false, true, or a given string), // it cannot change. if ( lock !== universalUnlock ) { storeLocks.set( namespace, lock ); } const rawStore = { state: proxifyState( namespace, isPlainObject( state ) ? state : {} ), ...block, }; const proxifiedStore = proxifyStore( namespace, rawStore ); rawStores.set( namespace, rawStore ); stores.set( namespace, proxifiedStore ); } else { // Lock the store if it wasn't locked yet and the passed lock is // different from the universal unlock. If no lock is given, the store // will be public and won't accept any lock from now on. if ( lock !== universalUnlock && ! storeLocks.has( namespace ) ) { storeLocks.set( namespace, lock ); } else { const storeLock = storeLocks.get( namespace ); const isLockValid = lock === universalUnlock || ( lock !== true && lock === storeLock ); if ( ! isLockValid ) { if ( ! storeLock ) { throw Error( 'Cannot lock a public store' ); } else { throw Error( 'Cannot unlock a private store with an invalid lock code' ); } } } const target = rawStores.get( namespace ); deepMerge( target, block ); deepMerge( target.state, state ); } return stores.get( namespace ); } export const parseServerData = ( dom = document ) => { const jsonDataScriptTag = // Preferred Script Module data passing form dom.getElementById( 'wp-script-module-data-@wordpress/interactivity' ) ?? // Legacy form dom.getElementById( 'wp-interactivity-data' ); if ( jsonDataScriptTag?.textContent ) { try { return JSON.parse( jsonDataScriptTag.textContent ); } catch {} } return {}; }; export const populateServerData = ( data?: { state?: Record< string, unknown >; config?: Record< string, unknown >; } ) => { if ( isPlainObject( data?.state ) ) { Object.entries( data!.state ).forEach( ( [ namespace, state ] ) => { const st = store< any >( namespace, {}, { lock: universalUnlock } ); deepMerge( st.state, state, false ); deepMerge( getServerState( namespace ), state ); } ); } if ( isPlainObject( data?.config ) ) { Object.entries( data!.config ).forEach( ( [ namespace, config ] ) => { storeConfigs.set( namespace, config ); } ); } }; // Parse and populate the initial state and config. const data = parseServerData(); populateServerData( data );