286 lines
7.6 KiB
TypeScript
286 lines
7.6 KiB
TypeScript
/**
|
|
* External dependencies
|
|
*/
|
|
import { deepSignal } from 'deepsignal';
|
|
import { computed } from '@preact/signals';
|
|
import {
|
|
getScope,
|
|
setScope,
|
|
resetScope,
|
|
setNamespace,
|
|
resetNamespace,
|
|
} from './hooks';
|
|
|
|
const isObject = ( item: unknown ): boolean =>
|
|
!! item && typeof item === 'object' && ! Array.isArray( item );
|
|
|
|
const deepMerge = ( target: any, source: any ) => {
|
|
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"]#wc-interactivity-initial-state`
|
|
);
|
|
if ( ! storeTag?.textContent ) return {};
|
|
try {
|
|
const initialState = JSON.parse( storeTag.textContent );
|
|
if ( isObject( initialState ) ) return initialState;
|
|
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: any, ns: string ) => {
|
|
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: any, key: string | symbol, receiver: any ) => {
|
|
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: unknown[] ) => {
|
|
const scope = getScope();
|
|
const gen: Generator< any > = result( ...args );
|
|
|
|
let value: any;
|
|
let it: IteratorResult< any >;
|
|
|
|
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: unknown[] ) => {
|
|
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;
|
|
},
|
|
};
|
|
|
|
/**
|
|
* @typedef StoreProps Properties object passed to `store`.
|
|
* @property {Object} state State to be added to the global store. All the
|
|
* properties included here become reactive.
|
|
*/
|
|
|
|
/**
|
|
* @typedef StoreOptions Options object.
|
|
*/
|
|
|
|
/**
|
|
* Extends the Interactivity API global store with the passed properties.
|
|
*
|
|
* These props typically consist of `state`, which is reactive, and other
|
|
* properties like `selectors`, `actions`, `effects`, etc. which can store
|
|
* callbacks and derived state. These props can then be referenced by any
|
|
* directive to make the HTML interactive.
|
|
*
|
|
* @example
|
|
* ```js
|
|
* store({
|
|
* state: {
|
|
* counter: { value: 0 },
|
|
* },
|
|
* actions: {
|
|
* counter: {
|
|
* increment: ({ state }) => {
|
|
* state.counter.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>
|
|
* <button
|
|
* data-wp-text="state.counter.value"
|
|
* data-wp-on--click="actions.counter.increment"
|
|
* >
|
|
* 0
|
|
* </button>
|
|
* </div>
|
|
* ```
|
|
*
|
|
* @param {StoreProps} properties Properties to be added to the global store.
|
|
* @param {StoreOptions} [options] Options passed to the `store` call.
|
|
*/
|
|
|
|
interface StoreOptions {
|
|
lock?: boolean | string;
|
|
}
|
|
|
|
const universalUnlock =
|
|
'I acknowledge that using a private store means my plugin will inevitably break on the next store release.';
|
|
|
|
export function store< S extends object = {} >(
|
|
namespace: string,
|
|
storePart?: S,
|
|
options?: StoreOptions
|
|
): S;
|
|
export function store< T extends object >(
|
|
namespace: string,
|
|
storePart?: T,
|
|
options?: StoreOptions
|
|
): 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: 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 } );
|
|
} );
|