@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
JavaScript
/**
* 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