UNPKG

@wordpress/interactivity

Version:

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

246 lines (237 loc) 7.62 kB
/** * External dependencies */ import { deepSignal } from 'deepsignal'; import { computed } from '@preact/signals'; /** * Internal dependencies */ import { getScope, setScope, resetScope, setNamespace, resetNamespace } from './hooks'; const isObject = item => !!item && typeof item === 'object' && !Array.isArray(item); const deepMerge = (target, source) => { if (isObject(target) && isObject(source)) { for (const key in source) { const getter = Object.getOwnPropertyDescriptor(source, key)?.get; if (typeof getter === 'function') { Object.defineProperty(target, key, { get: getter }); } else if (isObject(source[key])) { if (!target[key]) Object.assign(target, { [key]: {} }); deepMerge(target[key], source[key]); } else { Object.assign(target, { [key]: source[key] }); } } } }; const parseInitialState = () => { const storeTag = document.querySelector(`script[type="application/json"]#wp-interactivity-data`); if (!storeTag?.textContent) return {}; try { const { state } = JSON.parse(storeTag.textContent); if (isObject(state)) return state; throw Error('Parsed state is not an object'); } catch (e) { // eslint-disable-next-line no-console console.log(e); } return {}; }; export const stores = new Map(); const rawStores = new Map(); const storeLocks = new Map(); const objToProxy = new WeakMap(); const proxyToNs = new WeakMap(); const scopeToGetters = new WeakMap(); const proxify = (obj, ns) => { if (!objToProxy.has(obj)) { const proxy = new Proxy(obj, handlers); objToProxy.set(obj, proxy); proxyToNs.set(proxy, ns); } return objToProxy.get(obj); }; const handlers = { get: (target, key, receiver) => { const ns = proxyToNs.get(receiver); // Check if the property is a getter and we are inside an scope. If that is // the case, we clone the getter to avoid overwriting the scoped // dependencies of the computed each time that getter runs. const getter = Object.getOwnPropertyDescriptor(target, key)?.get; if (getter) { const scope = getScope(); if (scope) { const getters = scopeToGetters.get(scope) || scopeToGetters.set(scope, new Map()).get(scope); if (!getters.has(getter)) { getters.set(getter, computed(() => { setNamespace(ns); setScope(scope); try { return getter.call(target); } finally { resetScope(); resetNamespace(); } })); } return getters.get(getter).value; } } const result = Reflect.get(target, key, receiver); // Check if the proxy is the store root and no key with that name exist. In // that case, return an empty object for the requested key. if (typeof result === 'undefined' && receiver === stores.get(ns)) { const obj = {}; Reflect.set(target, key, obj, receiver); return proxify(obj, ns); } // Check if the property is a generator. If it is, we turn it into an // asynchronous function where we restore the default namespace and scope // each time it awaits/yields. if (result?.constructor?.name === 'GeneratorFunction') { return async (...args) => { const scope = getScope(); const gen = result(...args); let value; let it; while (true) { setNamespace(ns); setScope(scope); try { it = gen.next(value); } finally { resetScope(); resetNamespace(); } try { value = await it.value; } catch (e) { gen.throw(e); } if (it.done) break; } return value; }; } // Check if the property is a synchronous function. If it is, set the // default namespace. Synchronous functions always run in the proper scope, // which is set by the Directives component. if (typeof result === 'function') { return (...args) => { setNamespace(ns); try { return result(...args); } finally { resetNamespace(); } }; } // Check if the property is an object. If it is, proxyify it. if (isObject(result)) return proxify(result, ns); return result; } }; 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='{ "namespace": "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. */ export function store(namespace, { state = {}, ...block } = {}, { lock = false } = {}) { 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: deepSignal(state), ...block }; const proxiedStore = new Proxy(rawStore, handlers); rawStores.set(namespace, rawStore); stores.set(namespace, proxiedStore); proxyToNs.set(proxiedStore, namespace); } 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); } // Parse and populate the initial state. Object.entries(parseInitialState()).forEach(([namespace, state]) => { store(namespace, { state }); }); //# sourceMappingURL=store.js.map