diff --git a/packages/e2e-tests/plugins/interactive-blocks/tovdom/view.js b/packages/e2e-tests/plugins/interactive-blocks/tovdom/view.js
index 734ccbd801bb1e..3051680d224143 100644
--- a/packages/e2e-tests/plugins/interactive-blocks/tovdom/view.js
+++ b/packages/e2e-tests/plugins/interactive-blocks/tovdom/view.js
@@ -1,5 +1,5 @@
( ( { wp } ) => {
const { store } = wp.interactivity;
- store( {} );
+ store( 'tovdom', {} );
} )( window );
diff --git a/packages/interactivity/package.json b/packages/interactivity/package.json
index fd2491695be5ad..3fddcc531fb935 100644
--- a/packages/interactivity/package.json
+++ b/packages/interactivity/package.json
@@ -24,6 +24,7 @@
"main": "build/index.js",
"module": "build-module/index.js",
"react-native": "src/index",
+ "types": "build-types",
"dependencies": {
"@preact/signals": "^1.1.3",
"deepsignal": "^1.3.6",
diff --git a/packages/interactivity/src/directives.js b/packages/interactivity/src/directives.js
index ce3859c630231f..0793dc0cc5d5ba 100644
--- a/packages/interactivity/src/directives.js
+++ b/packages/interactivity/src/directives.js
@@ -17,6 +17,7 @@ import { createPortal } from './portals';
import { useSignalEffect } from './utils';
import { directive } from './hooks';
import { SlotProvider, Slot, Fill } from './slots';
+import { navigate } from './router';
const isObject = ( item ) =>
item && typeof item === 'object' && ! Array.isArray( item );
@@ -40,21 +41,24 @@ export default () => {
directive(
'context',
( {
- directives: {
- context: { default: newContext },
- },
+ directives: { context },
props: { children },
context: inheritedContext,
} ) => {
const { Provider } = inheritedContext;
const inheritedValue = useContext( inheritedContext );
const currentValue = useRef( deepSignal( {} ) );
+ const passedValues = context.map( ( { value } ) => value );
+
currentValue.current = useMemo( () => {
- const newValue = deepSignal( newContext );
+ const newValue = context
+ .map( ( c ) => deepSignal( { [ c.namespace ]: c.value } ) )
+ .reduceRight( mergeDeepSignals );
+
mergeDeepSignals( newValue, inheritedValue );
mergeDeepSignals( currentValue.current, newValue, true );
return currentValue.current;
- }, [ newContext, inheritedValue ] );
+ }, [ inheritedValue, ...passedValues ] );
return (
{ children }
@@ -68,32 +72,25 @@ export default () => {
return createPortal( children, document.body );
} );
- // data-wp-effect--[name]
- directive( 'effect', ( { directives: { effect }, context, evaluate } ) => {
- const contextValue = useContext( context );
- Object.values( effect ).forEach( ( path ) => {
- useSignalEffect( () => {
- return evaluate( path, { context: contextValue } );
- } );
+ // data-wp-watch--[name]
+ directive( 'watch', ( { directives: { watch }, evaluate } ) => {
+ watch.forEach( ( entry ) => {
+ useSignalEffect( () => evaluate( entry ) );
} );
} );
// data-wp-init--[name]
- directive( 'init', ( { directives: { init }, context, evaluate } ) => {
- const contextValue = useContext( context );
- Object.values( init ).forEach( ( path ) => {
- useEffect( () => {
- return evaluate( path, { context: contextValue } );
- }, [] );
+ directive( 'init', ( { directives: { init }, evaluate } ) => {
+ init.forEach( ( entry ) => {
+ useEffect( () => evaluate( entry ), [] );
} );
} );
// data-wp-on--[event]
- directive( 'on', ( { directives: { on }, element, evaluate, context } ) => {
- const contextValue = useContext( context );
- Object.entries( on ).forEach( ( [ name, path ] ) => {
- element.props[ `on${ name }` ] = ( event ) => {
- evaluate( path, { event, context: contextValue } );
+ directive( 'on', ( { directives: { on }, element, evaluate } ) => {
+ on.forEach( ( entry ) => {
+ element.props[ `on${ entry.suffix }` ] = ( event ) => {
+ evaluate( entry, event );
};
} );
} );
@@ -101,20 +98,12 @@ export default () => {
// data-wp-class--[classname]
directive(
'class',
- ( {
- directives: { class: className },
- element,
- evaluate,
- context,
- } ) => {
- const contextValue = useContext( context );
- Object.keys( className )
- .filter( ( n ) => n !== 'default' )
- .forEach( ( name ) => {
- const result = evaluate( className[ name ], {
- className: name,
- context: contextValue,
- } );
+ ( { directives: { class: className }, element, evaluate } ) => {
+ className
+ .filter( ( { suffix } ) => suffix !== 'default' )
+ .forEach( ( entry ) => {
+ const name = entry.suffix;
+ const result = evaluate( entry, { className: name } );
const currentClass = element.props.class || '';
const classFinder = new RegExp(
`(^|\\s)${ name }(\\s|$)`,
@@ -179,111 +168,142 @@ export default () => {
};
// data-wp-style--[style-key]
- directive(
- 'style',
- ( { directives: { style }, element, evaluate, context } ) => {
- const contextValue = useContext( context );
- Object.keys( style )
- .filter( ( n ) => n !== 'default' )
- .forEach( ( key ) => {
- const result = evaluate( style[ key ], {
- key,
- context: contextValue,
- } );
- element.props.style = element.props.style || {};
- if ( typeof element.props.style === 'string' )
- element.props.style = cssStringToObject(
- element.props.style
- );
- if ( ! result ) delete element.props.style[ key ];
- else element.props.style[ key ] = result;
+ directive( 'style', ( { directives: { style }, element, evaluate } ) => {
+ style
+ .filter( ( { suffix } ) => suffix !== 'default' )
+ .forEach( ( entry ) => {
+ const key = entry.suffix;
+ const result = evaluate( entry, { key } );
+ element.props.style = element.props.style || {};
+ if ( typeof element.props.style === 'string' )
+ element.props.style = cssStringToObject(
+ element.props.style
+ );
+ if ( ! result ) delete element.props.style[ key ];
+ else element.props.style[ key ] = result;
- useEffect( () => {
- // This seems necessary because Preact doesn't change the styles on
- // the hydration, so we have to do it manually. It doesn't need deps
- // because it only needs to do it the first time.
- if ( ! result ) {
- element.ref.current.style.removeProperty( key );
- } else {
- element.ref.current.style[ key ] = result;
- }
- }, [] );
- } );
- }
- );
+ useEffect( () => {
+ // This seems necessary because Preact doesn't change the styles on
+ // the hydration, so we have to do it manually. It doesn't need deps
+ // because it only needs to do it the first time.
+ if ( ! result ) {
+ element.ref.current.style.removeProperty( key );
+ } else {
+ element.ref.current.style[ key ] = result;
+ }
+ }, [] );
+ } );
+ } );
// data-wp-bind--[attribute]
+ directive( 'bind', ( { directives: { bind }, element, evaluate } ) => {
+ bind.filter( ( { suffix } ) => suffix !== 'default' ).forEach(
+ ( entry ) => {
+ const attribute = entry.suffix;
+ const result = evaluate( entry );
+ element.props[ attribute ] = result;
+ // Preact doesn't handle the `role` attribute properly, as it doesn't remove it when `null`.
+ // We need this workaround until the following issue is solved:
+ // https://github.com/preactjs/preact/issues/4136
+ useLayoutEffect( () => {
+ if (
+ attribute === 'role' &&
+ ( result === null || result === undefined )
+ ) {
+ element.ref.current.removeAttribute( attribute );
+ }
+ }, [ attribute, result ] );
+
+ // This seems necessary because Preact doesn't change the attributes
+ // on the hydration, so we have to do it manually. It doesn't need
+ // deps because it only needs to do it the first time.
+ useEffect( () => {
+ const el = element.ref.current;
+
+ // We set the value directly to the corresponding
+ // HTMLElement instance property excluding the following
+ // special cases.
+ // We follow Preact's logic: https://github.com/preactjs/preact/blob/ea49f7a0f9d1ff2c98c0bdd66aa0cbc583055246/src/diff/props.js#L110-L129
+ if (
+ attribute !== 'width' &&
+ attribute !== 'height' &&
+ attribute !== 'href' &&
+ attribute !== 'list' &&
+ attribute !== 'form' &&
+ // Default value in browsers is `-1` and an empty string is
+ // cast to `0` instead
+ attribute !== 'tabIndex' &&
+ attribute !== 'download' &&
+ attribute !== 'rowSpan' &&
+ attribute !== 'colSpan' &&
+ attribute !== 'role' &&
+ attribute in el
+ ) {
+ try {
+ el[ attribute ] =
+ result === null || result === undefined
+ ? ''
+ : result;
+ return;
+ } catch ( err ) {}
+ }
+ // aria- and data- attributes have no boolean representation.
+ // A `false` value is different from the attribute not being
+ // present, so we can't remove it.
+ // We follow Preact's logic: https://github.com/preactjs/preact/blob/ea49f7a0f9d1ff2c98c0bdd66aa0cbc583055246/src/diff/props.js#L131C24-L136
+ if (
+ result !== null &&
+ result !== undefined &&
+ ( result !== false || attribute[ 4 ] === '-' )
+ ) {
+ el.setAttribute( attribute, result );
+ } else {
+ el.removeAttribute( attribute );
+ }
+ }, [] );
+ }
+ );
+ } );
+
+ // data-wp-navigation-link
directive(
- 'bind',
- ( { directives: { bind }, element, context, evaluate } ) => {
- const contextValue = useContext( context );
- Object.entries( bind )
- .filter( ( n ) => n !== 'default' )
- .forEach( ( [ attribute, path ] ) => {
- const result = evaluate( path, {
- context: contextValue,
- } );
- element.props[ attribute ] = result;
- // Preact doesn't handle the `role` attribute properly, as it doesn't remove it when `null`.
- // We need this workaround until the following issue is solved:
- // https://github.com/preactjs/preact/issues/4136
- useLayoutEffect( () => {
- if (
- attribute === 'role' &&
- ( result === null || result === undefined )
- ) {
- element.ref.current.removeAttribute( attribute );
- }
- }, [ attribute, result ] );
+ 'navigation-link',
+ ( {
+ directives: { 'navigation-link': navigationLink },
+ props: { href },
+ element,
+ } ) => {
+ const { value: link } = navigationLink.find(
+ ( { suffix } ) => suffix === 'default'
+ );
- // This seems necessary because Preact doesn't change the attributes
- // on the hydration, so we have to do it manually. It doesn't need
- // deps because it only needs to do it the first time.
- useEffect( () => {
- const el = element.ref.current;
-
- // We set the value directly to the corresponding
- // HTMLElement instance property excluding the following
- // special cases.
- // We follow Preact's logic: https://github.com/preactjs/preact/blob/ea49f7a0f9d1ff2c98c0bdd66aa0cbc583055246/src/diff/props.js#L110-L129
- if (
- attribute !== 'width' &&
- attribute !== 'height' &&
- attribute !== 'href' &&
- attribute !== 'list' &&
- attribute !== 'form' &&
- // Default value in browsers is `-1` and an empty string is
- // cast to `0` instead
- attribute !== 'tabIndex' &&
- attribute !== 'download' &&
- attribute !== 'rowSpan' &&
- attribute !== 'colSpan' &&
- attribute !== 'role' &&
- attribute in el
- ) {
- try {
- el[ attribute ] =
- result === null || result === undefined
- ? ''
- : result;
- return;
- } catch ( err ) {}
- }
- // aria- and data- attributes have no boolean representation.
- // A `false` value is different from the attribute not being
- // present, so we can't remove it.
- // We follow Preact's logic: https://github.com/preactjs/preact/blob/ea49f7a0f9d1ff2c98c0bdd66aa0cbc583055246/src/diff/props.js#L131C24-L136
- if (
- result !== null &&
- result !== undefined &&
- ( result !== false || attribute[ 4 ] === '-' )
- ) {
- el.setAttribute( attribute, result );
- } else {
- el.removeAttribute( attribute );
- }
- }, [] );
- } );
+ useEffect( () => {
+ // Prefetch the page if it is in the directive options.
+ if ( link?.prefetch ) {
+ // prefetch( href );
+ }
+ } );
+
+ // Don't do anything if it's falsy.
+ if ( link !== false ) {
+ element.props.onclick = async ( event ) => {
+ event.preventDefault();
+
+ // Fetch the page (or return it from cache).
+ await navigate( href );
+
+ // Update the scroll, depending on the option. True by default.
+ if ( link?.scroll === 'smooth' ) {
+ window.scrollTo( {
+ top: 0,
+ left: 0,
+ behavior: 'smooth',
+ } );
+ } else if ( link?.scroll !== false ) {
+ window.scrollTo( 0, 0 );
+ }
+ };
+ }
}
);
@@ -308,35 +328,20 @@ export default () => {
);
// data-wp-text
- directive(
- 'text',
- ( {
- directives: {
- text: { default: text },
- },
- element,
- evaluate,
- context,
- } ) => {
- const contextValue = useContext( context );
- element.props.children = evaluate( text, {
- context: contextValue,
- } );
- }
- );
+ directive( 'text', ( { directives: { text }, element, evaluate } ) => {
+ const entry = text.find( ( { suffix } ) => suffix === 'default' );
+ element.props.children = evaluate( entry );
+ } );
// data-wp-slot
directive(
'slot',
- ( {
- directives: {
- slot: { default: slot },
- },
- props: { children },
- element,
- } ) => {
- const name = typeof slot === 'string' ? slot : slot.name;
- const position = slot.position || 'children';
+ ( { directives: { slot }, props: { children }, element } ) => {
+ const { value } = slot.find(
+ ( { suffix } ) => suffix === 'default'
+ );
+ const name = typeof value === 'string' ? value : value.name;
+ const position = value.position || 'children';
if ( position === 'before' ) {
return (
@@ -369,16 +374,9 @@ export default () => {
// data-wp-fill
directive(
'fill',
- ( {
- directives: {
- fill: { default: fill },
- },
- props: { children },
- evaluate,
- context,
- } ) => {
- const contextValue = useContext( context );
- const slot = evaluate( fill, { context: contextValue } );
+ ( { directives: { fill }, props: { children }, evaluate } ) => {
+ const entry = fill.find( ( { suffix } ) => suffix === 'default' );
+ const slot = evaluate( entry );
return
{ children };
},
{ priority: 4 }
diff --git a/packages/interactivity/src/hooks.js b/packages/interactivity/src/hooks.tsx
similarity index 63%
rename from packages/interactivity/src/hooks.js
rename to packages/interactivity/src/hooks.tsx
index d5b019300fed1a..f782d998498621 100644
--- a/packages/interactivity/src/hooks.js
+++ b/packages/interactivity/src/hooks.tsx
@@ -1,12 +1,15 @@
+// @ts-nocheck
+
/**
* External dependencies
*/
import { h, options, createContext, cloneElement } from 'preact';
-import { useRef, useCallback } from 'preact/hooks';
+import { useRef, useCallback, useContext } from 'preact/hooks';
+import { deepSignal } from 'deepsignal';
/**
* Internal dependencies
*/
-import { rawStore as store } from './store';
+import { stores } from './store';
/** @typedef {import('preact').VNode} VNode */
/** @typedef {typeof context} Context */
@@ -37,6 +40,67 @@ import { rawStore as store } from './store';
// Main context.
const context = createContext( {} );
+// Wrap the element props to prevent modifications.
+const immutableMap = new WeakMap();
+const immutableError = () => {
+ throw new Error(
+ 'Please use `data-wp-bind` to modify the attributes of an element.'
+ );
+};
+const immutableHandlers = {
+ get( target, key, receiver ) {
+ const value = Reflect.get( target, key, receiver );
+ return !! value && typeof value === 'object'
+ ? deepImmutable( value )
+ : value;
+ },
+ set: immutableError,
+ deleteProperty: immutableError,
+};
+const deepImmutable = < T extends Object = {} >( target: T ): T => {
+ if ( ! immutableMap.has( target ) )
+ immutableMap.set( target, new Proxy( target, immutableHandlers ) );
+ return immutableMap.get( target );
+};
+
+// Store stacks for the current scope and the default namespaces and export APIs
+// to interact with them.
+const scopeStack: any[] = [];
+const namespaceStack: string[] = [];
+
+export const getContext = < T extends object >( namespace?: string ): T =>
+ getScope()?.context[ namespace || namespaceStack.slice( -1 )[ 0 ] ];
+
+export const getElement = () => {
+ if ( ! getScope() ) {
+ throw Error(
+ 'Cannot call `getElement()` outside getters and actions used by directives.'
+ );
+ }
+ const { ref, state, props } = getScope();
+ return Object.freeze( {
+ ref: ref.current,
+ state,
+ props: deepImmutable( props ),
+ } );
+};
+
+export const getScope = () => scopeStack.slice( -1 )[ 0 ];
+
+export const setScope = ( scope ) => {
+ scopeStack.push( scope );
+};
+export const resetScope = () => {
+ scopeStack.pop();
+};
+
+export const setNamespace = ( namespace: string ) => {
+ namespaceStack.push( namespace );
+};
+export const resetNamespace = () => {
+ namespaceStack.pop();
+};
+
// WordPress Directives.
const directiveCallbacks = {};
const directivePriorities = {};
@@ -112,29 +176,28 @@ export const directive = ( name, callback, { priority = 10 } = {} ) => {
};
// Resolve the path to some property of the store object.
-const resolve = ( path, ctx ) => {
- let current = { ...store, context: ctx };
+const resolve = ( path, namespace ) => {
+ let current = {
+ ...stores.get( namespace ),
+ context: getScope().context[ namespace ],
+ };
path.split( '.' ).forEach( ( p ) => ( current = current[ p ] ) );
return current;
};
// Generate the evaluate function.
const getEvaluate =
- ( { ref } = {} ) =>
- ( path, extraArgs = {} ) => {
+ ( { scope } = {} ) =>
+ ( entry, ...args ) => {
+ let { value: path, namespace } = entry;
// If path starts with !, remove it and save a flag.
const hasNegationOperator =
path[ 0 ] === '!' && !! ( path = path.slice( 1 ) );
- const value = resolve( path, extraArgs.context );
- const returnValue =
- typeof value === 'function'
- ? value( {
- ref: ref.current,
- ...store,
- ...extraArgs,
- } )
- : value;
- return hasNegationOperator ? ! returnValue : returnValue;
+ setScope( scope );
+ const value = resolve( path, namespace );
+ const result = typeof value === 'function' ? value( ...args ) : value;
+ resetScope();
+ return hasNegationOperator ? ! result : result;
};
// Separate directives by priority. The resulting array contains objects
@@ -153,25 +216,28 @@ const getPriorityLevels = ( directives ) => {
.map( ( [ , arr ] ) => arr );
};
-// Priority level wrapper.
+// Component that wraps each priority level of directives of an element.
const Directives = ( {
directives,
priorityLevels: [ currentPriorityLevel, ...nextPriorityLevels ],
element,
- evaluate,
originalProps,
- elemRef,
+ previousScope = {},
} ) => {
- // Initialize the DOM reference.
- // eslint-disable-next-line react-hooks/rules-of-hooks
- elemRef = elemRef || useRef( null );
-
- // Create a reference to the evaluate function using the DOM reference.
- // eslint-disable-next-line react-hooks/rules-of-hooks, react-hooks/exhaustive-deps
- evaluate = evaluate || useCallback( getEvaluate( { ref: elemRef } ), [] );
+ // Initialize the scope of this element. These scopes are different per each
+ // level because each level has a different context, but they share the same
+ // element ref, state and props.
+ const scope = useRef( {} ).current;
+ scope.evaluate = useCallback( getEvaluate( { scope } ), [] );
+ scope.context = useContext( context );
+ /* eslint-disable react-hooks/rules-of-hooks */
+ scope.ref = previousScope.ref || useRef( null );
+ scope.state = previousScope.state || useRef( deepSignal( {} ) ).current;
+ /* eslint-enable react-hooks/rules-of-hooks */
- // Create a fresh copy of the vnode element.
- element = cloneElement( element, { ref: elemRef } );
+ // Create a fresh copy of the vnode element and add the props to the scope.
+ element = cloneElement( element, { ref: scope.ref } );
+ scope.props = element.props;
// Recursively render the wrapper for the next priority level.
const children =
@@ -180,22 +246,31 @@ const Directives = ( {
directives={ directives }
priorityLevels={ nextPriorityLevels }
element={ element }
- evaluate={ evaluate }
originalProps={ originalProps }
- elemRef={ elemRef }
+ previousScope={ scope }
/>
) : (
element
);
const props = { ...originalProps, children };
- const directiveArgs = { directives, props, element, context, evaluate };
+ const directiveArgs = {
+ directives,
+ props,
+ element,
+ context,
+ evaluate: scope.evaluate,
+ };
+
+ setScope( scope );
for ( const directiveName of currentPriorityLevel ) {
const wrapper = directiveCallbacks[ directiveName ]?.( directiveArgs );
if ( wrapper !== undefined ) props.children = wrapper;
}
+ resetScope();
+
return props.children;
};
@@ -205,7 +280,10 @@ options.vnode = ( vnode ) => {
if ( vnode.props.__directives ) {
const props = vnode.props;
const directives = props.__directives;
- if ( directives.key ) vnode.key = directives.key.default;
+ if ( directives.key )
+ vnode.key = directives.key.find(
+ ( { suffix } ) => suffix === 'default'
+ ).value;
delete props.__directives;
const priorityLevels = getPriorityLevels( directives );
if ( priorityLevels.length > 0 ) {
diff --git a/packages/interactivity/src/index.js b/packages/interactivity/src/index.js
index 88e81e6f5877c0..6c7b98e8e7a79e 100644
--- a/packages/interactivity/src/index.js
+++ b/packages/interactivity/src/index.js
@@ -3,9 +3,9 @@
*/
import registerDirectives from './directives';
import { init } from './router';
-import { rawStore, afterLoads } from './store';
+
export { store } from './store';
-export { directive } from './hooks';
+export { directive, getContext, getElement } from './hooks';
export { navigate, prefetch } from './router';
export { h as createElement } from 'preact';
export { useEffect, useContext, useMemo } from 'preact/hooks';
@@ -14,5 +14,4 @@ export { deepSignal } from 'deepsignal';
document.addEventListener( 'DOMContentLoaded', async () => {
registerDirectives();
await init();
- afterLoads.forEach( ( afterLoad ) => afterLoad( rawStore ) );
} );
diff --git a/packages/interactivity/src/store.js b/packages/interactivity/src/store.js
deleted file mode 100644
index e0c5f8b3fae777..00000000000000
--- a/packages/interactivity/src/store.js
+++ /dev/null
@@ -1,102 +0,0 @@
-/**
- * External dependencies
- */
-import { deepSignal } from 'deepsignal';
-
-const isObject = ( item ) =>
- item && typeof item === 'object' && ! Array.isArray( item );
-
-const deepMerge = ( target, source ) => {
- if ( isObject( target ) && isObject( source ) ) {
- for ( const key in source ) {
- if ( isObject( source[ key ] ) ) {
- if ( ! target[ key ] ) Object.assign( target, { [ key ]: {} } );
- deepMerge( target[ key ], source[ key ] );
- } else {
- Object.assign( target, { [ key ]: source[ key ] } );
- }
- }
- }
-};
-
-const getSerializedState = () => {
- const storeTag = document.querySelector(
- `script[type="application/json"]#wp-interactivity-store-data`
- );
- if ( ! storeTag ) 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 afterLoads = new Set();
-
-const rawState = getSerializedState();
-export const rawStore = { state: deepSignal( rawState ) };
-
-/**
- * @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.
- * @property {(store:any) => void} [afterLoad] Callback to be executed after the
- * Interactivity API has been set up
- * and the store is ready. It
- * receives the store as argument.
- */
-
-/**
- * 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
- *
- *
- *
- * ```
- *
- * @param {StoreProps} properties Properties to be added to the global store.
- * @param {StoreOptions} [options] Options passed to the `store` call.
- */
-export const store = ( { state, ...block }, { afterLoad } = {} ) => {
- deepMerge( rawStore, block );
- deepMerge( rawState, state );
- if ( afterLoad ) afterLoads.add( afterLoad );
-};
diff --git a/packages/interactivity/src/store.ts b/packages/interactivity/src/store.ts
new file mode 100644
index 00000000000000..1e9ab7e1a8f46b
--- /dev/null
+++ b/packages/interactivity/src/store.ts
@@ -0,0 +1,289 @@
+/**
+ * External dependencies
+ */
+import { deepSignal } from 'deepsignal';
+import { computed } from '@preact/signals';
+
+/**
+ * Internal dependencies
+ */
+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"]#wp-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
+ *
+ *
+ *
+ * ```
+ *
+ * @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 } );
+} );
diff --git a/packages/interactivity/src/vdom.js b/packages/interactivity/src/vdom.js
index 1cf4a91ec1ead5..b1342ac271a8e2 100644
--- a/packages/interactivity/src/vdom.js
+++ b/packages/interactivity/src/vdom.js
@@ -10,6 +10,7 @@ import { directivePrefix as p } from './constants';
const ignoreAttr = `data-${ p }-ignore`;
const islandAttr = `data-${ p }-interactive`;
const fullPrefix = `data-${ p }-`;
+let namespace = null;
// Regular expression for directive parsing.
const directiveParser = new RegExp(
@@ -25,6 +26,12 @@ const directiveParser = new RegExp(
'i' // Case insensitive.
);
+// Regular expression for reference parsing. It can contain a namespace before
+// the reference, separated by `::`, like `some-namespace::state.somePath`.
+// Namespaces can contain any alphanumeric characters, hyphens, underscores or
+// forward slashes. References don't have any restrictions.
+const nsPathRegExp = /^([\w-_\/]+)::(.+)$/;
+
export const hydratedIslands = new WeakSet();
// Recursive function that transforms a DOM tree into vDOM.
@@ -51,8 +58,7 @@ export function toVdom( root ) {
const props = {};
const children = [];
- const directives = {};
- let hasDirectives = false;
+ const directives = [];
let ignore = false;
let island = false;
@@ -64,17 +70,19 @@ export function toVdom( root ) {
) {
if ( n === ignoreAttr ) {
ignore = true;
- } else if ( n === islandAttr ) {
- island = true;
} else {
- hasDirectives = true;
- let val = attributes[ i ].value;
+ let [ ns, value ] = nsPathRegExp
+ .exec( attributes[ i ].value )
+ ?.slice( 1 ) ?? [ null, attributes[ i ].value ];
try {
- val = JSON.parse( val );
+ value = JSON.parse( value );
} catch ( e ) {}
- const [ , prefix, suffix ] = directiveParser.exec( n );
- directives[ prefix ] = directives[ prefix ] || {};
- directives[ prefix ][ suffix || 'default' ] = val;
+ if ( n === islandAttr ) {
+ island = true;
+ namespace = value?.namespace ?? null;
+ } else {
+ directives.push( [ n, ns, value ] );
+ }
}
} else if ( n === 'ref' ) {
continue;
@@ -92,7 +100,22 @@ export function toVdom( root ) {
];
if ( island ) hydratedIslands.add( node );
- if ( hasDirectives ) props.__directives = directives;
+ if ( directives.length ) {
+ props.__directives = directives.reduce(
+ ( obj, [ name, ns, value ] ) => {
+ const [ , prefix, suffix = 'default' ] =
+ directiveParser.exec( name );
+ if ( ! obj[ prefix ] ) obj[ prefix ] = [];
+ obj[ prefix ].push( {
+ namespace: ns ?? namespace,
+ value,
+ suffix,
+ } );
+ return obj;
+ },
+ {}
+ );
+ }
let child = treeWalker.firstChild();
if ( child ) {
diff --git a/packages/interactivity/tsconfig.json b/packages/interactivity/tsconfig.json
new file mode 100644
index 00000000000000..bcb26904e1d09d
--- /dev/null
+++ b/packages/interactivity/tsconfig.json
@@ -0,0 +1,10 @@
+{
+ "extends": "../../tsconfig.base.json",
+ "compilerOptions": {
+ "rootDir": "src",
+ "declarationDir": "build-types",
+ "checkJs": false,
+ "strict": false
+ },
+ "include": [ "src/**/*" ]
+}
diff --git a/phpunit/blocks/render-query-test.php b/phpunit/blocks/render-query-test.php
index 2d81bfdb513b33..796c4ae098867c 100644
--- a/phpunit/blocks/render-query-test.php
+++ b/phpunit/blocks/render-query-test.php
@@ -68,32 +68,32 @@ public function test_rendering_query_with_enhanced_pagination() {
$p = new WP_HTML_Tag_Processor( $output );
$p->next_tag( array( 'class_name' => 'wp-block-query' ) );
- $this->assertSame( '{"core":{"query":{"loadingText":"Loading page, please wait.","loadedText":"Page Loaded."}}}', $p->get_attribute( 'data-wp-context' ) );
+ $this->assertSame( '{"loadingText":"Loading page, please wait.","loadedText":"Page Loaded."}', $p->get_attribute( 'data-wp-context' ) );
$this->assertSame( 'query-0', $p->get_attribute( 'data-wp-navigation-id' ) );
- $this->assertSame( true, $p->get_attribute( 'data-wp-interactive' ) );
+ $this->assertSame( '{"namespace":"core/query"}', $p->get_attribute( 'data-wp-interactive' ) );
$p->next_tag( array( 'class_name' => 'wp-block-post' ) );
$this->assertSame( 'post-template-item-' . self::$posts[1], $p->get_attribute( 'data-wp-key' ) );
$p->next_tag( array( 'class_name' => 'wp-block-query-pagination-previous' ) );
$this->assertSame( 'query-pagination-previous', $p->get_attribute( 'data-wp-key' ) );
- $this->assertSame( 'actions.core.query.navigate', $p->get_attribute( 'data-wp-on--click' ) );
- $this->assertSame( 'actions.core.query.prefetch', $p->get_attribute( 'data-wp-on--mouseenter' ) );
- $this->assertSame( 'effects.core.query.prefetch', $p->get_attribute( 'data-wp-effect' ) );
+ $this->assertSame( 'core/query::actions.navigate', $p->get_attribute( 'data-wp-on--click' ) );
+ $this->assertSame( 'core/query::actions.prefetch', $p->get_attribute( 'data-wp-on--mouseenter' ) );
+ $this->assertSame( 'core/query::callbacks.prefetch', $p->get_attribute( 'data-wp-watch' ) );
$p->next_tag( array( 'class_name' => 'wp-block-query-pagination-next' ) );
$this->assertSame( 'query-pagination-next', $p->get_attribute( 'data-wp-key' ) );
- $this->assertSame( 'actions.core.query.navigate', $p->get_attribute( 'data-wp-on--click' ) );
- $this->assertSame( 'actions.core.query.prefetch', $p->get_attribute( 'data-wp-on--mouseenter' ) );
- $this->assertSame( 'effects.core.query.prefetch', $p->get_attribute( 'data-wp-effect' ) );
+ $this->assertSame( 'core/query::actions.navigate', $p->get_attribute( 'data-wp-on--click' ) );
+ $this->assertSame( 'core/query::actions.prefetch', $p->get_attribute( 'data-wp-on--mouseenter' ) );
+ $this->assertSame( 'core/query::callbacks.prefetch', $p->get_attribute( 'data-wp-watch' ) );
$p->next_tag( array( 'class_name' => 'screen-reader-text' ) );
$this->assertSame( 'polite', $p->get_attribute( 'aria-live' ) );
- $this->assertSame( 'context.core.query.message', $p->get_attribute( 'data-wp-text' ) );
+ $this->assertSame( 'context.message', $p->get_attribute( 'data-wp-text' ) );
$p->next_tag( array( 'class_name' => 'wp-block-query__enhanced-pagination-animation' ) );
- $this->assertSame( 'selectors.core.query.startAnimation', $p->get_attribute( 'data-wp-class--start-animation' ) );
- $this->assertSame( 'selectors.core.query.finishAnimation', $p->get_attribute( 'data-wp-class--finish-animation' ) );
+ $this->assertSame( 'state.startAnimation', $p->get_attribute( 'data-wp-class--start-animation' ) );
+ $this->assertSame( 'state.finishAnimation', $p->get_attribute( 'data-wp-class--finish-animation' ) );
}
/**
@@ -170,7 +170,7 @@ public function test_enhanced_query_markup_rendering_at_bottom_on_custom_html_el
$this->assertSame( $p->next_tag(), true );
// Test that that div is the accesibility one.
$this->assertSame( 'screen-reader-text', $p->get_attribute( 'class' ) );
- $this->assertSame( 'context.core.query.message', $p->get_attribute( 'data-wp-text' ) );
+ $this->assertSame( 'context.message', $p->get_attribute( 'data-wp-text' ) );
$this->assertSame( 'polite', $p->get_attribute( 'aria-live' ) );
}
diff --git a/phpunit/experimental/interactivity-api/class-wp-interactivity-store-test.php b/phpunit/experimental/interactivity-api/class-wp-interactivity-store-test.php
index 22205289b20bee..837d6fd50f193a 100644
--- a/phpunit/experimental/interactivity-api/class-wp-interactivity-store-test.php
+++ b/phpunit/experimental/interactivity-api/class-wp-interactivity-store-test.php
@@ -161,7 +161,7 @@ public function test_store_should_be_correctly_rendered() {
WP_Interactivity_Store::render();
$rendered = ob_get_clean();
$this->assertSame(
- '',
+ '',
$rendered
);
}
@@ -179,7 +179,7 @@ public function test_store_should_also_escape_tags_and_amps() {
WP_Interactivity_Store::render();
$rendered = ob_get_clean();
$this->assertSame(
- '',
+ '',
$rendered
);
}
diff --git a/test/e2e/specs/interactivity/directive-effect.spec.ts b/test/e2e/specs/interactivity/directive-watch.spec.ts
similarity index 79%
rename from test/e2e/specs/interactivity/directive-effect.spec.ts
rename to test/e2e/specs/interactivity/directive-watch.spec.ts
index 40030d257661fc..09bd0214c0a51e 100644
--- a/test/e2e/specs/interactivity/directive-effect.spec.ts
+++ b/test/e2e/specs/interactivity/directive-watch.spec.ts
@@ -3,14 +3,14 @@
*/
import { test, expect } from './fixtures';
-test.describe( 'data-wp-effect', () => {
+test.describe( 'data-wp-watch', () => {
test.beforeAll( async ( { interactivityUtils: utils } ) => {
await utils.activatePlugins();
- await utils.addPostWithBlock( 'test/directive-effect' );
+ await utils.addPostWithBlock( 'test/directive-watch' );
} );
test.beforeEach( async ( { interactivityUtils: utils, page } ) => {
- await page.goto( utils.getLink( 'test/directive-effect' ) );
+ await page.goto( utils.getLink( 'test/directive-watch' ) );
} );
test.afterAll( async ( { interactivityUtils: utils } ) => {
@@ -18,12 +18,12 @@ test.describe( 'data-wp-effect', () => {
await utils.deleteAllPosts();
} );
- test( 'check that effect runs when it is added', async ( { page } ) => {
+ test( 'check that watch runs when it is added', async ( { page } ) => {
const el = page.getByTestId( 'element in the DOM' );
await expect( el ).toContainText( 'element is in the DOM' );
} );
- test( 'check that effect runs when it is removed', async ( { page } ) => {
+ test( 'check that watch runs when it is removed', async ( { page } ) => {
await page.getByTestId( 'toggle' ).click();
const el = page.getByTestId( 'element in the DOM' );
await expect( el ).toContainText( 'element is not in the DOM' );
diff --git a/test/e2e/specs/interactivity/store-afterload.spec.ts b/test/e2e/specs/interactivity/store-afterload.spec.ts
deleted file mode 100644
index 388e80177b0339..00000000000000
--- a/test/e2e/specs/interactivity/store-afterload.spec.ts
+++ /dev/null
@@ -1,40 +0,0 @@
-/**
- * Internal dependencies
- */
-import { test, expect } from './fixtures';
-
-test.describe( 'store afterLoad callbacks', () => {
- test.beforeAll( async ( { interactivityUtils: utils } ) => {
- await utils.activatePlugins();
- await utils.addPostWithBlock( 'test/store-afterload' );
- } );
-
- test.beforeEach( async ( { interactivityUtils: utils, page } ) => {
- await page.goto( utils.getLink( 'test/store-afterload' ) );
- } );
-
- test.afterAll( async ( { interactivityUtils: utils } ) => {
- await utils.deactivatePlugins();
- await utils.deleteAllPosts();
- } );
-
- test( 'run after the vdom and store are ready', async ( { page } ) => {
- const allStoresReady = page.getByTestId( 'all-stores-ready' );
- const vdomReady = page.getByTestId( 'vdom-ready' );
-
- await expect( allStoresReady ).toHaveText( 'true' );
- await expect( vdomReady ).toHaveText( 'true' );
- } );
-
- test( 'run once even if shared between several store calls', async ( {
- page,
- } ) => {
- const afterLoadTimes = page.getByTestId( 'after-load-exec-times' );
- const sharedAfterLoadTimes = page.getByTestId(
- 'shared-after-load-exec-times'
- );
-
- await expect( afterLoadTimes ).toHaveText( '1' );
- await expect( sharedAfterLoadTimes ).toHaveText( '1' );
- } );
-} );
diff --git a/tools/webpack/interactivity.js b/tools/webpack/interactivity.js
index 26e49966ad40c3..e70bf476062284 100644
--- a/tools/webpack/interactivity.js
+++ b/tools/webpack/interactivity.js
@@ -25,6 +25,9 @@ module.exports = {
filename: './build/interactivity/[name].min.js',
path: join( __dirname, '..', '..' ),
},
+ resolve: {
+ extensions: [ '.js', '.ts', '.tsx' ],
+ },
module: {
rules: [
{
@@ -39,6 +42,7 @@ module.exports = {
babelrc: false,
configFile: false,
presets: [
+ '@babel/preset-typescript',
[
'@babel/preset-react',
{
diff --git a/tsconfig.json b/tsconfig.json
index 4ee1787a247cf7..d05e883ed70b03 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -27,6 +27,7 @@
{ "path": "packages/html-entities" },
{ "path": "packages/i18n" },
{ "path": "packages/icons" },
+ { "path": "packages/interactivity" },
{ "path": "packages/is-shallow-equal" },
{ "path": "packages/keycodes" },
{ "path": "packages/lazy-import" },