From a70b6250f334773d4cb40ee3196b9b13eb28e73f Mon Sep 17 00:00:00 2001 From: David Arenas Date: Mon, 16 Oct 2023 17:21:25 +0200 Subject: [PATCH 01/63] Copy new `store()` implementation --- packages/interactivity/src/directives.js | 355 +++++++++--------- .../interactivity/src/{hooks.js => hooks.tsx} | 130 +++++-- packages/interactivity/src/index.js | 5 +- packages/interactivity/src/store.js | 102 ----- packages/interactivity/src/store.ts | 285 ++++++++++++++ packages/interactivity/src/vdom.js | 29 +- 6 files changed, 599 insertions(+), 307 deletions(-) rename packages/interactivity/src/{hooks.js => hooks.tsx} (66%) delete mode 100644 packages/interactivity/src/store.js create mode 100644 packages/interactivity/src/store.ts diff --git a/packages/interactivity/src/directives.js b/packages/interactivity/src/directives.js index ce3859c630231f..9b1266b1062e71 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,44 @@ 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( async () => { + const result = evaluate( entry ); + return await result; } ); } ); } ); + // data-wp-layout-init--[name] + directive( + 'layout-init', + ( { directives: { 'layout-init': layoutInit }, evaluate } ) => { + layoutInit.forEach( ( entry ) => { + useLayoutEffect( () => { + const result = evaluate( entry ); + return result; + }, [] ); + } ); + } + ); + // data-wp-init--[name] - directive( 'init', ( { directives: { init }, context, evaluate } ) => { - const contextValue = useContext( context ); - Object.values( init ).forEach( ( path ) => { + directive( 'init', ( { directives: { init }, evaluate } ) => { + init.forEach( ( entry ) => { useEffect( () => { - return evaluate( path, { context: contextValue } ); + const result = evaluate( entry ); + return result; }, [] ); } ); } ); // 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 +117,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 +187,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 +347,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 +393,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 66% rename from packages/interactivity/src/hooks.js rename to packages/interactivity/src/hooks.tsx index d5b019300fed1a..93e07dc5b61775 100644 --- a/packages/interactivity/src/hooks.js +++ b/packages/interactivity/src/hooks.tsx @@ -2,11 +2,12 @@ * 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 +38,68 @@ import { rawStore as store } from './store'; // Main context. const context = createContext( {} ); +const immutableMap = new WeakMap(); +const deepImmutable = < T extends Object = {} >( target: T ): T => { + if ( immutableMap.has( target ) ) { + return immutableMap.get( target ); + } + const proxy = new Proxy( target, { + get( obj, prop ) { + const value = Reflect.get< any, string | symbol >( obj, prop ); + if ( !! value && typeof value === 'object' ) { + return deepImmutable( value ); + } + return value; + }, + set() { + throw Error( 'Cannot modify a deep immutable object.' ); + }, + deleteProperty() { + throw Error( 'Cannot modify a deep immutable object.' ); + }, + } ); + immutableMap.set( target, proxy ); + return proxy; +}; + +const scopeStack: any[] = []; +const namespaceStack: string[] = []; + +export const getContext = < T extends object >( namespace?: string ): T => { + const [ currentNamespace ] = namespaceStack.slice( -1 ); + return getScope()?.context[ namespace || currentNamespace ]; +}; + +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: state.current, + 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 +175,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 @@ -158,20 +220,23 @@ const Directives = ( { directives, priorityLevels: [ currentPriorityLevel, ...nextPriorityLevels ], element, - evaluate, originalProps, - elemRef, + previousScope = {} as any, } ) => { - // Initialize the DOM reference. + // 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: any = useRef( {} ).current; + scope.context = useContext( context ); // 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 } ), [] ); + scope.ref = previousScope.ref || useRef( null ); + // eslint-disable-next-line react-hooks/rules-of-hooks + scope.state = previousScope.state || useRef( deepSignal( {} ) ); + scope.props = element?.props || originalProps; + scope.evaluate = useCallback( getEvaluate( { scope } ), [] ); // Create a fresh copy of the vnode element. - element = cloneElement( element, { ref: elemRef } ); + element = cloneElement( element, { ref: scope.ref } ); // Recursively render the wrapper for the next priority level. const children = @@ -180,22 +245,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; }; 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..dc6a2526d73813 --- /dev/null +++ b/packages/interactivity/src/store.ts @@ -0,0 +1,285 @@ +/** + * 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"]#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 storeLocks = new Map(); + +const objToProxy = new WeakMap(); +const proxyToNs = 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 proxy is the store root and no prop with that name + // exist. In that case, return an empty object for the prop requested. + if ( receiver === stores.get( ns ) && ! ( key in target ) ) { + const obj = {}; + target[ key ] = obj; + return proxify( obj, ns ); + } + + // Check if the property is a getter. + const getter = Object.getOwnPropertyDescriptor( target, key )?.get; + if ( getter ) { + const scope = getScope(); + if ( scope ) { + scope.getters = scope.getters || new Map(); + if ( ! scope.getters.has( getter ) ) { + scope.getters.set( + getter, + computed( () => { + setNamespace( ns ); + setScope( scope ); + try { + return getter.call( target ); + } finally { + resetScope(); + resetNamespace(); + } + } ) + ); + } + return scope.getters.get( getter ).value; + } + } + + const result = Reflect.get( target, key, receiver ); + + // Check if the property is a generator. + 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 function. + // Actions always run in the current scope. + if ( typeof result === 'function' ) { + return ( ...args: unknown[] ) => { + setNamespace( ns ); + try { + return result( ...args ); + } finally { + resetNamespace(); + } + }; + } + + // Check if the property is an object. + 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. + */ + +type DeepPartial< T > = T extends object + ? { [ P in keyof T ]?: DeepPartial< T[ P ] > } + : T; + +interface StoreOptions { + lock?: boolean | string; +} + +const universalUnlock = + 'I know using a private store means my plugin will inevitably break on the next store release.'; + +export function store< S extends object = {} >( + namespace: string, + storePart?: DeepPartial< 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 ); + } + stores.set( + namespace, + new Proxy( { state: deepSignal( state ), ...block }, handlers ) + ); + proxyToNs.set( stores.get( namespace ), 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 ( ! storeLocks.has( namespace ) && lock !== universalUnlock ) { + 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 = stores.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..b2d2132e72b78b 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. ); +const nsPathRegExp = /^([\w-]+)::(.+)$/; + +const nsValueParser = ( value ) => { + return nsPathRegExp.exec( value )?.slice( 1 ) ?? [ namespace, value ]; +}; + export const hydratedIslands = new WeakSet(); // Recursive function that transforms a DOM tree into vDOM. @@ -66,15 +73,24 @@ export function toVdom( root ) { ignore = true; } else if ( n === islandAttr ) { island = true; + try { + const val = JSON.parse( attributes[ i ].value ); + namespace = val?.namespace ?? null; + } catch ( e ) {} } else { hasDirectives = true; - let val = attributes[ i ].value; + let [ ns, val ] = nsValueParser( attributes[ i ].value ); try { val = JSON.parse( val ); } catch ( e ) {} - const [ , prefix, suffix ] = directiveParser.exec( n ); - directives[ prefix ] = directives[ prefix ] || {}; - directives[ prefix ][ suffix || 'default' ] = val; + const [ , prefix, suffix = 'default' ] = + directiveParser.exec( n ); + directives[ prefix ] = directives[ prefix ] || []; + directives[ prefix ].push( { + namespace: ns, + value: val, + suffix, + } ); } } else if ( n === 'ref' ) { continue; @@ -92,7 +108,10 @@ export function toVdom( root ) { ]; if ( island ) hydratedIslands.add( node ); - if ( hasDirectives ) props.__directives = directives; + if ( hasDirectives ) { + props.__directives = directives; + directives.namespace = namespace; + } let child = treeWalker.firstChild(); if ( child ) { From 961bede440e5f2a256cc4ac23611098ae01591f6 Mon Sep 17 00:00:00 2001 From: David Arenas Date: Tue, 17 Oct 2023 00:40:25 +0200 Subject: [PATCH 02/63] Enable TS for the interactivity API --- packages/block-library/tsconfig.json | 1 + packages/interactivity/src/hooks.tsx | 16 ++++++++-------- packages/interactivity/tsconfig.json | 10 ++++++++++ tools/webpack/interactivity.js | 4 ++++ tsconfig.json | 1 + 5 files changed, 24 insertions(+), 8 deletions(-) create mode 100644 packages/interactivity/tsconfig.json diff --git a/packages/block-library/tsconfig.json b/packages/block-library/tsconfig.json index ddd88be5189a46..a9a30e9804e1f3 100644 --- a/packages/block-library/tsconfig.json +++ b/packages/block-library/tsconfig.json @@ -26,6 +26,7 @@ { "path": "../html-entities" }, { "path": "../i18n" }, { "path": "../icons" }, + { "path": "../interactivity" }, { "path": "../notices" }, { "path": "../keycodes" }, { "path": "../primitives" }, diff --git a/packages/interactivity/src/hooks.tsx b/packages/interactivity/src/hooks.tsx index 93e07dc5b61775..3face139c50aeb 100644 --- a/packages/interactivity/src/hooks.tsx +++ b/packages/interactivity/src/hooks.tsx @@ -1,7 +1,7 @@ /** * External dependencies */ -import { h, options, createContext, cloneElement } from 'preact'; +import { h, options, createContext, cloneElement, type VNode } from 'preact'; import { useRef, useCallback, useContext } from 'preact/hooks'; import { deepSignal } from 'deepsignal'; /** @@ -186,7 +186,7 @@ const resolve = ( path, namespace ) => { // Generate the evaluate function. const getEvaluate = - ( { scope } = {} ) => + ( { scope } ) => ( entry, ...args ) => { let { value: path, namespace } = entry; // If path starts with !, remove it and save a flag. @@ -211,7 +211,7 @@ const getPriorityLevels = ( directives ) => { }, {} ); return Object.entries( byPriority ) - .sort( ( [ p1 ], [ p2 ] ) => p1 - p2 ) + .sort( ( [ p1 ], [ p2 ] ) => parseInt( p1 ) - parseInt( p2 ) ) .map( ( [ , arr ] ) => arr ); }; @@ -243,7 +243,7 @@ const Directives = ( { nextPriorityLevels.length > 0 ? ( { +options.vnode = ( vnode: VNode< { __directives?: any } > ) => { if ( vnode.props.__directives ) { const props = vnode.props; const directives = props.__directives; @@ -288,10 +288,10 @@ options.vnode = ( vnode ) => { priorityLevels, originalProps: props, type: vnode.type, - element: h( vnode.type, props ), + element: h( vnode.type as string, props ), top: true, - }; - vnode.type = Directives; + } as any; + vnode.type = Directives as any; } } 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/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" }, From 81a7f4631c82c749b7ffa24eae88884c15e00794 Mon Sep 17 00:00:00 2001 From: David Arenas Date: Tue, 17 Oct 2023 00:41:02 +0200 Subject: [PATCH 03/63] Update directive bind tests --- .../plugins/interactive-blocks/directive-bind/render.php | 2 +- .../plugins/interactive-blocks/directive-bind/view.js | 9 +++++---- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/packages/e2e-tests/plugins/interactive-blocks/directive-bind/render.php b/packages/e2e-tests/plugins/interactive-blocks/directive-bind/render.php index a94eb20bfa6d54..f313262b7c0587 100644 --- a/packages/e2e-tests/plugins/interactive-blocks/directive-bind/render.php +++ b/packages/e2e-tests/plugins/interactive-blocks/directive-bind/render.php @@ -6,7 +6,7 @@ */ ?> -
+
{ - const { store } = wp.interactivity; + const { store, getContext } = wp.interactivity; - store( { + const { state, foo } = store( 'directive-bind', { state: { url: '/some-url', checked: true, @@ -12,13 +12,14 @@ bar: 1, }, actions: { - toggle: ( { state, foo } ) => { + toggle: () => { state.url = '/some-other-url'; state.checked = ! state.checked; state.show = ! state.show; state.width += foo.bar; }, - toggleValue: ( { context } ) => { + toggleValue: () => { + const context = getContext(); const previousValue = ( 'previousValue' in context ) ? context.previousValue // Any string works here; we just want to toggle the value From af4db1213f572fc2efe8c44e1f85a05dca1f8ae8 Mon Sep 17 00:00:00 2001 From: David Arenas Date: Tue, 17 Oct 2023 13:15:14 +0200 Subject: [PATCH 04/63] Update runtime with latest changes --- packages/interactivity/src/directives.js | 15 +---- packages/interactivity/src/hooks.tsx | 85 ++++++++++++------------ packages/interactivity/src/store.ts | 53 ++++++++------- packages/interactivity/src/vdom.js | 50 +++++++------- 4 files changed, 98 insertions(+), 105 deletions(-) diff --git a/packages/interactivity/src/directives.js b/packages/interactivity/src/directives.js index 9b1266b1062e71..415138011a1ede 100644 --- a/packages/interactivity/src/directives.js +++ b/packages/interactivity/src/directives.js @@ -75,10 +75,7 @@ export default () => { // data-wp-watch--[name] directive( 'watch', ( { directives: { watch }, evaluate } ) => { watch.forEach( ( entry ) => { - useSignalEffect( async () => { - const result = evaluate( entry ); - return await result; - } ); + useSignalEffect( () => evaluate( entry ) ); } ); } ); @@ -87,10 +84,7 @@ export default () => { 'layout-init', ( { directives: { 'layout-init': layoutInit }, evaluate } ) => { layoutInit.forEach( ( entry ) => { - useLayoutEffect( () => { - const result = evaluate( entry ); - return result; - }, [] ); + useLayoutEffect( () => evaluate( entry ), [] ); } ); } ); @@ -98,10 +92,7 @@ export default () => { // data-wp-init--[name] directive( 'init', ( { directives: { init }, evaluate } ) => { init.forEach( ( entry ) => { - useEffect( () => { - const result = evaluate( entry ); - return result; - }, [] ); + useEffect( () => evaluate( entry ), [] ); } ); } ); diff --git a/packages/interactivity/src/hooks.tsx b/packages/interactivity/src/hooks.tsx index 3face139c50aeb..c6255684ed39c3 100644 --- a/packages/interactivity/src/hooks.tsx +++ b/packages/interactivity/src/hooks.tsx @@ -1,7 +1,9 @@ +// @ts-nocheck + /** * External dependencies */ -import { h, options, createContext, cloneElement, type VNode } from 'preact'; +import { h, options, createContext, cloneElement } from 'preact'; import { useRef, useCallback, useContext } from 'preact/hooks'; import { deepSignal } from 'deepsignal'; /** @@ -38,37 +40,36 @@ import { stores } 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 ) ) { - return immutableMap.get( target ); - } - const proxy = new Proxy( target, { - get( obj, prop ) { - const value = Reflect.get< any, string | symbol >( obj, prop ); - if ( !! value && typeof value === 'object' ) { - return deepImmutable( value ); - } - return value; - }, - set() { - throw Error( 'Cannot modify a deep immutable object.' ); - }, - deleteProperty() { - throw Error( 'Cannot modify a deep immutable object.' ); - }, - } ); - immutableMap.set( target, proxy ); - return proxy; + 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 => { - const [ currentNamespace ] = namespaceStack.slice( -1 ); - return getScope()?.context[ namespace || currentNamespace ]; -}; +export const getContext = < T extends object >( namespace?: string ): T => + getScope()?.context[ namespace || namespaceStack.slice( -1 )[ 0 ] ]; export const getElement = () => { if ( ! getScope() ) { @@ -79,7 +80,7 @@ export const getElement = () => { const { ref, state, props } = getScope(); return Object.freeze( { ref: ref.current, - state: state.current, + state, props: deepImmutable( props ), } ); }; @@ -186,7 +187,7 @@ const resolve = ( path, namespace ) => { // Generate the evaluate function. const getEvaluate = - ( { scope } ) => + ( { scope } = {} ) => ( entry, ...args ) => { let { value: path, namespace } = entry; // If path starts with !, remove it and save a flag. @@ -211,39 +212,39 @@ const getPriorityLevels = ( directives ) => { }, {} ); return Object.entries( byPriority ) - .sort( ( [ p1 ], [ p2 ] ) => parseInt( p1 ) - parseInt( p2 ) ) + .sort( ( [ p1 ], [ p2 ] ) => p1 - p2 ) .map( ( [ , arr ] ) => arr ); }; -// Priority level wrapper. +// Component that wraps each priority level of directives of an element. const Directives = ( { directives, priorityLevels: [ currentPriorityLevel, ...nextPriorityLevels ], element, originalProps, - previousScope = {} as any, + previousScope = {}, } ) => { // 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: any = useRef( {} ).current; + const scope = useRef( {} ).current; + scope.evaluate = useCallback( getEvaluate( { scope } ), [] ); scope.context = useContext( context ); - // eslint-disable-next-line react-hooks/rules-of-hooks + /* eslint-disable react-hooks/rules-of-hooks */ scope.ref = previousScope.ref || useRef( null ); - // eslint-disable-next-line react-hooks/rules-of-hooks - scope.state = previousScope.state || useRef( deepSignal( {} ) ); - scope.props = element?.props || originalProps; - scope.evaluate = useCallback( getEvaluate( { scope } ), [] ); + scope.state = previousScope.state || useRef( deepSignal( {} ) ).current; + /* eslint-enable react-hooks/rules-of-hooks */ - // Create a fresh copy of the vnode element. + // 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 = nextPriorityLevels.length > 0 ? ( ) => { +options.vnode = ( vnode ) => { if ( vnode.props.__directives ) { const props = vnode.props; const directives = props.__directives; @@ -288,10 +289,10 @@ options.vnode = ( vnode: VNode< { __directives?: any } > ) => { priorityLevels, originalProps: props, type: vnode.type, - element: h( vnode.type as string, props ), + element: h( vnode.type, props ), top: true, - } as any; - vnode.type = Directives as any; + }; + vnode.type = Directives; } } diff --git a/packages/interactivity/src/store.ts b/packages/interactivity/src/store.ts index dc6a2526d73813..4790b1309aed32 100644 --- a/packages/interactivity/src/store.ts +++ b/packages/interactivity/src/store.ts @@ -55,6 +55,7 @@ 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 ) ) { @@ -62,7 +63,6 @@ const proxify = ( obj: any, ns: string ) => { objToProxy.set( obj, proxy ); proxyToNs.set( proxy, ns ); } - return objToProxy.get( obj ); }; @@ -70,22 +70,18 @@ const handlers = { get: ( target: any, key: string | symbol, receiver: any ) => { const ns = proxyToNs.get( receiver ); - // Check if the proxy is the store root and no prop with that name - // exist. In that case, return an empty object for the prop requested. - if ( receiver === stores.get( ns ) && ! ( key in target ) ) { - const obj = {}; - target[ key ] = obj; - return proxify( obj, ns ); - } - - // Check if the property is a getter. + // 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 ) { - scope.getters = scope.getters || new Map(); - if ( ! scope.getters.has( getter ) ) { - scope.getters.set( + const getters = + scopeToGetters.get( scope ) || + scopeToGetters.set( scope, new Map() ).get( scope ); + if ( ! getters.has( getter ) ) { + getters.set( getter, computed( () => { setNamespace( ns ); @@ -99,13 +95,23 @@ const handlers = { } ) ); } - return scope.getters.get( getter ).value; + return getters.get( getter ).value; } } const result = Reflect.get( target, key, receiver ); - // Check if the property is a generator. + // 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(); @@ -137,8 +143,9 @@ const handlers = { }; } - // Check if the property is a function. - // Actions always run in the current scope. + // 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 ); @@ -150,7 +157,7 @@ const handlers = { }; } - // Check if the property is an object. + // Check if the property is an object. If it is, proxyify it. if ( isObject( result ) ) return proxify( result, ns ); return result; @@ -209,20 +216,16 @@ const handlers = { * @param {StoreOptions} [options] Options passed to the `store` call. */ -type DeepPartial< T > = T extends object - ? { [ P in keyof T ]?: DeepPartial< T[ P ] > } - : T; - interface StoreOptions { lock?: boolean | string; } const universalUnlock = - 'I know using a private store means my plugin will inevitably break on the next store release.'; + '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?: DeepPartial< S >, + storePart?: S, options?: StoreOptions ): S; export function store< T extends object >( @@ -252,7 +255,7 @@ export function store( // 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 ( ! storeLocks.has( namespace ) && lock !== universalUnlock ) { + if ( lock !== universalUnlock && ! storeLocks.has( namespace ) ) { storeLocks.set( namespace, lock ); } else { const storeLock = storeLocks.get( namespace ); diff --git a/packages/interactivity/src/vdom.js b/packages/interactivity/src/vdom.js index b2d2132e72b78b..6b52b34510a983 100644 --- a/packages/interactivity/src/vdom.js +++ b/packages/interactivity/src/vdom.js @@ -26,11 +26,11 @@ const directiveParser = new RegExp( 'i' // Case insensitive. ); -const nsPathRegExp = /^([\w-]+)::(.+)$/; - -const nsValueParser = ( value ) => { - return nsPathRegExp.exec( value )?.slice( 1 ) ?? [ namespace, value ]; -}; +// 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 or underscores. +// References don't have any restrictions. +const nsPathRegExp = /^([\w-_]+)::(.+)$/; export const hydratedIslands = new WeakSet(); @@ -71,26 +71,27 @@ export function toVdom( root ) { ) { if ( n === ignoreAttr ) { ignore = true; - } else if ( n === islandAttr ) { - island = true; - try { - const val = JSON.parse( attributes[ i ].value ); - namespace = val?.namespace ?? null; - } catch ( e ) {} } else { - hasDirectives = true; - let [ ns, val ] = nsValueParser( attributes[ i ].value ); + let [ ns, value ] = nsPathRegExp + .exec( attributes[ i ].value ) + ?.slice( 1 ) ?? [ namespace, attributes[ i ].value ]; try { - val = JSON.parse( val ); + value = JSON.parse( value ); } catch ( e ) {} - const [ , prefix, suffix = 'default' ] = - directiveParser.exec( n ); - directives[ prefix ] = directives[ prefix ] || []; - directives[ prefix ].push( { - namespace: ns, - value: val, - suffix, - } ); + if ( n === islandAttr ) { + island = true; + namespace = value?.namespace ?? null; + } else { + hasDirectives = true; + const [ , prefix, suffix = 'default' ] = + directiveParser.exec( n ); + directives[ prefix ] = directives[ prefix ] || []; + directives[ prefix ].push( { + namespace: ns, + value, + suffix, + } ); + } } } else if ( n === 'ref' ) { continue; @@ -108,10 +109,7 @@ export function toVdom( root ) { ]; if ( island ) hydratedIslands.add( node ); - if ( hasDirectives ) { - props.__directives = directives; - directives.namespace = namespace; - } + if ( hasDirectives ) props.__directives = directives; let child = treeWalker.firstChild(); if ( child ) { From a5b3f95b1fbc4acd2051430823aefd8646f8fcd7 Mon Sep 17 00:00:00 2001 From: David Arenas Date: Tue, 17 Oct 2023 13:30:45 +0200 Subject: [PATCH 05/63] Replace wp-effect tests with wp-watch tests --- .../block.json | 6 ++-- .../render.php | 12 ++++---- .../view.js | 28 ++++++++++--------- ...effect.spec.ts => directive-watch.spec.ts} | 10 +++---- 4 files changed, 29 insertions(+), 27 deletions(-) rename packages/e2e-tests/plugins/interactive-blocks/{directive-effect => directive-watch}/block.json (59%) rename packages/e2e-tests/plugins/interactive-blocks/{directive-effect => directive-watch}/render.php (62%) rename packages/e2e-tests/plugins/interactive-blocks/{directive-effect => directive-watch}/view.js (66%) rename test/e2e/specs/interactivity/{directive-effect.spec.ts => directive-watch.spec.ts} (79%) diff --git a/packages/e2e-tests/plugins/interactive-blocks/directive-effect/block.json b/packages/e2e-tests/plugins/interactive-blocks/directive-watch/block.json similarity index 59% rename from packages/e2e-tests/plugins/interactive-blocks/directive-effect/block.json rename to packages/e2e-tests/plugins/interactive-blocks/directive-watch/block.json index b9cb2f782b2e6f..89285837ede3e4 100644 --- a/packages/e2e-tests/plugins/interactive-blocks/directive-effect/block.json +++ b/packages/e2e-tests/plugins/interactive-blocks/directive-watch/block.json @@ -1,7 +1,7 @@ { "apiVersion": 2, - "name": "test/directive-effect", - "title": "E2E Interactivity tests - directive effect", + "name": "test/directive-watch", + "title": "E2E Interactivity tests - directive watch", "category": "text", "icon": "heart", "description": "", @@ -9,6 +9,6 @@ "interactivity": true }, "textdomain": "e2e-interactivity", - "viewScript": "directive-effect-view", + "viewScript": "directive-watch-view", "render": "file:./render.php" } diff --git a/packages/e2e-tests/plugins/interactive-blocks/directive-effect/render.php b/packages/e2e-tests/plugins/interactive-blocks/directive-watch/render.php similarity index 62% rename from packages/e2e-tests/plugins/interactive-blocks/directive-effect/render.php rename to packages/e2e-tests/plugins/interactive-blocks/directive-watch/render.php index 3ae11975d329e5..acccdbb3db28dc 100644 --- a/packages/e2e-tests/plugins/interactive-blocks/directive-effect/render.php +++ b/packages/e2e-tests/plugins/interactive-blocks/directive-watch/render.php @@ -1,29 +1,29 @@ -
+
-
+
0 diff --git a/packages/e2e-tests/plugins/interactive-blocks/directive-effect/view.js b/packages/e2e-tests/plugins/interactive-blocks/directive-watch/view.js similarity index 66% rename from packages/e2e-tests/plugins/interactive-blocks/directive-effect/view.js rename to packages/e2e-tests/plugins/interactive-blocks/directive-watch/view.js index bd982775648845..fea8f60776a978 100644 --- a/packages/e2e-tests/plugins/interactive-blocks/directive-effect/view.js +++ b/packages/e2e-tests/plugins/interactive-blocks/directive-watch/view.js @@ -7,50 +7,52 @@ 'show-mock', ( { directives: { - "show-mock": { default: showMock }, + "show-mock": showMock, }, element, evaluate, } ) => { - if ( ! evaluate( showMock ) ) return null; + const entry = showMock.find( + ( { suffix } ) => suffix === 'default' + ); + if ( ! evaluate( entry ) ) return null; return element; } ); - store( { + const { state } = store( 'directive-watch', { state: { isOpen: true, isElementInTheDOM: false, counter: 0, - }, - selectors: { - elementInTheDOM: ( { state } ) => - state.isElementInTheDOM + get elementInTheDOM() { + return state.isElementInTheDOM ? 'element is in the DOM' - : 'element is not in the DOM', + : 'element is not in the DOM'; + } }, actions: { - toggle( { state } ) { + toggle() { state.isOpen = ! state.isOpen; }, - increment( { state } ) { + increment() { state.counter = state.counter + 1; }, }, effects: { - elementAddedToTheDOM: ( { state } ) => { + elementAddedToTheDOM: () => { state.isElementInTheDOM = true; return () => { state.isElementInTheDOM = false; }; }, - changeFocus: ( { state } ) => { + changeFocus: () => { if ( state.isOpen ) { document.querySelector( "[data-testid='input']" ).focus(); } }, - infiniteLoop: ({ state }) => { + infiniteLoop: () => { state.counter = state.counter + 1; } }, 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' ); From 3192bf76c903bcb90ebc2a232a54cc0e0538263e Mon Sep 17 00:00:00 2001 From: David Arenas Date: Tue, 17 Oct 2023 13:34:04 +0200 Subject: [PATCH 06/63] Update wp-body tests --- .../plugins/interactive-blocks/directive-body/render.php | 2 +- .../plugins/interactive-blocks/directive-body/view.js | 7 ++++--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/packages/e2e-tests/plugins/interactive-blocks/directive-body/render.php b/packages/e2e-tests/plugins/interactive-blocks/directive-body/render.php index 5e24b7d7a3b9b5..efca342b1babcc 100644 --- a/packages/e2e-tests/plugins/interactive-blocks/directive-body/render.php +++ b/packages/e2e-tests/plugins/interactive-blocks/directive-body/render.php @@ -7,7 +7,7 @@ ?>
diff --git a/packages/e2e-tests/plugins/interactive-blocks/directive-body/view.js b/packages/e2e-tests/plugins/interactive-blocks/directive-body/view.js index f3cbc521f4355b..764855643d6f6a 100644 --- a/packages/e2e-tests/plugins/interactive-blocks/directive-body/view.js +++ b/packages/e2e-tests/plugins/interactive-blocks/directive-body/view.js @@ -1,9 +1,10 @@ ( ( { wp } ) => { - const { store } = wp.interactivity; + const { store, getContext } = wp.interactivity; - store( { + store( 'directive-body', { actions: { - toggleText: ( { context } ) => { + toggleText: () => { + const context = getContext(); context.text = context.text === 'text-1' ? 'text-2' : 'text-1'; }, }, From bb63c8379deb7ee1028cfe36743bcf6c2c6c25a6 Mon Sep 17 00:00:00 2001 From: David Arenas Date: Tue, 17 Oct 2023 13:36:42 +0200 Subject: [PATCH 07/63] Update wp-class tests --- .../interactive-blocks/directive-class/render.php | 2 +- .../interactive-blocks/directive-class/view.js | 11 ++++++----- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/packages/e2e-tests/plugins/interactive-blocks/directive-class/render.php b/packages/e2e-tests/plugins/interactive-blocks/directive-class/render.php index b229418de2f67d..92e0c7c78a082f 100644 --- a/packages/e2e-tests/plugins/interactive-blocks/directive-class/render.php +++ b/packages/e2e-tests/plugins/interactive-blocks/directive-class/render.php @@ -6,7 +6,7 @@ */ ?> -
+
`; - store( { - derived: { - renderContext: ( { context } ) => { - return JSON.stringify( context, undefined, 2 ); - }, - }, + const { actions } = store( 'directive-context-navigate', { actions: { - updateContext: ( { context, event } ) => { - const { name, value } = event.target; - const [ key, ...path ] = name.split( '.' ).reverse(); - const obj = path.reduceRight( ( o, k ) => o[ k ], context ); - obj[ key ] = value; + toggleText() { + const ctx = getContext(); + ctx.text = "changed dynamically"; }, - toggleContextText: ( { context } ) => { - context.text = context.text === 'Text 1' ? 'Text 2' : 'Text 1'; + addNewText() { + const ctx = getContext(); + ctx.newText = 'some new text'; }, - toggleText: ( { context } ) => { - context.text = "changed dynamically"; - }, - addNewText: ( { context } ) => { - context.newText = 'some new text'; - }, - navigate: () => { - navigate( window.location, { + navigate() { + return navigate( window.location, { force: true, html, } ); }, - asyncNavigate: async ({ context }) => { - await navigate( window.location, { - force: true, - html, - } ); - context.newText = 'changed from async action'; + * asyncNavigate() { + yield actions.navigate(); + const ctx = getContext(); + ctx.newText = 'changed from async action'; } }, } ); From d9c153206544ccd7268a72bacb830e35e81cd9f0 Mon Sep 17 00:00:00 2001 From: David Arenas Date: Tue, 17 Oct 2023 15:18:57 +0200 Subject: [PATCH 09/63] Update wp-init tests --- .../directive-init/render.php | 12 ++--- .../interactive-blocks/directive-init/view.js | 51 ++++++++++--------- 2 files changed, 34 insertions(+), 29 deletions(-) diff --git a/packages/e2e-tests/plugins/interactive-blocks/directive-init/render.php b/packages/e2e-tests/plugins/interactive-blocks/directive-init/render.php index 76d5b776a68bb3..1d6774335442af 100644 --- a/packages/e2e-tests/plugins/interactive-blocks/directive-init/render.php +++ b/packages/e2e-tests/plugins/interactive-blocks/directive-init/render.php @@ -6,14 +6,14 @@ */ ?> -
+
-

false

-

0

+

false

+

0

-

false,false

-

0,0

+

false,false

+

0,0

toggle -

+

true

diff --git a/packages/e2e-tests/plugins/interactive-blocks/directive-init/view.js b/packages/e2e-tests/plugins/interactive-blocks/directive-init/view.js index 274809df3a9e5c..9900001a76b2a5 100644 --- a/packages/e2e-tests/plugins/interactive-blocks/directive-init/view.js +++ b/packages/e2e-tests/plugins/interactive-blocks/directive-init/view.js @@ -1,20 +1,17 @@ ( ( { wp } ) => { - const { store, directive, useContext } = wp.interactivity; + const { store, directive, getContext } = wp.interactivity; // Mock `data-wp-show` directive to test when things are removed from the // DOM. Replace with `data-wp-show` when it's ready. directive( 'show-mock', ( { - directives: { - 'show-mock': { default: showMock }, - }, + directives: { 'show-mock': showMock }, element, evaluate, - context, } ) => { - const contextValue = useContext( context ); - if ( ! evaluate( showMock, { context: contextValue } ) ) { + const entry = showMock.find( ( { suffix} ) => suffix === 'default'); + if ( ! evaluate( entry ) ) { return null; } return element; @@ -22,42 +19,50 @@ ); - store( { - selector: { - isReady: ({ context: { isReady } }) => { + store( 'directive-init', { + state: { + get isReady() { + const { isReady } = getContext(); return isReady - .map(v => v ? 'true': 'false') - .join(','); + .map(v => v ? 'true': 'false') + .join(','); }, - calls: ({ context: { calls } }) => { + get calls() { + const { calls } = getContext(); return calls.join(','); }, - isMounted: ({ context }) => { - return context.isMounted ? 'true' : 'false'; + get isMounted() { + const { isMounted } = getContext(); + return isMounted ? 'true' : 'false'; }, }, actions: { - initOne: ( { context: { isReady, calls } } ) => { + initOne() { + const { isReady, calls } = getContext(); isReady[0] = true; // Subscribe to changes in that prop. isReady[0] = isReady[0]; calls[0]++; }, - initTwo: ( { context: { isReady, calls } } ) => { + initTwo() { + const { isReady, calls } = getContext(); isReady[1] = true; calls[1]++; }, - initMount: ( { context } ) => { - context.isMounted = true; + initMount() { + const ctx = getContext(); + ctx.isMounted = true; return () => { - context.isMounted = false; + ctx.isMounted = false; } }, - reset: ( { context: { isReady } } ) => { + reset() { + const { isReady } = getContext(); isReady.fill(false); }, - toggle: ( { context } ) => { - context.isVisible = ! context.isVisible; + toggle() { + const ctx = getContext(); + ctx.isVisible = ! ctx.isVisible; }, }, } ); From da59bb84414a71bd4ebd9ba062cbdc0c932744dc Mon Sep 17 00:00:00 2001 From: David Arenas Date: Tue, 17 Oct 2023 16:12:01 +0200 Subject: [PATCH 10/63] Fix wp-key assignment --- packages/interactivity/src/hooks.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/interactivity/src/hooks.tsx b/packages/interactivity/src/hooks.tsx index c6255684ed39c3..f782d998498621 100644 --- a/packages/interactivity/src/hooks.tsx +++ b/packages/interactivity/src/hooks.tsx @@ -280,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 ) { From 74b665a049b6644ebfd453377270f0bf8045d3c4 Mon Sep 17 00:00:00 2001 From: David Arenas Date: Tue, 17 Oct 2023 16:12:13 +0200 Subject: [PATCH 11/63] Update wp-key tests --- .../plugins/interactive-blocks/directive-key/render.php | 5 ++++- .../plugins/interactive-blocks/directive-key/view.js | 9 ++++++--- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/packages/e2e-tests/plugins/interactive-blocks/directive-key/render.php b/packages/e2e-tests/plugins/interactive-blocks/directive-key/render.php index 07c6e4e3de161d..c163a0523420fd 100644 --- a/packages/e2e-tests/plugins/interactive-blocks/directive-key/render.php +++ b/packages/e2e-tests/plugins/interactive-blocks/directive-key/render.php @@ -7,7 +7,10 @@ ?> -
+
  • 2
  • 3
  • diff --git a/packages/e2e-tests/plugins/interactive-blocks/directive-key/view.js b/packages/e2e-tests/plugins/interactive-blocks/directive-key/view.js index a155dec99e0aa9..28a862eaef4ffa 100644 --- a/packages/e2e-tests/plugins/interactive-blocks/directive-key/view.js +++ b/packages/e2e-tests/plugins/interactive-blocks/directive-key/view.js @@ -2,7 +2,10 @@ const { store, navigate } = wp.interactivity; const html = ` -
    +
    • 1
    • 2
    • @@ -10,9 +13,9 @@
    `; - store( { + store( 'directive-key', { actions: { - navigate: () => { + navigate() { navigate( window.location, { force: true, html, From 3f6b0b833f8e7ed93c185b36409af3cb44441cbe Mon Sep 17 00:00:00 2001 From: David Arenas Date: Tue, 17 Oct 2023 16:15:15 +0200 Subject: [PATCH 12/63] Update wp-on tests --- .../interactive-blocks/directive-on/render.php | 2 +- .../interactive-blocks/directive-on/view.js | 14 ++++++++------ 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/packages/e2e-tests/plugins/interactive-blocks/directive-on/render.php b/packages/e2e-tests/plugins/interactive-blocks/directive-on/render.php index 9d96c7768a4894..3df028b9907d19 100644 --- a/packages/e2e-tests/plugins/interactive-blocks/directive-on/render.php +++ b/packages/e2e-tests/plugins/interactive-blocks/directive-on/render.php @@ -6,7 +6,7 @@ */ ?> -
    +

    0

    diff --git a/packages/e2e-tests/plugins/interactive-blocks/negation-operator/view.js b/packages/e2e-tests/plugins/interactive-blocks/negation-operator/view.js index 64a84269c356e3..58f6180cade69f 100644 --- a/packages/e2e-tests/plugins/interactive-blocks/negation-operator/view.js +++ b/packages/e2e-tests/plugins/interactive-blocks/negation-operator/view.js @@ -4,17 +4,15 @@ */ const { store } = wp.interactivity; - store( { - selectors: { - active: ( { state } ) => { - return state.active; - }, - }, + const { state } = store( 'negation-operator', { state: { active: false, + get isActive() { + return state.active; + }, }, actions: { - toggle: ( { state } ) => { + toggle() { state.active = ! state.active; }, }, From b067f8e836bb4467589c16b8adfe72ebe7aa9268 Mon Sep 17 00:00:00 2001 From: David Arenas Date: Tue, 17 Oct 2023 17:26:28 +0200 Subject: [PATCH 18/63] Update router navigate tests --- .../router-navigate/render.php | 17 ++++---- .../router-navigate/view.js | 40 +++++++++---------- 2 files changed, 28 insertions(+), 29 deletions(-) diff --git a/packages/e2e-tests/plugins/interactive-blocks/router-navigate/render.php b/packages/e2e-tests/plugins/interactive-blocks/router-navigate/render.php index 90246623ed997b..621d240064f0ab 100644 --- a/packages/e2e-tests/plugins/interactive-blocks/router-navigate/render.php +++ b/packages/e2e-tests/plugins/interactive-blocks/router-navigate/render.php @@ -9,23 +9,26 @@ ?> -
    +

    NaN undefined link $i
    link $i with hash diff --git a/packages/e2e-tests/plugins/interactive-blocks/router-navigate/view.js b/packages/e2e-tests/plugins/interactive-blocks/router-navigate/view.js index 422750eec366ef..9b17bbcff9f2ed 100644 --- a/packages/e2e-tests/plugins/interactive-blocks/router-navigate/view.js +++ b/packages/e2e-tests/plugins/interactive-blocks/router-navigate/view.js @@ -4,38 +4,34 @@ */ const { store, navigate } = wp.interactivity; - store( { + const { state } = store( 'router', { state: { - router: { - status: 'idle', - navigations: 0, - timeout: 10000, - } + status: 'idle', + navigations: 0, + timeout: 10000, }, actions: { - router: { - navigate: async ( { state, event: e } ) => { - e.preventDefault(); + * navigate( e ) { + e.preventDefault(); - state.router.navigations += 1; - state.router.status = 'busy'; + state.navigations += 1; + state.status = 'busy'; - const force = e.target.dataset.forceNavigation === 'true'; - const { timeout } = state.router; + const force = e.target.dataset.forceNavigation === 'true'; + const { timeout } = state; - await navigate( e.target.href, { force, timeout } ); + yield navigate( e.target.href, { force, timeout } ); - state.router.navigations -= 1; + state.navigations -= 1; - if ( state.router.navigations === 0) { - state.router.status = 'idle'; - } - }, - toggleTimeout: ( { state }) => { - state.router.timeout = - state.router.timeout === 10000 ? 0 : 10000; + if ( state.navigations === 0) { + state.status = 'idle'; } }, + toggleTimeout() { + state.timeout = + state.timeout === 10000 ? 0 : 10000; + } }, } ); } )( window ); From 7d8f5874c59a895a85af06f32b4e704cf0c6ef20 Mon Sep 17 00:00:00 2001 From: David Arenas Date: Tue, 17 Oct 2023 17:45:42 +0200 Subject: [PATCH 19/63] Refactor tests for router regions --- .../router-regions/render.php | 15 +++++++++++--- .../interactive-blocks/router-regions/view.js | 20 +++++++++++-------- 2 files changed, 24 insertions(+), 11 deletions(-) diff --git a/packages/e2e-tests/plugins/interactive-blocks/router-regions/render.php b/packages/e2e-tests/plugins/interactive-blocks/router-regions/render.php index db6e75709f9792..dc38107a12a4c1 100644 --- a/packages/e2e-tests/plugins/interactive-blocks/router-regions/render.php +++ b/packages/e2e-tests/plugins/interactive-blocks/router-regions/render.php @@ -10,7 +10,10 @@

    Region 1

    -
    +

    Region 2

    -
    +

    Nested region

    -
    +

    content from page

    diff --git a/packages/e2e-tests/plugins/interactive-blocks/router-regions/view.js b/packages/e2e-tests/plugins/interactive-blocks/router-regions/view.js index 296c77d3ee7b38..213c61a5a9174d 100644 --- a/packages/e2e-tests/plugins/interactive-blocks/router-regions/view.js +++ b/packages/e2e-tests/plugins/interactive-blocks/router-regions/view.js @@ -2,9 +2,9 @@ /** * WordPress dependencies */ - const { store, navigate } = wp.interactivity; + const { store, navigate, getContext } = wp.interactivity; - store( { + const { state } = store( 'router-regions', { state: { region1: { text: 'hydrated' @@ -18,21 +18,25 @@ }, actions: { router: { - navigate: async ( { event: e } ) => { + * navigate( e ) { e.preventDefault(); - await navigate( e.target.href ); + yield navigate( e.target.href ); + }, + back() { + history.back(); }, - back: () => history.back(), }, counter: { - increment: ( { state, context } ) => { - if ( context.counter ) { + increment() { + const context = getContext(); + if ( context?.counter ) { context.counter.value += 1; } else { state.counter.value += 1; } }, - init: ( { context } ) => { + init() { + const context = getContext(); if ( context.counter ) { context.counter.value = context.counter.initialValue; } From d93943a2f84b059a1e27b7db94d0e891ef85888a Mon Sep 17 00:00:00 2001 From: David Arenas Date: Tue, 17 Oct 2023 17:47:30 +0200 Subject: [PATCH 20/63] Removed afterLoad tests --- .../store-afterload/block.json | 14 ----- .../store-afterload/render.php | 41 ------------- .../store-afterload/view.js | 60 ------------------- .../interactivity/store-afterload.spec.ts | 40 ------------- 4 files changed, 155 deletions(-) delete mode 100644 packages/e2e-tests/plugins/interactive-blocks/store-afterload/block.json delete mode 100644 packages/e2e-tests/plugins/interactive-blocks/store-afterload/render.php delete mode 100644 packages/e2e-tests/plugins/interactive-blocks/store-afterload/view.js delete mode 100644 test/e2e/specs/interactivity/store-afterload.spec.ts diff --git a/packages/e2e-tests/plugins/interactive-blocks/store-afterload/block.json b/packages/e2e-tests/plugins/interactive-blocks/store-afterload/block.json deleted file mode 100644 index 0c00bbbb514be3..00000000000000 --- a/packages/e2e-tests/plugins/interactive-blocks/store-afterload/block.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "apiVersion": 2, - "name": "test/store-afterload", - "title": "E2E Interactivity tests - store afterload", - "category": "text", - "icon": "heart", - "description": "", - "supports": { - "interactivity": true - }, - "textdomain": "e2e-interactivity", - "viewScript": "store-afterload-view", - "render": "file:./render.php" -} diff --git a/packages/e2e-tests/plugins/interactive-blocks/store-afterload/render.php b/packages/e2e-tests/plugins/interactive-blocks/store-afterload/render.php deleted file mode 100644 index 950ba923428bf1..00000000000000 --- a/packages/e2e-tests/plugins/interactive-blocks/store-afterload/render.php +++ /dev/null @@ -1,41 +0,0 @@ - -
    -

    Store statuses

    -

    waiting

    -

    waiting

    -

    waiting

    -

    waiting

    - -

    afterLoad executions

    -

    All stores ready: - - >waiting -

    -

    vDOM ready: - - >waiting -

    -

    afterLoad exec times: - - >0 -

    -

    sharedAfterLoad exec times: - - >0 -

    -
    diff --git a/packages/e2e-tests/plugins/interactive-blocks/store-afterload/view.js b/packages/e2e-tests/plugins/interactive-blocks/store-afterload/view.js deleted file mode 100644 index 361a56dc622830..00000000000000 --- a/packages/e2e-tests/plugins/interactive-blocks/store-afterload/view.js +++ /dev/null @@ -1,60 +0,0 @@ -( ( { wp } ) => { - /** - * WordPress dependencies - */ - const { store } = wp.interactivity; - - const afterLoad = ({ state }) => { - // Check the state is correctly initialized. - const { status1, status2, status3, status4 } = state; - state.allStoresReady = - [ status1, status2, status3, status4 ] - .every( ( t ) => t === 'ready' ) - .toString(); - - // Check the HTML has been processed as well. - const selector = '[data-store-status]'; - state.vdomReady = - document.querySelector( selector ) && - Array.from( - document.querySelectorAll( selector ) - ).every( ( el ) => el.textContent === 'ready' ).toString(); - - // Increment exec times everytime this function runs. - state.execTimes.afterLoad += 1; - } - - const sharedAfterLoad = ({ state }) => { - // Increment exec times everytime this function runs. - state.execTimes.sharedAfterLoad += 1; - } - - // Case 1: without afterload callback - store( { - state: { status1: 'ready' }, - } ); - - // Case 2: non-shared afterload callback - store( { - state: { - status2: 'ready', - allStoresReady: false, - vdomReady: false, - execTimes: { afterLoad: 0 }, - }, - }, { afterLoad } ); - - // Case 3: shared afterload callback - store( { - state: { - status3: 'ready', - execTimes: { sharedAfterLoad: 0 }, - }, - }, { afterLoad: sharedAfterLoad } ); - store( { - state: { - status4: 'ready', - execTimes: { sharedAfterLoad: 0 }, - }, - }, { afterLoad: sharedAfterLoad } ); -} )( window ); 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' ); - } ); -} ); From 3a34ae175dc4fa8369351b068b9de76b9524489d Mon Sep 17 00:00:00 2001 From: David Arenas Date: Tue, 17 Oct 2023 18:03:11 +0200 Subject: [PATCH 21/63] Fix initial state tag id --- packages/interactivity/src/store.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/interactivity/src/store.ts b/packages/interactivity/src/store.ts index 4790b1309aed32..86be0605569765 100644 --- a/packages/interactivity/src/store.ts +++ b/packages/interactivity/src/store.ts @@ -36,7 +36,7 @@ const deepMerge = ( target: any, source: any ) => { const parseInitialState = () => { const storeTag = document.querySelector( - `script[type="application/json"]#wc-interactivity-initial-state` + `script[type="application/json"]#wp-interactivity-initial-state` ); if ( ! storeTag?.textContent ) return {}; try { From 4d2f9c8abac673e1fa13fb28d60e72b72b489b62 Mon Sep 17 00:00:00 2001 From: David Arenas Date: Tue, 17 Oct 2023 18:03:24 +0200 Subject: [PATCH 22/63] Update initial state tests --- .../plugins/interactive-blocks/store-tag/render.php | 8 ++++---- .../plugins/interactive-blocks/store-tag/view.js | 8 +++++--- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/packages/e2e-tests/plugins/interactive-blocks/store-tag/render.php b/packages/e2e-tests/plugins/interactive-blocks/store-tag/render.php index 9bc8126720b9b9..7bab5b3f5d58b5 100644 --- a/packages/e2e-tests/plugins/interactive-blocks/store-tag/render.php +++ b/packages/e2e-tests/plugins/interactive-blocks/store-tag/render.php @@ -10,7 +10,7 @@ $test_store_tag_counter = 'ok' === $attributes['condition'] ? 3 : 0; $test_store_tag_double = $test_store_tag_counter * 2; ?> -
    +
    Counter: + HTML; diff --git a/packages/e2e-tests/plugins/interactive-blocks/store-tag/view.js b/packages/e2e-tests/plugins/interactive-blocks/store-tag/view.js index 140cab6463137f..48cc9faf6f4dd7 100644 --- a/packages/e2e-tests/plugins/interactive-blocks/store-tag/view.js +++ b/packages/e2e-tests/plugins/interactive-blocks/store-tag/view.js @@ -4,17 +4,19 @@ */ const { store } = wp.interactivity; - store( { + const { state } = store( 'store-tag', { state: { counter: { // `value` is defined in the server. - double: ( { state } ) => state.counter.value * 2, + double() { + return state.counter.value * 2 + }, clicks: 0, }, }, actions: { counter: { - increment: ( { state } ) => { + increment() { state.counter.value += 1; state.counter.clicks += 1; }, From 6d35efb29f140e6ff38b6243e54f9c764b93cb64 Mon Sep 17 00:00:00 2001 From: David Arenas Date: Tue, 17 Oct 2023 18:10:15 +0200 Subject: [PATCH 23/63] Update tovdom-islands tests --- .../tovdom-islands/render.php | 15 +++++++++------ .../interactive-blocks/tovdom-islands/view.js | 19 +++++++++++-------- 2 files changed, 20 insertions(+), 14 deletions(-) diff --git a/packages/e2e-tests/plugins/interactive-blocks/tovdom-islands/render.php b/packages/e2e-tests/plugins/interactive-blocks/tovdom-islands/render.php index a3ebb7a87424e4..581ac2c2b2664e 100644 --- a/packages/e2e-tests/plugins/interactive-blocks/tovdom-islands/render.php +++ b/packages/e2e-tests/plugins/interactive-blocks/tovdom-islands/render.php @@ -13,7 +13,7 @@
    -
    +
    This should not be shown because it is inside an island. @@ -21,7 +21,7 @@
    -
    +
    -
    -
    +
    +
    -
    +
    -
    +
    { - const { store, directive, createElement } = wp.interactivity; + const { store, directive, createElement: h } = wp.interactivity; // Fake `data-wp-show-mock` directive to test when things are removed from the // DOM. Replace with `data-wp-show` when it's ready. - directive( +directive( 'show-mock', ( { - directives: { - "show-mock": { default: showMock }, - }, + directives: { 'show-mock': showMock }, element, evaluate, } ) => { - if ( ! evaluate( showMock ) ) + const entry = showMock.find( + ( { suffix} ) => suffix === 'default' + ); + + if ( ! evaluate( entry ) ) { element.props.children = - createElement( "template", null, element.props.children ); + h( "template", null, element.props.children ); + } } ); - store( { + store( 'tovdom-islands', { state: { falseValue: false, }, From 3c3d00e9aa0e91865f349ee7f8a433722a5ad1bb Mon Sep 17 00:00:00 2001 From: David Arenas Date: Tue, 17 Oct 2023 18:22:36 +0200 Subject: [PATCH 24/63] Update tovdom tests --- packages/e2e-tests/plugins/interactive-blocks/tovdom/render.php | 2 +- packages/e2e-tests/plugins/interactive-blocks/tovdom/view.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/e2e-tests/plugins/interactive-blocks/tovdom/render.php b/packages/e2e-tests/plugins/interactive-blocks/tovdom/render.php index 952a4f6c0a455d..06c5f404220b91 100644 --- a/packages/e2e-tests/plugins/interactive-blocks/tovdom/render.php +++ b/packages/e2e-tests/plugins/interactive-blocks/tovdom/render.php @@ -10,7 +10,7 @@ $src_cdata = $plugin_url . 'tovdom/cdata.js'; ?> -
    +
    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 ); From 519880f8cd06e527f6fd50a91725fa641e29f8ac Mon Sep 17 00:00:00 2001 From: David Arenas Date: Wed, 18 Oct 2023 12:25:28 +0200 Subject: [PATCH 25/63] Replace store with initial state in PHP --- .../class-wp-interactivity-initial-state.php | 64 +++++++++++++++++ .../class-wp-interactivity-store.php | 69 ------------------- .../interactivity-api/initial-state.php | 27 ++++++++ lib/experimental/interactivity-api/store.php | 28 -------- lib/load.php | 4 +- 5 files changed, 93 insertions(+), 99 deletions(-) create mode 100644 lib/experimental/interactivity-api/class-wp-interactivity-initial-state.php delete mode 100644 lib/experimental/interactivity-api/class-wp-interactivity-store.php create mode 100644 lib/experimental/interactivity-api/initial-state.php delete mode 100644 lib/experimental/interactivity-api/store.php diff --git a/lib/experimental/interactivity-api/class-wp-interactivity-initial-state.php b/lib/experimental/interactivity-api/class-wp-interactivity-initial-state.php new file mode 100644 index 00000000000000..a0c19d67e1e556 --- /dev/null +++ b/lib/experimental/interactivity-api/class-wp-interactivity-initial-state.php @@ -0,0 +1,64 @@ +%s', + wp_json_encode( self::$initial_state, JSON_HEX_TAG | JSON_HEX_AMP ) + ); + } +} diff --git a/lib/experimental/interactivity-api/class-wp-interactivity-store.php b/lib/experimental/interactivity-api/class-wp-interactivity-store.php deleted file mode 100644 index 89cb58700554a9..00000000000000 --- a/lib/experimental/interactivity-api/class-wp-interactivity-store.php +++ /dev/null @@ -1,69 +0,0 @@ -%s', - wp_json_encode( self::$store, JSON_HEX_TAG | JSON_HEX_AMP ) - ); - } -} diff --git a/lib/experimental/interactivity-api/initial-state.php b/lib/experimental/interactivity-api/initial-state.php new file mode 100644 index 00000000000000..4794d04702280e --- /dev/null +++ b/lib/experimental/interactivity-api/initial-state.php @@ -0,0 +1,27 @@ + Date: Wed, 18 Oct 2023 12:53:45 +0200 Subject: [PATCH 26/63] Fix wp prefixes in initial state --- .../class-wp-interactivity-initial-state.php | 2 +- lib/experimental/interactivity-api/initial-state.php | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/lib/experimental/interactivity-api/class-wp-interactivity-initial-state.php b/lib/experimental/interactivity-api/class-wp-interactivity-initial-state.php index a0c19d67e1e556..f63fa0e1750c19 100644 --- a/lib/experimental/interactivity-api/class-wp-interactivity-initial-state.php +++ b/lib/experimental/interactivity-api/class-wp-interactivity-initial-state.php @@ -57,7 +57,7 @@ public static function render() { return; } echo sprintf( - '', + '', wp_json_encode( self::$initial_state, JSON_HEX_TAG | JSON_HEX_AMP ) ); } diff --git a/lib/experimental/interactivity-api/initial-state.php b/lib/experimental/interactivity-api/initial-state.php index 4794d04702280e..e548af07e4c169 100644 --- a/lib/experimental/interactivity-api/initial-state.php +++ b/lib/experimental/interactivity-api/initial-state.php @@ -14,14 +14,14 @@ * * @return array The current state for the given namespace. */ -function wc_initial_state( $namespace, $data = null ) { +function wp_initial_state( $namespace, $data = null ) { if ( $data ) { - WC_Interactivity_Initial_State::merge_state( $namespace, $data ); + WP_Interactivity_Initial_State::merge_state( $namespace, $data ); } - return WC_Interactivity_Initial_State::get_state( $namespace ); + return WP_Interactivity_Initial_State::get_state( $namespace ); } /** * Render the Interactivity API initial state in the frontend. */ -add_action( 'wp_footer', array( 'WC_Interactivity_Initial_State', 'render' ), 8 ); +add_action( 'wp_footer', array( 'WP_Interactivity_Initial_State', 'render' ), 8 ); From c1bbf9fdbc4b8c594b61c4c12e2e8138549ead9d Mon Sep 17 00:00:00 2001 From: Michal Czaplinski Date: Thu, 19 Oct 2023 12:22:27 +0100 Subject: [PATCH 27/63] Add types declaration to package.json so that TS works --- packages/interactivity/package.json | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/interactivity/package.json b/packages/interactivity/package.json index ca1b21eaf5a94f..4ad901bdc316ba 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", From 51bf723490556445073f0874939a4e88429c9dff Mon Sep 17 00:00:00 2001 From: Mario Santos Date: Thu, 19 Oct 2023 19:23:41 +0200 Subject: [PATCH 28/63] Add `wp-each` directive in the new store API --- packages/interactivity/src/directives.js | 44 ++++++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/packages/interactivity/src/directives.js b/packages/interactivity/src/directives.js index 415138011a1ede..55b306fc92bb69 100644 --- a/packages/interactivity/src/directives.js +++ b/packages/interactivity/src/directives.js @@ -400,4 +400,48 @@ export default () => { ), { priority: 4 } ); + + // data-wp-each + directive( + 'each', + ( { + directives: { each, 'each-key': key }, + context: inheritedContext, + element, + evaluate, + } ) => { + const { Provider } = inheritedContext; + const inheritedValue = useContext( inheritedContext ); + + const entry = each + .filter( ( n ) => n !== 'default' ) + .find( ( n ) => n ); + + const list = evaluate( entry ); + + if ( ! list.length ) return null; + + return list.map( ( item ) => { + const currentValue = useRef( deepSignal( {} ) ); + + currentValue.current = useMemo( () => { + const newValue = deepSignal( { + [ entry.namespace ]: { [ entry.suffix ]: item }, + } ); + mergeDeepSignals( newValue, inheritedValue ); + mergeDeepSignals( currentValue.current, newValue, true ); + return currentValue.current; + }, [ inheritedValue, entry ] ); + + return ( + + { element.props.children[ 1 ] } + + ); + } ); + } + ); }; From 933c4aca997926cf01cbace7ba864b2e554be9ec Mon Sep 17 00:00:00 2001 From: Mario Santos Date: Fri, 20 Oct 2023 12:37:10 +0200 Subject: [PATCH 29/63] Add workaround for store API SDR --- .../class-wp-interactivity-initial-state.php | 14 +++++++++++++- .../interactivity-api/directive-processing.php | 2 +- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/lib/experimental/interactivity-api/class-wp-interactivity-initial-state.php b/lib/experimental/interactivity-api/class-wp-interactivity-initial-state.php index f63fa0e1750c19..ef7e7509d5faa4 100644 --- a/lib/experimental/interactivity-api/class-wp-interactivity-initial-state.php +++ b/lib/experimental/interactivity-api/class-wp-interactivity-initial-state.php @@ -23,7 +23,10 @@ class WP_Interactivity_Initial_State { * * @return array The requested state. */ - public static function get_state( $namespace ) { + public static function get_state( $namespace = null ) { + if ( ! $namespace ) { + return self::$initial_state; + } return self::$initial_state[ $namespace ] ?? array(); } @@ -42,6 +45,15 @@ public static function merge_state( $namespace, $data ) { ); } + /** + * Get store data. + * + * @return array + */ + public static function get_data() { + return self::$initial_state; + } + /** * Reset the initial state. */ diff --git a/lib/experimental/interactivity-api/directive-processing.php b/lib/experimental/interactivity-api/directive-processing.php index 41223c08158869..343e4bcdab1cae 100644 --- a/lib/experimental/interactivity-api/directive-processing.php +++ b/lib/experimental/interactivity-api/directive-processing.php @@ -131,7 +131,7 @@ function gutenberg_interactivity_process_directives( $tags, $prefix, $directives */ function gutenberg_interactivity_evaluate_reference( $path, array $context = array() ) { $store = array_merge( - WP_Interactivity_Store::get_data(), + WP_Interactivity_Initial_State::get_state(), array( 'context' => $context ) ); From 05432dd7c26f4c282b8750141a86a4b154704972 Mon Sep 17 00:00:00 2001 From: David Arenas Date: Tue, 24 Oct 2023 14:40:36 +0200 Subject: [PATCH 30/63] Modify children instead of replacing the element --- packages/interactivity/src/directives.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/interactivity/src/directives.js b/packages/interactivity/src/directives.js index 55b306fc92bb69..60b844d67777a9 100644 --- a/packages/interactivity/src/directives.js +++ b/packages/interactivity/src/directives.js @@ -421,7 +421,7 @@ export default () => { if ( ! list.length ) return null; - return list.map( ( item ) => { + element.props.children = list.map( ( item ) => { const currentValue = useRef( deepSignal( {} ) ); currentValue.current = useMemo( () => { From 7f517e01abf0fea3fa4807241e15ef61630e09ce Mon Sep 17 00:00:00 2001 From: David Arenas Date: Thu, 9 Nov 2023 17:35:18 +0100 Subject: [PATCH 31/63] Migrate File block to the new store API --- packages/block-library/src/file/index.php | 4 ++-- packages/block-library/src/file/view.js | 10 ++++------ 2 files changed, 6 insertions(+), 8 deletions(-) diff --git a/packages/block-library/src/file/index.php b/packages/block-library/src/file/index.php index 83c6dab20157df..907530e75253c0 100644 --- a/packages/block-library/src/file/index.php +++ b/packages/block-library/src/file/index.php @@ -57,9 +57,9 @@ static function ( $matches ) { if ( $should_load_view_script ) { $processor = new WP_HTML_Tag_Processor( $content ); $processor->next_tag(); - $processor->set_attribute( 'data-wp-interactive', '' ); + $processor->set_attribute( 'data-wp-interactive', '{"namespace":"core"}' ); $processor->next_tag( 'object' ); - $processor->set_attribute( 'data-wp-style--display', 'selectors.core.file.hasPdfPreview' ); + $processor->set_attribute( 'data-wp-style--display', 'state.file.hasPdfPreview' ); return $processor->get_updated_html(); } diff --git a/packages/block-library/src/file/view.js b/packages/block-library/src/file/view.js index 51c726d0cbe5ec..d7fccc01d14dd8 100644 --- a/packages/block-library/src/file/view.js +++ b/packages/block-library/src/file/view.js @@ -7,12 +7,10 @@ import { store } from '@wordpress/interactivity'; */ import { browserSupportsPdfs } from './utils'; -store( { - selectors: { - core: { - file: { - hasPdfPreview: browserSupportsPdfs() ? 'inherit' : 'none', - }, +store( 'core', { + state: { + get hasPdfPreview() { + return browserSupportsPdfs() ? 'inherit' : 'none'; }, }, } ); From 261d9ba8af95f57a4edffdecd8ffc6b5b6b02b04 Mon Sep 17 00:00:00 2001 From: David Arenas Date: Thu, 9 Nov 2023 18:02:43 +0100 Subject: [PATCH 32/63] Refactor Image block to use the new store API --- packages/block-library/src/image/index.php | 38 +- packages/block-library/src/image/view.js | 532 ++++++++++----------- 2 files changed, 263 insertions(+), 307 deletions(-) diff --git a/packages/block-library/src/image/index.php b/packages/block-library/src/image/index.php index 64b7457dd863d3..18caabf6af160c 100644 --- a/packages/block-library/src/image/index.php +++ b/packages/block-library/src/image/index.php @@ -187,7 +187,7 @@ function block_core_image_render_lightbox( $block_content, $block ) { $w = new WP_HTML_Tag_Processor( $block_content ); $w->next_tag( 'figure' ); $w->add_class( 'wp-lightbox-container' ); - $w->set_attribute( 'data-wp-interactive', true ); + $w->set_attribute( 'data-wp-interactive', '{"namespace":"core"}' ); $w->set_attribute( 'data-wp-context', @@ -218,10 +218,10 @@ function block_core_image_render_lightbox( $block_content, $block ) { ) ); $w->next_tag( 'img' ); - $w->set_attribute( 'data-wp-init', 'effects.core.image.setCurrentSrc' ); - $w->set_attribute( 'data-wp-on--load', 'actions.core.image.handleLoad' ); - $w->set_attribute( 'data-wp-effect', 'effects.core.image.setButtonStyles' ); - $w->set_attribute( 'data-wp-effect--setStylesOnResize', 'effects.core.image.setStylesOnResize' ); + $w->set_attribute( 'data-wp-init', 'effects.image.setCurrentSrc' ); + $w->set_attribute( 'data-wp-on--load', 'actions.image.handleLoad' ); + $w->set_attribute( 'data-wp-effect', 'effects.image.setButtonStyles' ); + $w->set_attribute( 'data-wp-effect--setStylesOnResize', 'effects.image.setStylesOnResize' ); $body_content = $w->get_updated_html(); // Wrap the image in the body content with a button. @@ -234,7 +234,7 @@ function block_core_image_render_lightbox( $block_content, $block ) { type="button" aria-haspopup="dialog" aria-label="' . esc_attr( $aria_label ) . '" - data-wp-on--click="actions.core.image.showLightbox" + data-wp-on--click="actions.image.showLightbox" data-wp-style--width="context.core.image.imageButtonWidth" data-wp-style--height="context.core.image.imageButtonHeight" data-wp-style--left="context.core.image.imageButtonLeft" @@ -261,7 +261,7 @@ function block_core_image_render_lightbox( $block_content, $block ) { // we wait for the larger image to load. $m->set_attribute( 'src', '' ); $m->set_attribute( 'data-wp-bind--src', 'context.core.image.imageCurrentSrc' ); - $m->set_attribute( 'data-wp-style--object-fit', 'selectors.core.image.lightboxObjectFit' ); + $m->set_attribute( 'data-wp-style--object-fit', 'state.image.lightboxObjectFit' ); $initial_image_content = $m->get_updated_html(); $q = new WP_HTML_Tag_Processor( $block_content ); @@ -276,8 +276,8 @@ function block_core_image_render_lightbox( $block_content, $block ) { // and Chrome (see https://github.com/WordPress/gutenberg/pull/52765#issuecomment-1674008151). Until that // is resolved, manually setting the 'src' seems to be the best solution to load the large image on demand. $q->set_attribute( 'src', '' ); - $q->set_attribute( 'data-wp-bind--src', 'selectors.core.image.enlargedImgSrc' ); - $q->set_attribute( 'data-wp-style--object-fit', 'selectors.core.image.lightboxObjectFit' ); + $q->set_attribute( 'data-wp-bind--src', 'state.image.enlargedImgSrc' ); + $q->set_attribute( 'data-wp-style--object-fit', 'state.image.lightboxObjectFit' ); $enlarged_image_content = $q->get_updated_html(); // If the current theme does NOT have a `theme.json`, or the colors are not defined, @@ -300,20 +300,20 @@ function block_core_image_render_lightbox( $block_content, $block ) { $lightbox_html = << - diff --git a/packages/block-library/src/image/view.js b/packages/block-library/src/image/view.js index 3f2242ad737f02..58a061e64ca2db 100644 --- a/packages/block-library/src/image/view.js +++ b/packages/block-library/src/image/view.js @@ -1,7 +1,7 @@ /** * WordPress dependencies */ -import { store } from '@wordpress/interactivity'; +import { store, getContext, getElement } from '@wordpress/interactivity'; const focusableSelectors = [ 'a[href]', @@ -66,315 +66,275 @@ let lastTouchTime = 0; * * @param {Object} context Interactivity page context? */ -function handleScroll( context ) { +function handleScroll( image ) { // We can't override the scroll behavior on mobile devices // because doing so breaks the pinch to zoom functionality, and we // want to allow users to zoom in further on the high-res image. if ( ! isTouching && Date.now() - lastTouchTime > 450 ) { // We are unable to use event.preventDefault() to prevent scrolling // because the scroll event can't be canceled, so we reset the position instead. - window.scrollTo( - context.core.image.scrollLeftReset, - context.core.image.scrollTopReset - ); + window.scrollTo( image.scrollLeftReset, image.scrollTopReset ); } } -store( - { - state: { - core: { - image: { - windowWidth: window.innerWidth, - windowHeight: window.innerHeight, - }, +const { state, actions, effects } = store( { + state: { + image: { + windowWidth: window.innerWidth, + windowHeight: window.innerHeight, + get roleAttribute() { + const { image } = getContext(); + return image.lightboxEnabled ? 'dialog' : null; }, - }, - actions: { - core: { - image: { - showLightbox: ( { context, event } ) => { - // We can't initialize the lightbox until the reference - // image is loaded, otherwise the UX is broken. - if ( ! context.core.image.imageLoaded ) { - return; - } - context.core.image.initialized = true; - context.core.image.lastFocusedElement = - window.document.activeElement; - context.core.image.scrollDelta = 0; - - context.core.image.lightboxEnabled = true; - setStyles( - context, - event.target.previousElementSibling - ); - - context.core.image.scrollTopReset = - window.pageYOffset || - document.documentElement.scrollTop; - - // In most cases, this value will be 0, but this is included - // in case a user has created a page with horizontal scrolling. - context.core.image.scrollLeftReset = - window.pageXOffset || - document.documentElement.scrollLeft; - - // We define and bind the scroll callback here so - // that we can pass the context and as an argument. - // We may be able to change this in the future if we - // define the scroll callback in the store instead, but - // this approach seems to tbe clearest for now. - scrollCallback = handleScroll.bind( null, context ); - - // We need to add a scroll event listener to the window - // here because we are unable to otherwise access it via - // the Interactivity API directives. If we add a native way - // to access the window, we can remove this. - window.addEventListener( - 'scroll', - scrollCallback, - false - ); - }, - hideLightbox: async ( { context } ) => { - context.core.image.hideAnimationEnabled = true; - if ( context.core.image.lightboxEnabled ) { - // We want to wait until the close animation is completed - // before allowing a user to scroll again. The duration of this - // animation is defined in the styles.scss and depends on if the - // animation is 'zoom' or 'fade', but in any case we should wait - // a few milliseconds longer than the duration, otherwise a user - // may scroll too soon and cause the animation to look sloppy. - setTimeout( function () { - window.removeEventListener( - 'scroll', - scrollCallback - ); - }, 450 ); - - context.core.image.lightboxEnabled = false; - context.core.image.lastFocusedElement.focus( { - preventScroll: true, - } ); - } - }, - handleKeydown: ( { context, actions, event } ) => { - if ( context.core.image.lightboxEnabled ) { - if ( event.key === 'Tab' || event.keyCode === 9 ) { - // If shift + tab it change the direction - if ( - event.shiftKey && - window.document.activeElement === - context.core.image.firstFocusableElement - ) { - event.preventDefault(); - context.core.image.lastFocusableElement.focus(); - } else if ( - ! event.shiftKey && - window.document.activeElement === - context.core.image.lastFocusableElement - ) { - event.preventDefault(); - context.core.image.firstFocusableElement.focus(); - } - } - - if ( - event.key === 'Escape' || - event.keyCode === 27 - ) { - actions.core.image.hideLightbox( { - context, - event, - } ); - } - } - }, - handleLoad: ( { state, context, effects, ref } ) => { - context.core.image.imageLoaded = true; - context.core.image.imageCurrentSrc = ref.currentSrc; - effects.core.image.setButtonStyles( { - state, - context, - ref, - } ); - }, - handleTouchStart: () => { - isTouching = true; - }, - handleTouchMove: ( { context, event } ) => { - // On mobile devices, we want to prevent triggering the - // scroll event because otherwise the page jumps around as - // we reset the scroll position. This also means that closing - // the lightbox requires that a user perform a simple tap. This - // may be changed in the future if we find a better alternative - // to override or reset the scroll position during swipe actions. - if ( context.core.image.lightboxEnabled ) { - event.preventDefault(); - } - }, - handleTouchEnd: () => { - // We need to wait a few milliseconds before resetting - // to ensure that pinch to zoom works consistently - // on mobile devices when the lightbox is open. - lastTouchTime = Date.now(); - isTouching = false; - }, - }, + get ariaModal() { + const { image } = getContext(); + return image.lightboxEnabled ? 'true' : null; }, - }, - selectors: { - core: { - image: { - roleAttribute: ( { context } ) => { - return context.core.image.lightboxEnabled - ? 'dialog' - : null; - }, - ariaModal: ( { context } ) => { - return context.core.image.lightboxEnabled - ? 'true' - : null; - }, - dialogLabel: ( { context } ) => { - return context.core.image.lightboxEnabled - ? context.core.image.dialogLabel - : null; - }, - lightboxObjectFit: ( { context } ) => { - if ( context.core.image.initialized ) { - return 'cover'; - } - }, - enlargedImgSrc: ( { context } ) => { - return context.core.image.initialized - ? context.core.image.imageUploadedSrc - : 'data:image/gif;base64,R0lGODlhAQABAAD/ACwAAAAAAQABAAACADs='; - }, - }, + get dialogLabel() { + const { image } = getContext(); + return image.lightboxEnabled ? image.dialogLabel : null; + }, + get lightboxObjectFit() { + const { image } = getContext(); + if ( image.initialized ) { + return 'cover'; + } + }, + get enlargedImgSrc() { + const { image } = getContext(); + return image.initialized + ? image.imageUploadedSrc + : 'data:image/gif;base64,R0lGODlhAQABAAD/ACwAAAAAAQABAAACADs='; }, }, - effects: { - core: { - image: { - setCurrentSrc: ( { context, ref } ) => { - if ( ref.complete ) { - context.core.image.imageLoaded = true; - context.core.image.imageCurrentSrc = ref.currentSrc; - } - }, - initLightbox: async ( { context, ref } ) => { - context.core.image.figureRef = - ref.querySelector( 'figure' ); - context.core.image.imageRef = - ref.querySelector( 'img' ); - if ( context.core.image.lightboxEnabled ) { - const focusableElements = - ref.querySelectorAll( focusableSelectors ); - context.core.image.firstFocusableElement = - focusableElements[ 0 ]; - context.core.image.lastFocusableElement = - focusableElements[ - focusableElements.length - 1 - ]; - - ref.querySelector( '.close-button' ).focus(); - } - }, - setButtonStyles: ( { state, context, ref } ) => { - const { - naturalWidth, - naturalHeight, - offsetWidth, - offsetHeight, - } = ref; - - // If the image isn't loaded yet, we can't - // calculate how big the button should be. - if ( naturalWidth === 0 || naturalHeight === 0 ) { - return; - } - - // Subscribe to the window dimensions so we can - // recalculate the styles if the window is resized. + }, + actions: { + image: { + showLightbox( event ) { + // We can't initialize the lightbox until the reference + // image is loaded, otherwise the UX is broken. + const { image } = getContext(); + if ( ! image.imageLoaded ) { + return; + } + image.initialized = true; + image.lastFocusedElement = window.document.activeElement; + image.scrollDelta = 0; + + image.lightboxEnabled = true; + setStyles( image, event.target.previousElementSibling ); + + image.scrollTopReset = + window.pageYOffset || document.documentElement.scrollTop; + + // In most cases, this value will be 0, but this is included + // in case a user has created a page with horizontal scrolling. + image.scrollLeftReset = + window.pageXOffset || document.documentElement.scrollLeft; + + // We define and bind the scroll callback here so + // that we can pass the context and as an argument. + // We may be able to change this in the future if we + // define the scroll callback in the store instead, but + // this approach seems to tbe clearest for now. + scrollCallback = handleScroll.bind( null, image ); + + // We need to add a scroll event listener to the window + // here because we are unable to otherwise access it via + // the Interactivity API directives. If we add a native way + // to access the window, we can remove this. + window.addEventListener( 'scroll', scrollCallback, false ); + }, + hideLightbox() { + const { image } = getContext(); + image.hideAnimationEnabled = true; + if ( image.lightboxEnabled ) { + // We want to wait until the close animation is completed + // before allowing a user to scroll again. The duration of this + // animation is defined in the styles.scss and depends on if the + // animation is 'zoom' or 'fade', but in any case we should wait + // a few milliseconds longer than the duration, otherwise a user + // may scroll too soon and cause the animation to look sloppy. + setTimeout( function () { + window.removeEventListener( 'scroll', scrollCallback ); + }, 450 ); + + image.lightboxEnabled = false; + image.lastFocusedElement.focus( { + preventScroll: true, + } ); + } + }, + handleKeydown( event ) { + const { image } = getContext(); + if ( image.lightboxEnabled ) { + if ( event.key === 'Tab' || event.keyCode === 9 ) { + // If shift + tab it change the direction if ( - ( state.core.image.windowWidth || - state.core.image.windowHeight ) && - context.core.image.scaleAttr === 'contain' + event.shiftKey && + window.document.activeElement === + image.firstFocusableElement ) { - // In the case of an image with object-fit: contain, the - // size of the img element can be larger than the image itself, - // so we need to calculate the size of the button to match. - - // Natural ratio of the image. - const naturalRatio = naturalWidth / naturalHeight; - // Offset ratio of the image. - const offsetRatio = offsetWidth / offsetHeight; - - if ( naturalRatio > offsetRatio ) { - // If it reaches the width first, keep - // the width and recalculate the height. - context.core.image.imageButtonWidth = - offsetWidth; - const buttonHeight = offsetWidth / naturalRatio; - context.core.image.imageButtonHeight = - buttonHeight; - context.core.image.imageButtonTop = - ( offsetHeight - buttonHeight ) / 2; - } else { - // If it reaches the height first, keep - // the height and recalculate the width. - context.core.image.imageButtonHeight = - offsetHeight; - const buttonWidth = offsetHeight * naturalRatio; - context.core.image.imageButtonWidth = - buttonWidth; - context.core.image.imageButtonLeft = - ( offsetWidth - buttonWidth ) / 2; - } - } else { - // In all other cases, we can trust that the size of - // the image is the right size for the button as well. - - context.core.image.imageButtonWidth = offsetWidth; - context.core.image.imageButtonHeight = offsetHeight; - } - }, - setStylesOnResize: ( { state, context, ref } ) => { - if ( - context.core.image.lightboxEnabled && - ( state.core.image.windowWidth || - state.core.image.windowHeight ) + event.preventDefault(); + image.lastFocusableElement.focus(); + } else if ( + ! event.shiftKey && + window.document.activeElement === + image.lastFocusableElement ) { - setStyles( context, ref ); + event.preventDefault(); + image.firstFocusableElement.focus(); } - }, - }, + } + + if ( event.key === 'Escape' || event.keyCode === 27 ) { + actions.image.hideLightbox(); + } + } + }, + handleLoad() { + const { image } = getContext(); + const { ref } = getElement(); + image.imageLoaded = true; + image.imageCurrentSrc = ref.currentSrc; + effects.image.setButtonStyles(); + }, + handleTouchStart() { + isTouching = true; + }, + handleTouchMove( event ) { + const { image } = getContext(); + // On mobile devices, we want to prevent triggering the + // scroll event because otherwise the page jumps around as + // we reset the scroll position. This also means that closing + // the lightbox requires that a user perform a simple tap. This + // may be changed in the future if we find a better alternative + // to override or reset the scroll position during swipe actions. + if ( image.lightboxEnabled ) { + event.preventDefault(); + } + }, + handleTouchEnd: () => { + // We need to wait a few milliseconds before resetting + // to ensure that pinch to zoom works consistently + // on mobile devices when the lightbox is open. + lastTouchTime = Date.now(); + isTouching = false; }, }, }, - { - afterLoad: ( { state } ) => { - window.addEventListener( - 'resize', - debounce( () => { - state.core.image.windowWidth = window.innerWidth; - state.core.image.windowHeight = window.innerHeight; - } ) - ); + effects: { + image: { + setCurrentSrc() { + const { image } = getContext(); + const { ref } = getElement(); + if ( ref.complete ) { + image.imageLoaded = true; + image.imageCurrentSrc = ref.currentSrc; + } + }, + initLightbox() { + const { image } = getContext(); + const { ref } = getElement(); + image.figureRef = ref.querySelector( 'figure' ); + image.imageRef = ref.querySelector( 'img' ); + if ( image.lightboxEnabled ) { + const focusableElements = + ref.querySelectorAll( focusableSelectors ); + image.firstFocusableElement = focusableElements[ 0 ]; + image.lastFocusableElement = + focusableElements[ focusableElements.length - 1 ]; + + ref.querySelector( '.close-button' ).focus(); + } + }, + setButtonStyles() { + const { ref } = getElement(); + const { + naturalWidth, + naturalHeight, + offsetWidth, + offsetHeight, + } = ref; + + // If the image isn't loaded yet, we can't + // calculate how big the button should be. + if ( naturalWidth === 0 || naturalHeight === 0 ) { + return; + } + + const { image } = getContext(); + + // Subscribe to the window dimensions so we can + // recalculate the styles if the window is resized. + if ( + ( state.image.windowWidth || state.image.windowHeight ) && + image.scaleAttr === 'contain' + ) { + // In the case of an image with object-fit: contain, the + // size of the img element can be larger than the image itself, + // so we need to calculate the size of the button to match. + + // Natural ratio of the image. + const naturalRatio = naturalWidth / naturalHeight; + // Offset ratio of the image. + const offsetRatio = offsetWidth / offsetHeight; + + if ( naturalRatio > offsetRatio ) { + // If it reaches the width first, keep + // the width and recalculate the height. + image.imageButtonWidth = offsetWidth; + const buttonHeight = offsetWidth / naturalRatio; + image.imageButtonHeight = buttonHeight; + image.imageButtonTop = + ( offsetHeight - buttonHeight ) / 2; + } else { + // If it reaches the height first, keep + // the height and recalculate the width. + image.imageButtonHeight = offsetHeight; + const buttonWidth = offsetHeight * naturalRatio; + image.imageButtonWidth = buttonWidth; + image.imageButtonLeft = + ( offsetWidth - buttonWidth ) / 2; + } + } else { + // In all other cases, we can trust that the size of + // the image is the right size for the button as well. + + image.imageButtonWidth = offsetWidth; + image.imageButtonHeight = offsetHeight; + } + }, + setStylesOnResize() { + const { image } = getContext(); + const { ref } = getElement(); + if ( + image.lightboxEnabled && + ( state.image.windowWidth || state.image.windowHeight ) + ) { + setStyles( image, ref ); + } + }, }, - } + }, +} ); + +window.addEventListener( + 'resize', + debounce( () => { + state.image.windowWidth = window.innerWidth; + state.image.windowHeight = window.innerHeight; + } ) ); /* * Computes styles for the lightbox and adds them to the document. * * @function - * @param {Object} context - An Interactivity API context + * @param {Object} image - The image object inside the Interactivity API context. * @param {Object} event - A triggering event */ -function setStyles( context, ref ) { +function setStyles( image, ref ) { // The reference img element lies adjacent // to the event target button in the DOM. let { @@ -392,7 +352,7 @@ function setStyles( context, ref ) { // If it has object-fit: contain, recalculate the original sizes // and the screen position without the blank spaces. - if ( context.core.image.scaleAttr === 'contain' ) { + if ( image.scaleAttr === 'contain' ) { if ( naturalRatio > originalRatio ) { const heightWithoutSpace = originalWidth / naturalRatio; // Recalculate screen position without the top space. @@ -412,14 +372,10 @@ function setStyles( context, ref ) { // the image's dimensions in the lightbox are the same // as those of the image in the content. let imgMaxWidth = parseFloat( - context.core.image.targetWidth !== 'none' - ? context.core.image.targetWidth - : naturalWidth + image.targetWidth !== 'none' ? image.targetWidth : naturalWidth ); let imgMaxHeight = parseFloat( - context.core.image.targetHeight !== 'none' - ? context.core.image.targetHeight - : naturalHeight + image.targetHeight !== 'none' ? image.targetHeight : naturalHeight ); // Ratio of the biggest image stored in the database. From 905fe3c25ded451d14af5a859f0e71fe748da554 Mon Sep 17 00:00:00 2001 From: David Arenas Date: Tue, 14 Nov 2023 12:26:08 +0100 Subject: [PATCH 33/63] Update runtime --- packages/interactivity/src/store.ts | 13 +++++++------ packages/interactivity/src/vdom.js | 30 +++++++++++++++++------------ 2 files changed, 25 insertions(+), 18 deletions(-) diff --git a/packages/interactivity/src/store.ts b/packages/interactivity/src/store.ts index 86be0605569765..1e9ab7e1a8f46b 100644 --- a/packages/interactivity/src/store.ts +++ b/packages/interactivity/src/store.ts @@ -51,6 +51,7 @@ const parseInitialState = () => { }; export const stores = new Map(); +const rawStores = new Map(); const storeLocks = new Map(); const objToProxy = new WeakMap(); @@ -246,11 +247,11 @@ export function store( if ( lock !== universalUnlock ) { storeLocks.set( namespace, lock ); } - stores.set( - namespace, - new Proxy( { state: deepSignal( state ), ...block }, handlers ) - ); - proxyToNs.set( stores.get( namespace ), namespace ); + 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 @@ -274,7 +275,7 @@ export function store( } } - const target = stores.get( namespace ); + const target = rawStores.get( namespace ); deepMerge( target, block ); deepMerge( target.state, state ); } diff --git a/packages/interactivity/src/vdom.js b/packages/interactivity/src/vdom.js index 6b52b34510a983..4504d3e105a858 100644 --- a/packages/interactivity/src/vdom.js +++ b/packages/interactivity/src/vdom.js @@ -58,8 +58,7 @@ export function toVdom( root ) { const props = {}; const children = []; - const directives = {}; - let hasDirectives = false; + const directives = []; let ignore = false; let island = false; @@ -82,15 +81,7 @@ export function toVdom( root ) { island = true; namespace = value?.namespace ?? null; } else { - hasDirectives = true; - const [ , prefix, suffix = 'default' ] = - directiveParser.exec( n ); - directives[ prefix ] = directives[ prefix ] || []; - directives[ prefix ].push( { - namespace: ns, - value, - suffix, - } ); + directives.push( [ n, ns, value ] ); } } } else if ( n === 'ref' ) { @@ -109,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 ) { From 8a9bd47711a134fbc2f1aa8f2d5c83ca9f6547b5 Mon Sep 17 00:00:00 2001 From: David Arenas Date: Tue, 14 Nov 2023 12:27:08 +0100 Subject: [PATCH 34/63] Fix file namespace --- packages/block-library/src/file/index.php | 2 +- packages/block-library/src/file/view.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/block-library/src/file/index.php b/packages/block-library/src/file/index.php index 907530e75253c0..e5b708dfd2d102 100644 --- a/packages/block-library/src/file/index.php +++ b/packages/block-library/src/file/index.php @@ -57,7 +57,7 @@ static function ( $matches ) { if ( $should_load_view_script ) { $processor = new WP_HTML_Tag_Processor( $content ); $processor->next_tag(); - $processor->set_attribute( 'data-wp-interactive', '{"namespace":"core"}' ); + $processor->set_attribute( 'data-wp-interactive', '{"namespace":"core/file"}' ); $processor->next_tag( 'object' ); $processor->set_attribute( 'data-wp-style--display', 'state.file.hasPdfPreview' ); return $processor->get_updated_html(); diff --git a/packages/block-library/src/file/view.js b/packages/block-library/src/file/view.js index d7fccc01d14dd8..6e0baadd59aa73 100644 --- a/packages/block-library/src/file/view.js +++ b/packages/block-library/src/file/view.js @@ -7,7 +7,7 @@ import { store } from '@wordpress/interactivity'; */ import { browserSupportsPdfs } from './utils'; -store( 'core', { +store( 'core/file', { state: { get hasPdfPreview() { return browserSupportsPdfs() ? 'inherit' : 'none'; From 37aa4ce40aa18a52c09a6590a203d495e91a87a5 Mon Sep 17 00:00:00 2001 From: David Arenas Date: Tue, 14 Nov 2023 12:41:41 +0100 Subject: [PATCH 35/63] Finish migrating the Image block --- packages/block-library/src/image/index.php | 82 ++-- packages/block-library/src/image/view.js | 454 ++++++++++----------- 2 files changed, 260 insertions(+), 276 deletions(-) diff --git a/packages/block-library/src/image/index.php b/packages/block-library/src/image/index.php index 18caabf6af160c..a9fb1ce3708044 100644 --- a/packages/block-library/src/image/index.php +++ b/packages/block-library/src/image/index.php @@ -187,27 +187,23 @@ function block_core_image_render_lightbox( $block_content, $block ) { $w = new WP_HTML_Tag_Processor( $block_content ); $w->next_tag( 'figure' ); $w->add_class( 'wp-lightbox-container' ); - $w->set_attribute( 'data-wp-interactive', '{"namespace":"core"}' ); + $w->set_attribute( 'data-wp-interactive', '{"namespace":"core/image"}' ); $w->set_attribute( 'data-wp-context', sprintf( - '{ "core": - { "image": - { "imageLoaded": false, - "initialized": false, - "lightboxEnabled": false, - "hideAnimationEnabled": false, - "preloadInitialized": false, - "lightboxAnimation": "%s", - "imageUploadedSrc": "%s", - "imageCurrentSrc": "", - "targetWidth": "%s", - "targetHeight": "%s", - "scaleAttr": "%s", - "dialogLabel": "%s" - } - } + '{ "imageLoaded": false, + "initialized": false, + "lightboxEnabled": false, + "hideAnimationEnabled": false, + "preloadInitialized": false, + "lightboxAnimation": "%s", + "imageUploadedSrc": "%s", + "imageCurrentSrc": "", + "targetWidth": "%s", + "targetHeight": "%s", + "scaleAttr": "%s", + "dialogLabel": "%s" }', $lightbox_animation, $img_uploaded_src, @@ -218,10 +214,10 @@ function block_core_image_render_lightbox( $block_content, $block ) { ) ); $w->next_tag( 'img' ); - $w->set_attribute( 'data-wp-init', 'effects.image.setCurrentSrc' ); - $w->set_attribute( 'data-wp-on--load', 'actions.image.handleLoad' ); - $w->set_attribute( 'data-wp-effect', 'effects.image.setButtonStyles' ); - $w->set_attribute( 'data-wp-effect--setStylesOnResize', 'effects.image.setStylesOnResize' ); + $w->set_attribute( 'data-wp-init', 'effects.setCurrentSrc' ); + $w->set_attribute( 'data-wp-on--load', 'actions.handleLoad' ); + $w->set_attribute( 'data-wp-effect', 'effects.setButtonStyles' ); + $w->set_attribute( 'data-wp-effect--setStylesOnResize', 'effects.setStylesOnResize' ); $body_content = $w->get_updated_html(); // Wrap the image in the body content with a button. @@ -234,11 +230,11 @@ function block_core_image_render_lightbox( $block_content, $block ) { type="button" aria-haspopup="dialog" aria-label="' . esc_attr( $aria_label ) . '" - data-wp-on--click="actions.image.showLightbox" - data-wp-style--width="context.core.image.imageButtonWidth" - data-wp-style--height="context.core.image.imageButtonHeight" - data-wp-style--left="context.core.image.imageButtonLeft" - data-wp-style--top="context.core.image.imageButtonTop" + data-wp-on--click="actions.showLightbox" + data-wp-style--width="context.imageButtonWidth" + data-wp-style--height="context.imageButtonHeight" + data-wp-style--left="context.imageButtonLeft" + data-wp-style--top="context.imageButtonTop" >'; $body_content = preg_replace( '/]+>/', $button, $body_content ); @@ -260,8 +256,8 @@ function block_core_image_render_lightbox( $block_content, $block ) { // use the exact same image as in the content when the lightbox is first opened while // we wait for the larger image to load. $m->set_attribute( 'src', '' ); - $m->set_attribute( 'data-wp-bind--src', 'context.core.image.imageCurrentSrc' ); - $m->set_attribute( 'data-wp-style--object-fit', 'state.image.lightboxObjectFit' ); + $m->set_attribute( 'data-wp-bind--src', 'context.imageCurrentSrc' ); + $m->set_attribute( 'data-wp-style--object-fit', 'state.lightboxObjectFit' ); $initial_image_content = $m->get_updated_html(); $q = new WP_HTML_Tag_Processor( $block_content ); @@ -276,8 +272,8 @@ function block_core_image_render_lightbox( $block_content, $block ) { // and Chrome (see https://github.com/WordPress/gutenberg/pull/52765#issuecomment-1674008151). Until that // is resolved, manually setting the 'src' seems to be the best solution to load the large image on demand. $q->set_attribute( 'src', '' ); - $q->set_attribute( 'data-wp-bind--src', 'state.image.enlargedImgSrc' ); - $q->set_attribute( 'data-wp-style--object-fit', 'state.image.lightboxObjectFit' ); + $q->set_attribute( 'data-wp-bind--src', 'state.enlargedImgSrc' ); + $q->set_attribute( 'data-wp-style--object-fit', 'state.lightboxObjectFit' ); $enlarged_image_content = $q->get_updated_html(); // If the current theme does NOT have a `theme.json`, or the colors are not defined, @@ -300,20 +296,20 @@ function block_core_image_render_lightbox( $block_content, $block ) { $lightbox_html = << - diff --git a/packages/block-library/src/image/view.js b/packages/block-library/src/image/view.js index 58a061e64ca2db..44c6f0499cc8c4 100644 --- a/packages/block-library/src/image/view.js +++ b/packages/block-library/src/image/view.js @@ -17,7 +17,7 @@ const focusableSelectors = [ '[tabindex]:not([tabindex^="-"])', ]; -/* +/** * Stores a context-bound scroll handler. * * This callback could be defined inline inside of the store @@ -32,7 +32,7 @@ const focusableSelectors = [ */ let scrollCallback; -/* +/** * Tracks whether user is touching screen; used to * differentiate behavior for touch and mouse input. * @@ -40,7 +40,7 @@ let scrollCallback; */ let isTouching = false; -/* +/** * Tracks the last time the screen was touched; used to * differentiate behavior for touch and mouse input. * @@ -48,7 +48,7 @@ let isTouching = false; */ let lastTouchTime = 0; -/* +/** * Lightbox page-scroll handler: prevents scrolling. * * This handler is added to prevent scrolling behaviors that @@ -64,7 +64,7 @@ let lastTouchTime = 0; * instead to not rely on JavaScript, but this seems to be the best approach * for now that provides the best visual experience. * - * @param {Object} context Interactivity page context? + * @param {Object} image Context for the `core/image` namespace. */ function handleScroll( image ) { // We can't override the scroll behavior on mobile devices @@ -77,244 +77,232 @@ function handleScroll( image ) { } } -const { state, actions, effects } = store( { +const { state, actions, effects } = store( 'core/image', { state: { - image: { - windowWidth: window.innerWidth, - windowHeight: window.innerHeight, - get roleAttribute() { - const { image } = getContext(); - return image.lightboxEnabled ? 'dialog' : null; - }, - get ariaModal() { - const { image } = getContext(); - return image.lightboxEnabled ? 'true' : null; - }, - get dialogLabel() { - const { image } = getContext(); - return image.lightboxEnabled ? image.dialogLabel : null; - }, - get lightboxObjectFit() { - const { image } = getContext(); - if ( image.initialized ) { - return 'cover'; - } - }, - get enlargedImgSrc() { - const { image } = getContext(); - return image.initialized - ? image.imageUploadedSrc - : 'data:image/gif;base64,R0lGODlhAQABAAD/ACwAAAAAAQABAAACADs='; - }, + windowWidth: window.innerWidth, + windowHeight: window.innerHeight, + get roleAttribute() { + const image = getContext(); + return image.lightboxEnabled ? 'dialog' : null; + }, + get ariaModal() { + const image = getContext(); + return image.lightboxEnabled ? 'true' : null; + }, + get dialogLabel() { + const image = getContext(); + return image.lightboxEnabled ? image.dialogLabel : null; + }, + get lightboxObjectFit() { + const image = getContext(); + if ( image.initialized ) { + return 'cover'; + } + }, + get enlargedImgSrc() { + const image = getContext(); + return image.initialized + ? image.imageUploadedSrc + : 'data:image/gif;base64,R0lGODlhAQABAAD/ACwAAAAAAQABAAACADs='; }, }, actions: { - image: { - showLightbox( event ) { - // We can't initialize the lightbox until the reference - // image is loaded, otherwise the UX is broken. - const { image } = getContext(); - if ( ! image.imageLoaded ) { - return; - } - image.initialized = true; - image.lastFocusedElement = window.document.activeElement; - image.scrollDelta = 0; - - image.lightboxEnabled = true; - setStyles( image, event.target.previousElementSibling ); - - image.scrollTopReset = - window.pageYOffset || document.documentElement.scrollTop; - - // In most cases, this value will be 0, but this is included - // in case a user has created a page with horizontal scrolling. - image.scrollLeftReset = - window.pageXOffset || document.documentElement.scrollLeft; - - // We define and bind the scroll callback here so - // that we can pass the context and as an argument. - // We may be able to change this in the future if we - // define the scroll callback in the store instead, but - // this approach seems to tbe clearest for now. - scrollCallback = handleScroll.bind( null, image ); - - // We need to add a scroll event listener to the window - // here because we are unable to otherwise access it via - // the Interactivity API directives. If we add a native way - // to access the window, we can remove this. - window.addEventListener( 'scroll', scrollCallback, false ); - }, - hideLightbox() { - const { image } = getContext(); - image.hideAnimationEnabled = true; - if ( image.lightboxEnabled ) { - // We want to wait until the close animation is completed - // before allowing a user to scroll again. The duration of this - // animation is defined in the styles.scss and depends on if the - // animation is 'zoom' or 'fade', but in any case we should wait - // a few milliseconds longer than the duration, otherwise a user - // may scroll too soon and cause the animation to look sloppy. - setTimeout( function () { - window.removeEventListener( 'scroll', scrollCallback ); - }, 450 ); - - image.lightboxEnabled = false; - image.lastFocusedElement.focus( { - preventScroll: true, - } ); - } - }, - handleKeydown( event ) { - const { image } = getContext(); - if ( image.lightboxEnabled ) { - if ( event.key === 'Tab' || event.keyCode === 9 ) { - // If shift + tab it change the direction - if ( - event.shiftKey && - window.document.activeElement === - image.firstFocusableElement - ) { - event.preventDefault(); - image.lastFocusableElement.focus(); - } else if ( - ! event.shiftKey && - window.document.activeElement === - image.lastFocusableElement - ) { - event.preventDefault(); - image.firstFocusableElement.focus(); - } - } - - if ( event.key === 'Escape' || event.keyCode === 27 ) { - actions.image.hideLightbox(); + showLightbox( event ) { + // We can't initialize the lightbox until the reference + // image is loaded, otherwise the UX is broken. + const image = getContext(); + if ( ! image.imageLoaded ) { + return; + } + image.initialized = true; + image.lastFocusedElement = window.document.activeElement; + image.scrollDelta = 0; + + image.lightboxEnabled = true; + setStyles( image, event.target.previousElementSibling ); + + image.scrollTopReset = + window.pageYOffset || document.documentElement.scrollTop; + + // In most cases, this value will be 0, but this is included + // in case a user has created a page with horizontal scrolling. + image.scrollLeftReset = + window.pageXOffset || document.documentElement.scrollLeft; + + // We define and bind the scroll callback here so + // that we can pass the context and as an argument. + // We may be able to change this in the future if we + // define the scroll callback in the store instead, but + // this approach seems to tbe clearest for now. + scrollCallback = handleScroll.bind( null, image ); + + // We need to add a scroll event listener to the window + // here because we are unable to otherwise access it via + // the Interactivity API directives. If we add a native way + // to access the window, we can remove this. + window.addEventListener( 'scroll', scrollCallback, false ); + }, + hideLightbox() { + const image = getContext(); + image.hideAnimationEnabled = true; + if ( image.lightboxEnabled ) { + // We want to wait until the close animation is completed + // before allowing a user to scroll again. The duration of this + // animation is defined in the styles.scss and depends on if the + // animation is 'zoom' or 'fade', but in any case we should wait + // a few milliseconds longer than the duration, otherwise a user + // may scroll too soon and cause the animation to look sloppy. + setTimeout( function () { + window.removeEventListener( 'scroll', scrollCallback ); + }, 450 ); + + image.lightboxEnabled = false; + image.lastFocusedElement.focus( { + preventScroll: true, + } ); + } + }, + handleKeydown( event ) { + const image = getContext(); + if ( image.lightboxEnabled ) { + if ( event.key === 'Tab' || event.keyCode === 9 ) { + // If shift + tab it change the direction + if ( + event.shiftKey && + window.document.activeElement === + image.firstFocusableElement + ) { + event.preventDefault(); + image.lastFocusableElement.focus(); + } else if ( + ! event.shiftKey && + window.document.activeElement === + image.lastFocusableElement + ) { + event.preventDefault(); + image.firstFocusableElement.focus(); } } - }, - handleLoad() { - const { image } = getContext(); - const { ref } = getElement(); - image.imageLoaded = true; - image.imageCurrentSrc = ref.currentSrc; - effects.image.setButtonStyles(); - }, - handleTouchStart() { - isTouching = true; - }, - handleTouchMove( event ) { - const { image } = getContext(); - // On mobile devices, we want to prevent triggering the - // scroll event because otherwise the page jumps around as - // we reset the scroll position. This also means that closing - // the lightbox requires that a user perform a simple tap. This - // may be changed in the future if we find a better alternative - // to override or reset the scroll position during swipe actions. - if ( image.lightboxEnabled ) { - event.preventDefault(); + + if ( event.key === 'Escape' || event.keyCode === 27 ) { + actions.hideLightbox(); } - }, - handleTouchEnd: () => { - // We need to wait a few milliseconds before resetting - // to ensure that pinch to zoom works consistently - // on mobile devices when the lightbox is open. - lastTouchTime = Date.now(); - isTouching = false; - }, + } + }, + handleLoad() { + const image = getContext(); + const { ref } = getElement(); + image.imageLoaded = true; + image.imageCurrentSrc = ref.currentSrc; + effects.image.setButtonStyles(); + }, + handleTouchStart() { + isTouching = true; + }, + handleTouchMove( event ) { + const image = getContext(); + // On mobile devices, we want to prevent triggering the + // scroll event because otherwise the page jumps around as + // we reset the scroll position. This also means that closing + // the lightbox requires that a user perform a simple tap. This + // may be changed in the future if we find a better alternative + // to override or reset the scroll position during swipe actions. + if ( image.lightboxEnabled ) { + event.preventDefault(); + } + }, + handleTouchEnd: () => { + // We need to wait a few milliseconds before resetting + // to ensure that pinch to zoom works consistently + // on mobile devices when the lightbox is open. + lastTouchTime = Date.now(); + isTouching = false; }, }, effects: { - image: { - setCurrentSrc() { - const { image } = getContext(); - const { ref } = getElement(); - if ( ref.complete ) { - image.imageLoaded = true; - image.imageCurrentSrc = ref.currentSrc; - } - }, - initLightbox() { - const { image } = getContext(); - const { ref } = getElement(); - image.figureRef = ref.querySelector( 'figure' ); - image.imageRef = ref.querySelector( 'img' ); - if ( image.lightboxEnabled ) { - const focusableElements = - ref.querySelectorAll( focusableSelectors ); - image.firstFocusableElement = focusableElements[ 0 ]; - image.lastFocusableElement = - focusableElements[ focusableElements.length - 1 ]; - - ref.querySelector( '.close-button' ).focus(); - } - }, - setButtonStyles() { - const { ref } = getElement(); - const { - naturalWidth, - naturalHeight, - offsetWidth, - offsetHeight, - } = ref; - - // If the image isn't loaded yet, we can't - // calculate how big the button should be. - if ( naturalWidth === 0 || naturalHeight === 0 ) { - return; - } - - const { image } = getContext(); - - // Subscribe to the window dimensions so we can - // recalculate the styles if the window is resized. - if ( - ( state.image.windowWidth || state.image.windowHeight ) && - image.scaleAttr === 'contain' - ) { - // In the case of an image with object-fit: contain, the - // size of the img element can be larger than the image itself, - // so we need to calculate the size of the button to match. - - // Natural ratio of the image. - const naturalRatio = naturalWidth / naturalHeight; - // Offset ratio of the image. - const offsetRatio = offsetWidth / offsetHeight; - - if ( naturalRatio > offsetRatio ) { - // If it reaches the width first, keep - // the width and recalculate the height. - image.imageButtonWidth = offsetWidth; - const buttonHeight = offsetWidth / naturalRatio; - image.imageButtonHeight = buttonHeight; - image.imageButtonTop = - ( offsetHeight - buttonHeight ) / 2; - } else { - // If it reaches the height first, keep - // the height and recalculate the width. - image.imageButtonHeight = offsetHeight; - const buttonWidth = offsetHeight * naturalRatio; - image.imageButtonWidth = buttonWidth; - image.imageButtonLeft = - ( offsetWidth - buttonWidth ) / 2; - } - } else { - // In all other cases, we can trust that the size of - // the image is the right size for the button as well. + setCurrentSrc() { + const image = getContext(); + const { ref } = getElement(); + if ( ref.complete ) { + image.imageLoaded = true; + image.imageCurrentSrc = ref.currentSrc; + } + }, + initLightbox() { + const image = getContext(); + const { ref } = getElement(); + image.figureRef = ref.querySelector( 'figure' ); + image.imageRef = ref.querySelector( 'img' ); + if ( image.lightboxEnabled ) { + const focusableElements = + ref.querySelectorAll( focusableSelectors ); + image.firstFocusableElement = focusableElements[ 0 ]; + image.lastFocusableElement = + focusableElements[ focusableElements.length - 1 ]; + + ref.querySelector( '.close-button' ).focus(); + } + }, + setButtonStyles() { + const { ref } = getElement(); + const { naturalWidth, naturalHeight, offsetWidth, offsetHeight } = + ref; + + // If the image isn't loaded yet, we can't + // calculate how big the button should be. + if ( naturalWidth === 0 || naturalHeight === 0 ) { + return; + } + const image = getContext(); + + // Subscribe to the window dimensions so we can + // recalculate the styles if the window is resized. + if ( + ( state.windowWidth || state.windowHeight ) && + image.scaleAttr === 'contain' + ) { + // In the case of an image with object-fit: contain, the + // size of the img element can be larger than the image itself, + // so we need to calculate the size of the button to match. + + // Natural ratio of the image. + const naturalRatio = naturalWidth / naturalHeight; + // Offset ratio of the image. + const offsetRatio = offsetWidth / offsetHeight; + + if ( naturalRatio > offsetRatio ) { + // If it reaches the width first, keep + // the width and recalculate the height. image.imageButtonWidth = offsetWidth; + const buttonHeight = offsetWidth / naturalRatio; + image.imageButtonHeight = buttonHeight; + image.imageButtonTop = ( offsetHeight - buttonHeight ) / 2; + } else { + // If it reaches the height first, keep + // the height and recalculate the width. image.imageButtonHeight = offsetHeight; + const buttonWidth = offsetHeight * naturalRatio; + image.imageButtonWidth = buttonWidth; + image.imageButtonLeft = ( offsetWidth - buttonWidth ) / 2; } - }, - setStylesOnResize() { - const { image } = getContext(); - const { ref } = getElement(); - if ( - image.lightboxEnabled && - ( state.image.windowWidth || state.image.windowHeight ) - ) { - setStyles( image, ref ); - } - }, + } else { + // In all other cases, we can trust that the size of + // the image is the right size for the button as well. + + image.imageButtonWidth = offsetWidth; + image.imageButtonHeight = offsetHeight; + } + }, + setStylesOnResize() { + const image = getContext(); + const { ref } = getElement(); + if ( + image.lightboxEnabled && + ( state.windowWidth || state.windowHeight ) + ) { + setStyles( image, ref ); + } }, }, } ); @@ -322,8 +310,8 @@ const { state, actions, effects } = store( { window.addEventListener( 'resize', debounce( () => { - state.image.windowWidth = window.innerWidth; - state.image.windowHeight = window.innerHeight; + state.windowWidth = window.innerWidth; + state.windowHeight = window.innerHeight; } ) ); @@ -497,12 +485,12 @@ function setStyles( image, ref ) { `; } -/* +/** * Debounces a function call. * * @function * @param {Function} func - A function to be called - * @param {number} wait - The time to wait before calling the function + * @param {number} wait - The time to wait before calling the function */ function debounce( func, wait = 50 ) { let timeout; From 01a285a12fb297a2368015c2ca6a455084e7c39a Mon Sep 17 00:00:00 2001 From: David Arenas Date: Tue, 14 Nov 2023 14:03:50 +0100 Subject: [PATCH 36/63] Update Navigation block --- .../block-library/src/navigation/index.php | 58 ++- packages/block-library/src/navigation/view.js | 332 ++++++++---------- 2 files changed, 176 insertions(+), 214 deletions(-) diff --git a/packages/block-library/src/navigation/index.php b/packages/block-library/src/navigation/index.php index 40b22757528551..877d3a70b96353 100644 --- a/packages/block-library/src/navigation/index.php +++ b/packages/block-library/src/navigation/index.php @@ -85,14 +85,14 @@ function block_core_navigation_add_directives_to_submenu( $w, $block_attributes ) ) ) { // Add directives to the parent `
  • `. - $w->set_attribute( 'data-wp-interactive', true ); - $w->set_attribute( 'data-wp-context', '{ "core": { "navigation": { "submenuOpenedBy": {}, "type": "submenu" } } }' ); - $w->set_attribute( 'data-wp-effect', 'effects.core.navigation.initMenu' ); - $w->set_attribute( 'data-wp-on--focusout', 'actions.core.navigation.handleMenuFocusout' ); - $w->set_attribute( 'data-wp-on--keydown', 'actions.core.navigation.handleMenuKeydown' ); + $w->set_attribute( 'data-wp-interactive', '{ "namespace":"core/navigation" }' ); + $w->set_attribute( 'data-wp-context', '{ "submenuOpenedBy": {}, "type": "submenu" }' ); + $w->set_attribute( 'data-wp-watch', 'effects.initMenu' ); + $w->set_attribute( 'data-wp-on--focusout', 'actions.handleMenuFocusout' ); + $w->set_attribute( 'data-wp-on--keydown', 'actions.handleMenuKeydown' ); if ( ! isset( $block_attributes['openSubmenusOnClick'] ) || false === $block_attributes['openSubmenusOnClick'] ) { - $w->set_attribute( 'data-wp-on--mouseenter', 'actions.core.navigation.openMenuOnHover' ); - $w->set_attribute( 'data-wp-on--mouseleave', 'actions.core.navigation.closeMenuOnHover' ); + $w->set_attribute( 'data-wp-on--mouseenter', 'actions.openMenuOnHover' ); + $w->set_attribute( 'data-wp-on--mouseleave', 'actions.closeMenuOnHover' ); } // Add directives to the toggle submenu button. @@ -102,8 +102,8 @@ function block_core_navigation_add_directives_to_submenu( $w, $block_attributes 'class_name' => 'wp-block-navigation-submenu__toggle', ) ) ) { - $w->set_attribute( 'data-wp-on--click', 'actions.core.navigation.toggleMenuOnClick' ); - $w->set_attribute( 'data-wp-bind--aria-expanded', 'selectors.core.navigation.isMenuOpen' ); + $w->set_attribute( 'data-wp-on--click', 'actions.toggleMenuOnClick' ); + $w->set_attribute( 'data-wp-bind--aria-expanded', 'state.isMenuOpen' ); // The `aria-expanded` attribute for SSR is already added in the submenu block. } // Add directives to the submenu. @@ -113,7 +113,7 @@ function block_core_navigation_add_directives_to_submenu( $w, $block_attributes 'class_name' => 'wp-block-navigation__submenu-container', ) ) ) { - $w->set_attribute( 'data-wp-on--focus', 'actions.core.navigation.openMenuOnFocus' ); + $w->set_attribute( 'data-wp-on--focus', 'actions.openMenuOnFocus' ); } // Iterate through subitems if exist. @@ -698,41 +698,37 @@ function render_block_core_navigation( $attributes, $content, $block ) { if ( $should_load_view_script ) { $nav_element_context = wp_json_encode( array( - 'core' => array( - 'navigation' => array( - 'overlayOpenedBy' => array(), - 'type' => 'overlay', - 'roleAttribute' => '', - 'ariaLabel' => __( 'Menu' ), - ), - ), + 'overlayOpenedBy' => array(), + 'type' => 'overlay', + 'roleAttribute' => '', + 'ariaLabel' => __( 'Menu' ), ), JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_AMP ); $nav_element_directives = ' - data-wp-interactive + data-wp-interactive=\'{ "namespace": "core/navigation" }\' data-wp-context=\'' . $nav_element_context . '\' '; $open_button_directives = ' - data-wp-on--click="actions.core.navigation.openMenuOnClick" - data-wp-on--keydown="actions.core.navigation.handleMenuKeydown" + data-wp-on--click="actions.openMenuOnClick" + data-wp-on--keydown="actions.handleMenuKeydown" '; $responsive_container_directives = ' - data-wp-class--has-modal-open="selectors.core.navigation.isMenuOpen" - data-wp-class--is-menu-open="selectors.core.navigation.isMenuOpen" - data-wp-effect="effects.core.navigation.initMenu" - data-wp-on--keydown="actions.core.navigation.handleMenuKeydown" - data-wp-on--focusout="actions.core.navigation.handleMenuFocusout" + data-wp-class--has-modal-open="state.isMenuOpen" + data-wp-class--is-menu-open="state.isMenuOpen" + data-wp-watch="effects.initMenu" + data-wp-on--keydown="actions.handleMenuKeydown" + data-wp-on--focusout="actions.handleMenuFocusout" tabindex="-1" '; $responsive_dialog_directives = ' - data-wp-bind--aria-modal="selectors.core.navigation.ariaModal" - data-wp-bind--aria-label="selectors.core.navigation.ariaLabel" - data-wp-bind--role="selectors.core.navigation.roleAttribute" - data-wp-effect="effects.core.navigation.focusFirstElement" + data-wp-bind--aria-modal="state.ariaModal" + data-wp-bind--aria-label="state.ariaLabel" + data-wp-bind--role="state.roleAttribute" + data-wp-watch="effects.focusFirstElement" '; $close_button_directives = ' - data-wp-on--click="actions.core.navigation.closeMenuOnClick" + data-wp-on--click="actions.closeMenuOnClick" '; } diff --git a/packages/block-library/src/navigation/view.js b/packages/block-library/src/navigation/view.js index c0853b2814e2b3..1fb243ab39dd74 100644 --- a/packages/block-library/src/navigation/view.js +++ b/packages/block-library/src/navigation/view.js @@ -1,7 +1,7 @@ /** * WordPress dependencies */ -import { store as wpStore } from '@wordpress/interactivity'; +import { store, getContext, getElement } from '@wordpress/interactivity'; const focusableSelectors = [ 'a[href]', @@ -13,198 +13,164 @@ const focusableSelectors = [ '[tabindex]:not([tabindex^="-"])', ]; -const openMenu = ( store, menuOpenedOn ) => { - const { context, ref, selectors } = store; - selectors.core.navigation.menuOpenedBy( store )[ menuOpenedOn ] = true; - context.core.navigation.previousFocus = ref; - if ( context.core.navigation.type === 'overlay' ) { - // Add a `has-modal-open` class to the root. - document.documentElement.classList.add( 'has-modal-open' ); - } -}; - -const closeMenu = ( store, menuClosedOn ) => { - const { context, selectors } = store; - selectors.core.navigation.menuOpenedBy( store )[ menuClosedOn ] = false; - // Check if the menu is still open or not. - if ( ! selectors.core.navigation.isMenuOpen( store ) ) { - if ( - context.core.navigation.modal?.contains( - window.document.activeElement - ) - ) { - context.core.navigation.previousFocus.focus(); - } - context.core.navigation.modal = null; - context.core.navigation.previousFocus = null; - if ( context.core.navigation.type === 'overlay' ) { - document.documentElement.classList.remove( 'has-modal-open' ); - } - } -}; - -wpStore( { +const { state, actions } = store( 'core/navigation', { effects: { - core: { - navigation: { - initMenu: ( store ) => { - const { context, selectors, ref } = store; - if ( selectors.core.navigation.isMenuOpen( store ) ) { - const focusableElements = - ref.querySelectorAll( focusableSelectors ); - context.core.navigation.modal = ref; - context.core.navigation.firstFocusableElement = - focusableElements[ 0 ]; - context.core.navigation.lastFocusableElement = - focusableElements[ focusableElements.length - 1 ]; - } - }, - focusFirstElement: ( store ) => { - const { selectors, ref } = store; - if ( selectors.core.navigation.isMenuOpen( store ) ) { - ref.querySelector( - '.wp-block-navigation-item > *:first-child' - ).focus(); - } - }, - }, + initMenu() { + const ctx = getContext(); + const { ref } = getElement(); + if ( state.isMenuOpen ) { + const focusableElements = + ref.querySelectorAll( focusableSelectors ); + ctx.modal = ref; + ctx.firstFocusableElement = focusableElements[ 0 ]; + ctx.lastFocusableElement = + focusableElements[ focusableElements.length - 1 ]; + } + }, + focusFirstElement() { + const { ref } = getElement(); + if ( state.isMenuOpen ) { + ref.querySelector( + '.wp-block-navigation-item > *:first-child' + ).focus(); + } }, }, - selectors: { - core: { - navigation: { - roleAttribute: ( store ) => { - const { context, selectors } = store; - return context.core.navigation.type === 'overlay' && - selectors.core.navigation.isMenuOpen( store ) - ? 'dialog' - : null; - }, - ariaModal: ( store ) => { - const { context, selectors } = store; - return context.core.navigation.type === 'overlay' && - selectors.core.navigation.isMenuOpen( store ) - ? 'true' - : null; - }, - ariaLabel: ( store ) => { - const { context, selectors } = store; - return context.core.navigation.type === 'overlay' && - selectors.core.navigation.isMenuOpen( store ) - ? context.core.navigation.ariaLabel - : null; - }, - isMenuOpen: ( { context } ) => - // The menu is opened if either `click`, `hover` or `focus` is true. - Object.values( - context.core.navigation[ - context.core.navigation.type === 'overlay' - ? 'overlayOpenedBy' - : 'submenuOpenedBy' - ] - ).filter( Boolean ).length > 0, - menuOpenedBy: ( { context } ) => - context.core.navigation[ - context.core.navigation.type === 'overlay' - ? 'overlayOpenedBy' - : 'submenuOpenedBy' - ], - }, + state: { + get roleAttribute() { + const ctx = getContext(); + return ctx.type === 'overlay' && state.isMenuOpen ? 'dialog' : null; + }, + get ariaModal() { + const ctx = getContext(); + return ctx.type === 'overlay' && state.isMenuOpen ? 'true' : null; + }, + get ariaLabel() { + const ctx = getContext(); + return ctx.type === 'overlay' && state.isMenuOpen + ? ctx.ariaLabel + : null; + }, + get isMenuOpen() { + // The menu is opened if either `click`, `hover` or `focus` is true. + return ( + Object.values( state.menuOpenedBy ).filter( Boolean ).length > 0 + ); + }, + get menuOpenedBy() { + const ctx = getContext(); + return ctx.type === 'overlay' + ? ctx.overlayOpenedBy + : ctx.submenuOpenedBy; }, }, actions: { - core: { - navigation: { - openMenuOnHover( store ) { - const { navigation } = store.context.core; - if ( - navigation.type === 'submenu' && - // Only open on hover if the overlay is closed. - Object.values( - navigation.overlayOpenedBy || {} - ).filter( Boolean ).length === 0 - ) - openMenu( store, 'hover' ); - }, - closeMenuOnHover( store ) { - closeMenu( store, 'hover' ); - }, - openMenuOnClick( store ) { - openMenu( store, 'click' ); - }, - closeMenuOnClick( store ) { - closeMenu( store, 'click' ); - closeMenu( store, 'focus' ); - }, - openMenuOnFocus( store ) { - openMenu( store, 'focus' ); - }, - toggleMenuOnClick: ( store ) => { - const { selectors } = store; - const menuOpenedBy = - selectors.core.navigation.menuOpenedBy( store ); - if ( menuOpenedBy.click || menuOpenedBy.focus ) { - closeMenu( store, 'click' ); - closeMenu( store, 'focus' ); - } else { - openMenu( store, 'click' ); - } - }, - handleMenuKeydown: ( store ) => { - const { context, selectors, event } = store; - if ( - selectors.core.navigation.menuOpenedBy( store ).click - ) { - // If Escape close the menu. - if ( event?.key === 'Escape' ) { - closeMenu( store, 'click' ); - closeMenu( store, 'focus' ); - return; - } + openMenuOnHover() { + const ctx = getContext(); + if ( + ctx.type === 'submenu' && + // Only open on hover if the overlay is closed. + Object.values( ctx.overlayOpenedBy || {} ).filter( Boolean ) + .length === 0 + ) + actions.openMenu( 'hover' ); + }, + closeMenuOnHover() { + actions.closeMenu( 'hover' ); + }, + openMenuOnClick() { + actions.openMenu( 'click' ); + }, + closeMenuOnClick() { + actions.closeMenu( 'click' ); + actions.closeMenu( 'focus' ); + }, + openMenuOnFocus() { + actions.openMenu( 'focus' ); + }, + toggleMenuOnClick() { + const { menuOpenedBy } = state; + if ( menuOpenedBy.click || menuOpenedBy.focus ) { + actions.closeMenu( 'click' ); + actions.closeMenu( 'focus' ); + } else { + actions.openMenu( 'click' ); + } + }, + handleMenuKeydown( event ) { + if ( state.menuOpenedBy.click ) { + // If Escape close the menu. + if ( event?.key === 'Escape' ) { + actions.closeMenu( 'click' ); + actions.closeMenu( 'focus' ); + return; + } - // Trap focus if it is an overlay (main menu). - if ( - context.core.navigation.type === 'overlay' && - event.key === 'Tab' - ) { - // If shift + tab it change the direction. - if ( - event.shiftKey && - window.document.activeElement === - context.core.navigation - .firstFocusableElement - ) { - event.preventDefault(); - context.core.navigation.lastFocusableElement.focus(); - } else if ( - ! event.shiftKey && - window.document.activeElement === - context.core.navigation.lastFocusableElement - ) { - event.preventDefault(); - context.core.navigation.firstFocusableElement.focus(); - } - } - } - }, - handleMenuFocusout: ( store ) => { - const { context, event } = store; - // If focus is outside modal, and in the document, close menu - // event.target === The element losing focus - // event.relatedTarget === The element receiving focus (if any) - // When focusout is outsite the document, - // `window.document.activeElement` doesn't change. + const ctx = getContext(); + // Trap focus if it is an overlay (main menu). + if ( ctx.type === 'overlay' && event.key === 'Tab' ) { + // If shift + tab it change the direction. if ( - ! context.core.navigation.modal?.contains( - event.relatedTarget - ) && - event.target !== window.document.activeElement + event.shiftKey && + window.document.activeElement === + ctx.firstFocusableElement + ) { + event.preventDefault(); + ctx.lastFocusableElement.focus(); + } else if ( + ! event.shiftKey && + window.document.activeElement === + ctx.lastFocusableElement ) { - closeMenu( store, 'click' ); - closeMenu( store, 'focus' ); + event.preventDefault(); + ctx.firstFocusableElement.focus(); } - }, - }, + } + } + }, + handleMenuFocusout( event ) { + const ctx = getContext(); + // If focus is outside modal, and in the document, close menu + // event.target === The element losing focus + // event.relatedTarget === The element receiving focus (if any) + // When focusout is outsite the document, + // `window.document.activeElement` doesn't change. + if ( + ! ctx.modal?.contains( event.relatedTarget ) && + event.target !== window.document.activeElement + ) { + actions.closeMenu( 'click' ); + actions.closeMenu( 'focus' ); + } + }, + + openMenu( menuOpenedOn ) { + const ctx = getContext(); + const { ref } = getElement(); + state.menuOpenedBy[ menuOpenedOn ] = true; + ctx.previousFocus = ref; + if ( ctx.type === 'overlay' ) { + // Add a `has-modal-open` class to the root. + document.documentElement.classList.add( 'has-modal-open' ); + } + }, + + closeMenu( menuClosedOn ) { + const ctx = getContext(); + state.menuOpenedBy[ menuClosedOn ] = false; + // Check if the menu is still open or not. + if ( ! state.isMenuOpen ) { + if ( ctx.modal?.contains( window.document.activeElement ) ) { + ctx.previousFocus.focus(); + } + ctx.modal = null; + ctx.previousFocus = null; + if ( ctx.type === 'overlay' ) { + document.documentElement.classList.remove( + 'has-modal-open' + ); + } + } }, }, } ); From 9a8dacd913f22404e62795cbc297685e7a062525 Mon Sep 17 00:00:00 2001 From: David Arenas Date: Tue, 14 Nov 2023 14:49:10 +0100 Subject: [PATCH 37/63] Replace effect with watch in Image --- packages/block-library/src/image/index.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/block-library/src/image/index.php b/packages/block-library/src/image/index.php index a9fb1ce3708044..32a2844c26afd5 100644 --- a/packages/block-library/src/image/index.php +++ b/packages/block-library/src/image/index.php @@ -216,8 +216,8 @@ function block_core_image_render_lightbox( $block_content, $block ) { $w->next_tag( 'img' ); $w->set_attribute( 'data-wp-init', 'effects.setCurrentSrc' ); $w->set_attribute( 'data-wp-on--load', 'actions.handleLoad' ); - $w->set_attribute( 'data-wp-effect', 'effects.setButtonStyles' ); - $w->set_attribute( 'data-wp-effect--setStylesOnResize', 'effects.setStylesOnResize' ); + $w->set_attribute( 'data-wp-watch', 'effects.setButtonStyles' ); + $w->set_attribute( 'data-wp-watch--setStylesOnResize', 'effects.setStylesOnResize' ); $body_content = $w->get_updated_html(); // Wrap the image in the body content with a button. @@ -302,7 +302,7 @@ function block_core_image_render_lightbox( $block_content, $block ) { data-wp-class--active="context.lightboxEnabled" data-wp-class--hideAnimationEnabled="context.hideAnimationEnabled" data-wp-bind--aria-modal="state.ariaModal" - data-wp-effect="effects.initLightbox" + data-wp-watch="effects.initLightbox" data-wp-on--keydown="actions.handleKeydown" data-wp-on--touchstart="actions.handleTouchStart" data-wp-on--touchmove="actions.handleTouchMove" From f4371e33249f59d3c459eaba3216cf9e164eeca7 Mon Sep 17 00:00:00 2001 From: David Arenas Date: Tue, 14 Nov 2023 14:56:17 +0100 Subject: [PATCH 38/63] Fix namespace assignment --- packages/interactivity/src/vdom.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/interactivity/src/vdom.js b/packages/interactivity/src/vdom.js index 4504d3e105a858..3165484efd0b41 100644 --- a/packages/interactivity/src/vdom.js +++ b/packages/interactivity/src/vdom.js @@ -73,7 +73,7 @@ export function toVdom( root ) { } else { let [ ns, value ] = nsPathRegExp .exec( attributes[ i ].value ) - ?.slice( 1 ) ?? [ namespace, attributes[ i ].value ]; + ?.slice( 1 ) ?? [ null, attributes[ i ].value ]; try { value = JSON.parse( value ); } catch ( e ) {} From f30cc5da4e1e8bc74c663ccf5c2a53d075b11b39 Mon Sep 17 00:00:00 2001 From: David Arenas Date: Tue, 14 Nov 2023 17:16:17 +0100 Subject: [PATCH 39/63] Fix image state getter names --- packages/block-library/src/image/view.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/block-library/src/image/view.js b/packages/block-library/src/image/view.js index 486e6994661707..f5c234744d6d12 100644 --- a/packages/block-library/src/image/view.js +++ b/packages/block-library/src/image/view.js @@ -85,21 +85,21 @@ const { state, actions, effects } = store( 'core/image', { const ctx = getContext(); return ctx.lightboxEnabled ? 'dialog' : null; }, - get ariaModalroleAttribute() { + get ariaModal() { const ctx = getContext(); return ctx.lightboxEnabled ? 'true' : null; }, - get dialogLabelroleAttribute() { + get dialogLabel() { const ctx = getContext(); return ctx.lightboxEnabled ? ctx.dialogLabel : null; }, - get lightboxObjectFitroleAttribute() { + get lightboxObjectFit() { const ctx = getContext(); if ( ctx.initialized ) { return 'cover'; } }, - get enlargedImgSrcroleAttribute() { + get enlargedImgSrc() { const ctx = getContext(); return ctx.initialized ? ctx.imageUploadedSrc From 6f6e4d05aa2b9fcc325afbe6725a65cb8197433a Mon Sep 17 00:00:00 2001 From: David Arenas Date: Tue, 14 Nov 2023 17:20:46 +0100 Subject: [PATCH 40/63] Fix getters in navigation state --- packages/block-library/src/navigation/view.js | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/packages/block-library/src/navigation/view.js b/packages/block-library/src/navigation/view.js index 7459044d39ad37..7b1998b84253b2 100644 --- a/packages/block-library/src/navigation/view.js +++ b/packages/block-library/src/navigation/view.js @@ -46,25 +46,23 @@ const { state, actions } = store( 'core/navigation', { const ctx = getContext(); return ctx.type === 'overlay' && state.isMenuOpen ? 'dialog' : null; }, - get ariaModalroleAttribute() { + get ariaModal() { const ctx = getContext(); return ctx.type === 'overlay' && state.isMenuOpen ? 'true' : null; }, - get ariaLabelroleAttribute() { + get ariaLabel() { const ctx = getContext(); return ctx.type === 'overlay' && state.isMenuOpen ? ctx.ariaLabel : null; }, - get isMenuOpenroleAttribute() { + get isMenuOpen() { // The menu is opened if either `click`, `hover` or `focus` is true. return ( - Object.values( state.menuOpenedByroleAttribute ).filter( - Boolean - ).length > 0 + Object.values( state.menuOpenedBy ).filter( Boolean ).length > 0 ); }, - get menuOpenedByroleAttribute() { + get menuOpenedBy() { const ctx = getContext(); return ctx.type === 'overlay' ? ctx.overlayOpenedBy From e4210bb6ba593d68a2b59e9437763d02e757cdf5 Mon Sep 17 00:00:00 2001 From: David Arenas Date: Tue, 14 Nov 2023 17:25:54 +0100 Subject: [PATCH 41/63] Update Query block --- .../src/query-pagination-next/index.php | 6 +- .../src/query-pagination-numbers/index.php | 2 +- .../src/query-pagination-previous/index.php | 6 +- packages/block-library/src/query/index.php | 8 +- packages/block-library/src/query/view.js | 119 +++++++++--------- 5 files changed, 67 insertions(+), 74 deletions(-) diff --git a/packages/block-library/src/query-pagination-next/index.php b/packages/block-library/src/query-pagination-next/index.php index 768fde56ff06f3..004c45af9f730d 100644 --- a/packages/block-library/src/query-pagination-next/index.php +++ b/packages/block-library/src/query-pagination-next/index.php @@ -72,9 +72,9 @@ function render_block_core_query_pagination_next( $attributes, $content, $block ) ) ) { $p->set_attribute( 'data-wp-key', 'query-pagination-next' ); - $p->set_attribute( 'data-wp-on--click', 'actions.core.query.navigate' ); - $p->set_attribute( 'data-wp-on--mouseenter', 'actions.core.query.prefetch' ); - $p->set_attribute( 'data-wp-effect', 'effects.core.query.prefetch' ); + $p->set_attribute( 'data-wp-on--click', 'core/query::actions.navigate' ); + $p->set_attribute( 'data-wp-on--mouseenter', 'core/query::actions.prefetch' ); + $p->set_attribute( 'data-wp-watch', 'core/query::effects.prefetch' ); $content = $p->get_updated_html(); } } diff --git a/packages/block-library/src/query-pagination-numbers/index.php b/packages/block-library/src/query-pagination-numbers/index.php index 98098533adac7d..2f9370751f6d25 100644 --- a/packages/block-library/src/query-pagination-numbers/index.php +++ b/packages/block-library/src/query-pagination-numbers/index.php @@ -98,7 +98,7 @@ function render_block_core_query_pagination_numbers( $attributes, $content, $blo 'class_name' => 'page-numbers', ) ) ) { - $p->set_attribute( 'data-wp-on--click', 'actions.core.query.navigate' ); + $p->set_attribute( 'data-wp-on--click', 'core/query::actions.navigate' ); } $content = $p->get_updated_html(); } diff --git a/packages/block-library/src/query-pagination-previous/index.php b/packages/block-library/src/query-pagination-previous/index.php index fc1fee08e82148..1bad6bcdab1b4f 100644 --- a/packages/block-library/src/query-pagination-previous/index.php +++ b/packages/block-library/src/query-pagination-previous/index.php @@ -60,9 +60,9 @@ function render_block_core_query_pagination_previous( $attributes, $content, $bl ) ) ) { $p->set_attribute( 'data-wp-key', 'query-pagination-previous' ); - $p->set_attribute( 'data-wp-on--click', 'actions.core.query.navigate' ); - $p->set_attribute( 'data-wp-on--mouseenter', 'actions.core.query.prefetch' ); - $p->set_attribute( 'data-wp-effect', 'effects.core.query.prefetch' ); + $p->set_attribute( 'data-wp-on--click', 'core/query::actions.navigate' ); + $p->set_attribute( 'data-wp-on--mouseenter', 'core/query::actions.prefetch' ); + $p->set_attribute( 'data-wp-watch', 'core/query::effects.prefetch' ); $content = $p->get_updated_html(); } } diff --git a/packages/block-library/src/query/index.php b/packages/block-library/src/query/index.php index b6a5733632ff44..ac83db8dcd7c82 100644 --- a/packages/block-library/src/query/index.php +++ b/packages/block-library/src/query/index.php @@ -21,7 +21,7 @@ function render_block_core_query( $attributes, $content, $block ) { $p = new WP_HTML_Tag_Processor( $content ); if ( $p->next_tag() ) { // Add the necessary directives. - $p->set_attribute( 'data-wp-interactive', true ); + $p->set_attribute( 'data-wp-interactive', '{"namespace":"core/query"}' ); $p->set_attribute( 'data-wp-navigation-id', 'query-' . $attributes['queryId'] ); // Use context to send translated strings. $p->set_attribute( @@ -54,12 +54,12 @@ function render_block_core_query( $attributes, $content, $block ) { '
    ', $last_tag_position, 0 diff --git a/packages/block-library/src/query/view.js b/packages/block-library/src/query/view.js index 1dac448952b11e..897fbfb23fe519 100644 --- a/packages/block-library/src/query/view.js +++ b/packages/block-library/src/query/view.js @@ -1,7 +1,13 @@ /** * WordPress dependencies */ -import { store, navigate, prefetch } from '@wordpress/interactivity'; +import { + store, + getContext, + getElement, + navigate, + prefetch, +} from '@wordpress/interactivity'; const isValidLink = ( ref ) => ref && @@ -19,82 +25,69 @@ const isValidEvent = ( event ) => ! event.defaultPrevented; store( { - selectors: { - core: { - query: { - startAnimation: ( { context } ) => - context.core.query.animation === 'start', - finishAnimation: ( { context } ) => - context.core.query.animation === 'finish', - }, + state: { + get startAnimation() { + return getContext().animation === 'start'; + }, + get finishAnimation() { + return getContext().animation === 'finish'; }, }, actions: { - core: { - query: { - navigate: async ( { event, ref, context } ) => { - const isDisabled = ref.closest( '[data-wp-navigation-id]' ) - ?.dataset.wpNavigationDisabled; + *navigate( event ) { + const ctx = getContext(); + const { ref } = getElement(); + const isDisabled = ref.closest( '[data-wp-navigation-id]' )?.dataset + .wpNavigationDisabled; - if ( - isValidLink( ref ) && - isValidEvent( event ) && - ! isDisabled - ) { - event.preventDefault(); + if ( isValidLink( ref ) && isValidEvent( event ) && ! isDisabled ) { + event.preventDefault(); - const id = ref.closest( '[data-wp-navigation-id]' ) - .dataset.wpNavigationId; + const id = ref.closest( '[data-wp-navigation-id]' ).dataset + .wpNavigationId; - // Don't announce the navigation immediately, wait 400 ms. - const timeout = setTimeout( () => { - context.core.query.message = - context.core.query.loadingText; - context.core.query.animation = 'start'; - }, 400 ); + // Don't announce the navigation immediately, wait 400 ms. + const timeout = setTimeout( () => { + ctx.message = ctx.loadingText; + ctx.animation = 'start'; + }, 400 ); - await navigate( ref.href ); + yield navigate( ref.href ); - // Dismiss loading message if it hasn't been added yet. - clearTimeout( timeout ); + // Dismiss loading message if it hasn't been added yet. + clearTimeout( timeout ); - // Announce that the page has been loaded. If the message is the - // same, we use a no-break space similar to the @wordpress/a11y - // package: https://github.com/WordPress/gutenberg/blob/c395242b8e6ee20f8b06c199e4fc2920d7018af1/packages/a11y/src/filter-message.js#L20-L26 - context.core.query.message = - context.core.query.loadedText + - ( context.core.query.message === - context.core.query.loadedText - ? '\u00A0' - : '' ); + // Announce that the page has been loaded. If the message is the + // same, we use a no-break space similar to the @wordpress/a11y + // package: https://github.com/WordPress/gutenberg/blob/c395242b8e6ee20f8b06c199e4fc2920d7018af1/packages/a11y/src/filter-message.js#L20-L26 + ctx.message = + ctx.loadedText + + ( ctx.message === ctx.loadedText ? '\u00A0' : '' ); - context.core.query.animation = 'finish'; - context.core.query.url = ref.href; + ctx.animation = 'finish'; + ctx.url = ref.href; - // Focus the first anchor of the Query block. - const firstAnchor = `[data-wp-navigation-id=${ id }] .wp-block-post-template a[href]`; - document.querySelector( firstAnchor )?.focus(); - } - }, - prefetch: async ( { ref } ) => { - const isDisabled = ref.closest( '[data-wp-navigation-id]' ) - ?.dataset.wpNavigationDisabled; - if ( isValidLink( ref ) && ! isDisabled ) { - await prefetch( ref.href ); - } - }, - }, + // Focus the first anchor of the Query block. + const firstAnchor = `[data-wp-navigation-id=${ id }] .wp-block-post-template a[href]`; + document.querySelector( firstAnchor )?.focus(); + } + }, + *prefetch() { + const { ref } = getElement(); + const isDisabled = ref.closest( '[data-wp-navigation-id]' )?.dataset + .wpNavigationDisabled; + if ( isValidLink( ref ) && ! isDisabled ) { + yield prefetch( ref.href ); + } }, }, effects: { - core: { - query: { - prefetch: async ( { ref, context } ) => { - if ( context.core.query.url && isValidLink( ref ) ) { - await prefetch( ref.href ); - } - }, - }, + *prefetch() { + const { url } = getContext(); + const { ref } = getElement(); + if ( url && isValidLink( ref ) ) { + yield prefetch( ref.href ); + } }, }, } ); From 3c69837fa5b2e77e6336dcb2ff6d3853312555b8 Mon Sep 17 00:00:00 2001 From: David Arenas Date: Tue, 14 Nov 2023 20:26:02 +0100 Subject: [PATCH 42/63] Fix directives for Navigation block --- .../class-wp-navigation-block-renderer.php | 38 +++++++++---------- 1 file changed, 17 insertions(+), 21 deletions(-) diff --git a/lib/compat/wordpress-6.5/class-wp-navigation-block-renderer.php b/lib/compat/wordpress-6.5/class-wp-navigation-block-renderer.php index 0e9166a7c7d548..f8fd4e9c3cc27a 100644 --- a/lib/compat/wordpress-6.5/class-wp-navigation-block-renderer.php +++ b/lib/compat/wordpress-6.5/class-wp-navigation-block-renderer.php @@ -429,25 +429,25 @@ private static function get_responsive_container_markup( $attributes, $inner_blo $close_button_directives = ''; if ( $should_load_view_script ) { $open_button_directives = ' - data-wp-on--click="actions.core.navigation.openMenuOnClick" - data-wp-on--keydown="actions.core.navigation.handleMenuKeydown" + data-wp-on--click="actions.openMenuOnClick" + data-wp-on--keydown="actions.handleMenuKeydown" '; $responsive_container_directives = ' - data-wp-class--has-modal-open="selectors.core.navigation.isMenuOpen" - data-wp-class--is-menu-open="selectors.core.navigation.isMenuOpen" - data-wp-effect="effects.core.navigation.initMenu" - data-wp-on--keydown="actions.core.navigation.handleMenuKeydown" - data-wp-on--focusout="actions.core.navigation.handleMenuFocusout" + data-wp-class--has-modal-open="state.isMenuOpen" + data-wp-class--is-menu-open="state.isMenuOpen" + data-wp-watch="effects.initMenu" + data-wp-on--keydown="actions.handleMenuKeydown" + data-wp-on--focusout="actions.handleMenuFocusout" tabindex="-1" '; $responsive_dialog_directives = ' - data-wp-bind--aria-modal="selectors.core.navigation.ariaModal" - data-wp-bind--aria-label="selectors.core.navigation.ariaLabel" - data-wp-bind--role="selectors.core.navigation.roleAttribute" - data-wp-effect="effects.core.navigation.focusFirstElement" + data-wp-bind--aria-modal="state.ariaModal" + data-wp-bind--aria-label="state.ariaLabel" + data-wp-bind--role="state.roleAttribute" + data-wp-watch="effects.focusFirstElement" '; $close_button_directives = ' - data-wp-on--click="actions.core.navigation.closeMenuOnClick" + data-wp-on--click="actions.closeMenuOnClick" '; } @@ -521,19 +521,15 @@ private static function get_nav_element_directives( $should_load_view_script ) { // When adding to this array be mindful of security concerns. $nav_element_context = wp_json_encode( array( - 'core' => array( - 'navigation' => array( - 'overlayOpenedBy' => array(), - 'type' => 'overlay', - 'roleAttribute' => '', - 'ariaLabel' => __( 'Menu' ), - ), - ), + 'overlayOpenedBy' => array(), + 'type' => 'overlay', + 'roleAttribute' => '', + 'ariaLabel' => __( 'Menu' ), ), JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_AMP ); return ' - data-wp-interactive + data-wp-interactive=\'{"namespace":"core/navigation"}\' data-wp-context=\'' . $nav_element_context . '\' '; } From 3fa96f04f77009d14099e2b803ced9db321d38fb Mon Sep 17 00:00:00 2001 From: David Arenas Date: Tue, 14 Nov 2023 20:33:05 +0100 Subject: [PATCH 43/63] Add missing store namespace --- packages/block-library/src/query/view.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/block-library/src/query/view.js b/packages/block-library/src/query/view.js index 897fbfb23fe519..a311f8f4c42369 100644 --- a/packages/block-library/src/query/view.js +++ b/packages/block-library/src/query/view.js @@ -24,7 +24,7 @@ const isValidEvent = ( event ) => ! event.shiftKey && ! event.defaultPrevented; -store( { +store( 'core/query', { state: { get startAnimation() { return getContext().animation === 'start'; From a16d9571bb86594f2d57dd04fbb2b4f6e613e644 Mon Sep 17 00:00:00 2001 From: David Arenas Date: Tue, 14 Nov 2023 20:34:42 +0100 Subject: [PATCH 44/63] Allow forward slashes in namespaces --- packages/interactivity/src/vdom.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/interactivity/src/vdom.js b/packages/interactivity/src/vdom.js index 3165484efd0b41..b1342ac271a8e2 100644 --- a/packages/interactivity/src/vdom.js +++ b/packages/interactivity/src/vdom.js @@ -28,9 +28,9 @@ const directiveParser = new RegExp( // 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 or underscores. -// References don't have any restrictions. -const nsPathRegExp = /^([\w-_]+)::(.+)$/; +// 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(); From b46004d2e44d2512d4c93f08d946d9578191546e Mon Sep 17 00:00:00 2001 From: David Arenas Date: Tue, 14 Nov 2023 21:02:02 +0100 Subject: [PATCH 45/63] Migrate Search block --- packages/block-library/src/search/index.php | 24 ++-- packages/block-library/src/search/view.js | 121 ++++++++++---------- 2 files changed, 70 insertions(+), 75 deletions(-) diff --git a/packages/block-library/src/search/index.php b/packages/block-library/src/search/index.php index f00ecfe6abe1cc..ec7e763ecb1f60 100644 --- a/packages/block-library/src/search/index.php +++ b/packages/block-library/src/search/index.php @@ -80,8 +80,8 @@ function render_block_core_search( $attributes, $content, $block ) { $is_expandable_searchfield = 'button-only' === $button_position && 'expand-searchfield' === $button_behavior; if ( $is_expandable_searchfield ) { - $input->set_attribute( 'data-wp-bind--aria-hidden', '!context.core.search.isSearchInputVisible' ); - $input->set_attribute( 'data-wp-bind--tabindex', 'selectors.core.search.tabindex' ); + $input->set_attribute( 'data-wp-bind--aria-hidden', '!context.isSearchInputVisible' ); + $input->set_attribute( 'data-wp-bind--tabindex', 'state.tabindex' ); // Adding these attributes manually is needed until the Interactivity API SSR logic is added to core. $input->set_attribute( 'aria-hidden', 'true' ); $input->set_attribute( 'tabindex', '-1' ); @@ -145,11 +145,11 @@ function render_block_core_search( $attributes, $content, $block ) { if ( $button->next_tag() ) { $button->add_class( implode( ' ', $button_classes ) ); if ( 'expand-searchfield' === $attributes['buttonBehavior'] && 'button-only' === $attributes['buttonPosition'] ) { - $button->set_attribute( 'data-wp-bind--aria-label', 'selectors.core.search.ariaLabel' ); - $button->set_attribute( 'data-wp-bind--aria-controls', 'selectors.core.search.ariaControls' ); - $button->set_attribute( 'data-wp-bind--aria-expanded', 'context.core.search.isSearchInputVisible' ); - $button->set_attribute( 'data-wp-bind--type', 'selectors.core.search.type' ); - $button->set_attribute( 'data-wp-on--click', 'actions.core.search.openSearchInput' ); + $button->set_attribute( 'data-wp-bind--aria-label', 'state.ariaLabel' ); + $button->set_attribute( 'data-wp-bind--aria-controls', 'state.ariaControls' ); + $button->set_attribute( 'data-wp-bind--aria-expanded', 'context.isSearchInputVisible' ); + $button->set_attribute( 'data-wp-bind--type', 'state.type' ); + $button->set_attribute( 'data-wp-on--click', 'actions.openSearchInput' ); // Adding these attributes manually is needed until the Interactivity API SSR logic is added to core. $button->set_attribute( 'aria-label', __( 'Expand search field' ) ); $button->set_attribute( 'aria-controls', 'wp-block-search__input-' . $input_id ); @@ -176,11 +176,11 @@ function render_block_core_search( $attributes, $content, $block ) { $aria_label_expanded = __( 'Submit Search' ); $aria_label_collapsed = __( 'Expand search field' ); $form_directives = ' - data-wp-interactive - data-wp-context=\'{ "core": { "search": { "isSearchInputVisible": ' . $open_by_default . ', "inputId": "' . $input_id . '", "ariaLabelExpanded": "' . $aria_label_expanded . '", "ariaLabelCollapsed": "' . $aria_label_collapsed . '" } } }\' - data-wp-class--wp-block-search__searchfield-hidden="!context.core.search.isSearchInputVisible" - data-wp-on--keydown="actions.core.search.handleSearchKeydown" - data-wp-on--focusout="actions.core.search.handleSearchFocusout" + data-wp-interactive=\'{ "namespace": "core/search" }\' + data-wp-context=\'{ "isSearchInputVisible": ' . $open_by_default . ', "inputId": "' . $input_id . '", "ariaLabelExpanded": "' . $aria_label_expanded . '", "ariaLabelCollapsed": "' . $aria_label_collapsed . '" }\' + data-wp-class--wp-block-search__searchfield-hidden="!context.isSearchInputVisible" + data-wp-on--keydown="actions.handleSearchKeydown" + data-wp-on--focusout="actions.handleSearchFocusout" '; } diff --git a/packages/block-library/src/search/view.js b/packages/block-library/src/search/view.js index d99dfc5696ccbb..b633bf971f363a 100644 --- a/packages/block-library/src/search/view.js +++ b/packages/block-library/src/search/view.js @@ -1,73 +1,68 @@ /** * WordPress dependencies */ -import { store as wpStore } from '@wordpress/interactivity'; +import { store, getContext, getElement } from '@wordpress/interactivity'; -wpStore( { - selectors: { - core: { - search: { - ariaLabel: ( { context } ) => { - const { ariaLabelCollapsed, ariaLabelExpanded } = - context.core.search; - return context.core.search.isSearchInputVisible - ? ariaLabelExpanded - : ariaLabelCollapsed; - }, - ariaControls: ( { context } ) => { - return context.core.search.isSearchInputVisible - ? null - : context.core.search.inputId; - }, - type: ( { context } ) => { - return context.core.search.isSearchInputVisible - ? 'submit' - : 'button'; - }, - tabindex: ( { context } ) => { - return context.core.search.isSearchInputVisible - ? '0' - : '-1'; - }, - }, +const { actions } = store( 'core/search', { + state: { + get ariaLabel() { + const { + isSearchInputVisible, + ariaLabelCollapsed, + ariaLabelExpanded, + } = getContext(); + return isSearchInputVisible + ? ariaLabelExpanded + : ariaLabelCollapsed; + }, + get ariaControls() { + const { isSearchInputVisible, inputId } = getContext(); + return isSearchInputVisible ? null : inputId; + }, + get type() { + const { isSearchInputVisible } = getContext(); + return isSearchInputVisible ? 'submit' : 'button'; + }, + get tabindex() { + const { isSearchInputVisible } = getContext(); + return isSearchInputVisible ? '0' : '-1'; }, }, actions: { - core: { - search: { - openSearchInput: ( { context, event, ref } ) => { - if ( ! context.core.search.isSearchInputVisible ) { - event.preventDefault(); - context.core.search.isSearchInputVisible = true; - ref.parentElement.querySelector( 'input' ).focus(); - } - }, - closeSearchInput: ( { context } ) => { - context.core.search.isSearchInputVisible = false; - }, - handleSearchKeydown: ( store ) => { - const { actions, event, ref } = store; - // If Escape close the menu. - if ( event?.key === 'Escape' ) { - actions.core.search.closeSearchInput( store ); - ref.querySelector( 'button' ).focus(); - } - }, - handleSearchFocusout: ( store ) => { - const { actions, event, ref } = store; - // If focus is outside search form, and in the document, close menu - // event.target === The element losing focus - // event.relatedTarget === The element receiving focus (if any) - // When focusout is outside the document, - // `window.document.activeElement` doesn't change. - if ( - ! ref.contains( event.relatedTarget ) && - event.target !== window.document.activeElement - ) { - actions.core.search.closeSearchInput( store ); - } - }, - }, + openSearchInput( event ) { + const ctx = getContext(); + const { ref } = getElement(); + if ( ! ctx.isSearchInputVisible ) { + event.preventDefault(); + ctx.isSearchInputVisible = true; + ref.parentElement.querySelector( 'input' ).focus(); + } + }, + closeSearchInput() { + const ctx = getContext(); + ctx.isSearchInputVisible = false; + }, + handleSearchKeydown( event ) { + const { ref } = getElement(); + // If Escape close the menu. + if ( event?.key === 'Escape' ) { + actions.closeSearchInput(); + ref.querySelector( 'button' ).focus(); + } + }, + handleSearchFocusout( event ) { + const { ref } = getElement(); + // If focus is outside search form, and in the document, close menu + // event.target === The element losing focus + // event.relatedTarget === The element receiving focus (if any) + // When focusout is outside the document, + // `window.document.activeElement` doesn't change. + if ( + ! ref.contains( event.relatedTarget ) && + event.target !== window.document.activeElement + ) { + actions.closeSearchInput(); + } }, }, } ); From 7e5580c79f13a8f3580c2f40fdbdce65a1acfc13 Mon Sep 17 00:00:00 2001 From: David Arenas Date: Mon, 27 Nov 2023 13:47:20 +0100 Subject: [PATCH 46/63] Update Initial State and its tests --- .../class-wp-interactivity-initial-state.php | 25 ++- ...ss-wp-interactivity-initial-state-test.php | 111 +++++++++++ .../class-wp-interactivity-store-test.php | 186 ------------------ 3 files changed, 128 insertions(+), 194 deletions(-) create mode 100644 phpunit/experimental/interactivity-api/class-wp-interactivity-initial-state-test.php delete mode 100644 phpunit/experimental/interactivity-api/class-wp-interactivity-store-test.php diff --git a/lib/experimental/interactivity-api/class-wp-interactivity-initial-state.php b/lib/experimental/interactivity-api/class-wp-interactivity-initial-state.php index ef7e7509d5faa4..a1edbcff776fbc 100644 --- a/lib/experimental/interactivity-api/class-wp-interactivity-initial-state.php +++ b/lib/experimental/interactivity-api/class-wp-interactivity-initial-state.php @@ -2,6 +2,15 @@ /** * WP_Interactivity_Initial_State class * + * @package Gutenberg + * @subpackage Interactivity API + */ + +if ( class_exists( 'WP_Interactivity_Initial_State' ) ) { + return; +} + +/** * Manages the initial state of the Interactivity API store in the server and * its serialization so it can be restored in the browser upon hydration. * @@ -19,28 +28,28 @@ class WP_Interactivity_Initial_State { /** * Get state from a given namespace. * - * @param string $namespace Namespace. + * @param string $store_ns Namespace. * * @return array The requested state. */ - public static function get_state( $namespace = null ) { - if ( ! $namespace ) { + public static function get_state( $store_ns = null ) { + if ( ! $store_ns ) { return self::$initial_state; } - return self::$initial_state[ $namespace ] ?? array(); + return self::$initial_state[ $store_ns ] ?? array(); } /** * Merge data into the state with the given namespace. * - * @param string $namespace Namespace. + * @param string $store_ns Namespace. * @param array $data State to merge. * * @return void */ - public static function merge_state( $namespace, $data ) { - self::$initial_state[ $namespace ] = array_replace_recursive( - self::get_state( $namespace ), + public static function merge_state( $store_ns, $data ) { + self::$initial_state[ $store_ns ] = array_replace_recursive( + self::get_state( $store_ns ), $data ); } diff --git a/phpunit/experimental/interactivity-api/class-wp-interactivity-initial-state-test.php b/phpunit/experimental/interactivity-api/class-wp-interactivity-initial-state-test.php new file mode 100644 index 00000000000000..ece4ee9c39f01e --- /dev/null +++ b/phpunit/experimental/interactivity-api/class-wp-interactivity-initial-state-test.php @@ -0,0 +1,111 @@ +assertEmpty( WP_Interactivity_Initial_State::get_data() ); + } + + public function test_initial_state_can_be_merged() { + $state = array( + 'a' => 1, + 'b' => 2, + 'nested' => array( + 'c' => 3, + ), + ); + WP_Interactivity_Initial_State::merge_state( 'core', $state ); + $this->assertSame( $state, WP_Interactivity_Initial_State::get_state( 'core' ) ); + } + + public function test_initial_state_can_be_extended() { + WP_Interactivity_Initial_State::merge_state( 'core', array( 'a' => 1 ) ); + WP_Interactivity_Initial_State::merge_state( 'core', array( 'b' => 2 ) ); + WP_Interactivity_Initial_State::merge_state( 'custom', array( 'c' => 3 ) ); + $this->assertSame( + array( + 'core' => array( + 'a' => 1, + 'b' => 2, + ), + 'custom' => array( + 'c' => 3, + ), + ), + WP_Interactivity_Initial_State::get_data() + ); + } + + public function test_initial_state_existing_props_should_be_overwritten() { + WP_Interactivity_Initial_State::merge_state( 'core', array( 'a' => 1 ) ); + WP_Interactivity_Initial_State::merge_state( 'core', array( 'a' => 'overwritten' ) ); + $this->assertSame( + array( + 'core' => array( + 'a' => 'overwritten', + ), + ), + WP_Interactivity_Initial_State::get_data() + ); + } + + public function test_initial_state_existing_indexed_arrays_should_be_replaced() { + WP_Interactivity_Initial_State::merge_state( 'core', array( 'a' => array( 1, 2 ) ) ); + WP_Interactivity_Initial_State::merge_state( 'core', array( 'a' => array( 3, 4 ) ) ); + $this->assertSame( + array( + 'core' => array( + 'a' => array( 3, 4 ), + ), + ), + WP_Interactivity_Initial_State::get_data() + ); + } + + public function test_initial_state_should_be_correctly_rendered() { + WP_Interactivity_Initial_State::merge_state( 'core', array( 'a' => 1 ) ); + WP_Interactivity_Initial_State::merge_state( 'core', array( 'b' => 2 ) ); + WP_Interactivity_Initial_State::merge_state( 'custom', array( 'c' => 3 ) ); + + ob_start(); + WP_Interactivity_Initial_State::render(); + $rendered = ob_get_clean(); + $this->assertSame( + '', + $rendered + ); + } + + public function test_initial_state_should_also_escape_tags_and_amps() { + WP_Interactivity_Initial_State::merge_state( + 'test', + array( + 'amps' => 'http://site.test/?foo=1&baz=2&bar=3', + 'tags' => 'Do not do this: + inline (i.e., without new line or blank characters in between) to + ensure it is the only child node. Otherwise, tests could fail. -->
  • From 67cd96c26b6ae64a3a530b5df87cfea8488c972f Mon Sep 17 00:00:00 2001 From: David Arenas Date: Mon, 27 Nov 2023 18:21:43 +0100 Subject: [PATCH 52/63] Update Query phpunit tests --- phpunit/blocks/render-query-test.php | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/phpunit/blocks/render-query-test.php b/phpunit/blocks/render-query-test.php index 2d81bfdb513b33..d8b8bd7ae8d1cd 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::effects.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::effects.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' ) ); } From f3db18d4b51559b479ad9c3ff99fe670b743e6e0 Mon Sep 17 00:00:00 2001 From: David Arenas Date: Mon, 27 Nov 2023 18:22:15 +0100 Subject: [PATCH 53/63] Fix Query's Interactivity API context definition --- packages/block-library/src/query/index.php | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/packages/block-library/src/query/index.php b/packages/block-library/src/query/index.php index ac83db8dcd7c82..6daf2411233bdb 100644 --- a/packages/block-library/src/query/index.php +++ b/packages/block-library/src/query/index.php @@ -28,12 +28,8 @@ function render_block_core_query( $attributes, $content, $block ) { 'data-wp-context', wp_json_encode( array( - 'core' => array( - 'query' => array( - 'loadingText' => __( 'Loading page, please wait.' ), - 'loadedText' => __( 'Page Loaded.' ), - ), - ), + 'loadingText' => __( 'Loading page, please wait.' ), + 'loadedText' => __( 'Page Loaded.' ), ), JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_AMP ) From cbc7a3fae97610883eb6dbd9f8834a58e644234c Mon Sep 17 00:00:00 2001 From: David Arenas Date: Mon, 27 Nov 2023 18:50:45 +0100 Subject: [PATCH 54/63] Fix action name in Navigation --- packages/block-library/src/navigation/view.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/block-library/src/navigation/view.js b/packages/block-library/src/navigation/view.js index 7b1998b84253b2..cef33f1ad083d0 100644 --- a/packages/block-library/src/navigation/view.js +++ b/packages/block-library/src/navigation/view.js @@ -32,7 +32,7 @@ const { state, actions } = store( 'core/navigation', { focusableElements[ focusableElements.length - 1 ]; } }, - focusFirstElementinitMenu() { + focusFirstElement() { const { ref } = getElement(); if ( state.isMenuOpen ) { ref.querySelector( From 0924a467be40fcae2edd813929353a1af8dba45f Mon Sep 17 00:00:00 2001 From: David Arenas Date: Tue, 28 Nov 2023 17:49:14 +0100 Subject: [PATCH 55/63] Make `double()` prop a getter Co-authored-by: Luis Herranz --- packages/e2e-tests/plugins/interactive-blocks/store-tag/view.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/e2e-tests/plugins/interactive-blocks/store-tag/view.js b/packages/e2e-tests/plugins/interactive-blocks/store-tag/view.js index 48cc9faf6f4dd7..625628cdf365b0 100644 --- a/packages/e2e-tests/plugins/interactive-blocks/store-tag/view.js +++ b/packages/e2e-tests/plugins/interactive-blocks/store-tag/view.js @@ -9,7 +9,7 @@ counter: { // `value` is defined in the server. double() { - return state.counter.value * 2 + get double() { }, clicks: 0, }, From 83dd4b0ea75b8d3d0484ddcecf639ca22c839027 Mon Sep 17 00:00:00 2001 From: David Arenas Date: Tue, 28 Nov 2023 17:51:24 +0100 Subject: [PATCH 56/63] Fix `double()` prop definition --- packages/e2e-tests/plugins/interactive-blocks/store-tag/view.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/e2e-tests/plugins/interactive-blocks/store-tag/view.js b/packages/e2e-tests/plugins/interactive-blocks/store-tag/view.js index 625628cdf365b0..99bbf93cf18e0f 100644 --- a/packages/e2e-tests/plugins/interactive-blocks/store-tag/view.js +++ b/packages/e2e-tests/plugins/interactive-blocks/store-tag/view.js @@ -8,8 +8,8 @@ state: { counter: { // `value` is defined in the server. - double() { get double() { + return state.counter.value * 2 }, clicks: 0, }, From 93d97b0292e90d7204e0dadbecac0250b46ed08d Mon Sep 17 00:00:00 2001 From: David Arenas Date: Tue, 28 Nov 2023 18:01:10 +0100 Subject: [PATCH 57/63] Rename effects to callbacks in tests --- .../plugins/interactive-blocks/directive-watch/render.php | 6 +++--- .../plugins/interactive-blocks/directive-watch/view.js | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/e2e-tests/plugins/interactive-blocks/directive-watch/render.php b/packages/e2e-tests/plugins/interactive-blocks/directive-watch/render.php index acccdbb3db28dc..9434bd777fd24a 100644 --- a/packages/e2e-tests/plugins/interactive-blocks/directive-watch/render.php +++ b/packages/e2e-tests/plugins/interactive-blocks/directive-watch/render.php @@ -10,7 +10,7 @@
    @@ -19,11 +19,11 @@ data-testid="element in the DOM" >
    -
    +
    0 diff --git a/packages/e2e-tests/plugins/interactive-blocks/directive-watch/view.js b/packages/e2e-tests/plugins/interactive-blocks/directive-watch/view.js index fea8f60776a978..cd844bef9efb10 100644 --- a/packages/e2e-tests/plugins/interactive-blocks/directive-watch/view.js +++ b/packages/e2e-tests/plugins/interactive-blocks/directive-watch/view.js @@ -39,7 +39,7 @@ state.counter = state.counter + 1; }, }, - effects: { + callbacks: { elementAddedToTheDOM: () => { state.isElementInTheDOM = true; From 3010ab12a1c17242f3e8a1e8a5e0ee403c6a42e4 Mon Sep 17 00:00:00 2001 From: David Arenas Date: Tue, 28 Nov 2023 18:04:46 +0100 Subject: [PATCH 58/63] Rename effects to callbacks in blocks --- .../wordpress-6.5/class-wp-navigation-block-renderer.php | 4 ++-- packages/block-library/src/image/index.php | 8 ++++---- packages/block-library/src/image/view.js | 6 +++--- packages/block-library/src/navigation/index.php | 2 +- packages/block-library/src/navigation/view.js | 2 +- .../block-library/src/query-pagination-next/index.php | 2 +- .../block-library/src/query-pagination-previous/index.php | 2 +- packages/block-library/src/query/view.js | 2 +- 8 files changed, 14 insertions(+), 14 deletions(-) diff --git a/lib/compat/wordpress-6.5/class-wp-navigation-block-renderer.php b/lib/compat/wordpress-6.5/class-wp-navigation-block-renderer.php index 63ad3235c539a9..52ec4f508246ac 100644 --- a/lib/compat/wordpress-6.5/class-wp-navigation-block-renderer.php +++ b/lib/compat/wordpress-6.5/class-wp-navigation-block-renderer.php @@ -435,7 +435,7 @@ private static function get_responsive_container_markup( $attributes, $inner_blo $responsive_container_directives = ' data-wp-class--has-modal-open="state.isMenuOpen" data-wp-class--is-menu-open="state.isMenuOpen" - data-wp-watch="effects.initMenu" + data-wp-watch="callbacks.initMenu" data-wp-on--keydown="actions.handleMenuKeydown" data-wp-on--focusout="actions.handleMenuFocusout" tabindex="-1" @@ -444,7 +444,7 @@ private static function get_responsive_container_markup( $attributes, $inner_blo data-wp-bind--aria-modal="state.ariaModal" data-wp-bind--aria-label="state.ariaLabel" data-wp-bind--role="state.roleAttribute" - data-wp-watch="effects.focusFirstElement" + data-wp-watch="callbacks.focusFirstElement" '; $close_button_directives = ' data-wp-on--click="actions.closeMenuOnClick" diff --git a/packages/block-library/src/image/index.php b/packages/block-library/src/image/index.php index ef8e479a207968..5667c71c45affc 100644 --- a/packages/block-library/src/image/index.php +++ b/packages/block-library/src/image/index.php @@ -214,14 +214,14 @@ function block_core_image_render_lightbox( $block_content, $block ) { ) ); $w->next_tag( 'img' ); - $w->set_attribute( 'data-wp-init', 'effects.initOriginImage' ); + $w->set_attribute( 'data-wp-init', 'callbacks.initOriginImage' ); $w->set_attribute( 'data-wp-on--load', 'actions.handleLoad' ); - $w->set_attribute( 'data-wp-watch', 'effects.setButtonStyles' ); + $w->set_attribute( 'data-wp-watch', 'callbacks.setButtonStyles' ); // We need to set an event callback on the `img` specifically // because the `figure` element can also contain a caption, and // we don't want to trigger the lightbox when the caption is clicked. $w->set_attribute( 'data-wp-on--click', 'actions.showLightbox' ); - $w->set_attribute( 'data-wp-watch--setStylesOnResize', 'effects.setStylesOnResize' ); + $w->set_attribute( 'data-wp-watch--setStylesOnResize', 'callbacks.setStylesOnResize' ); $body_content = $w->get_updated_html(); // Add a button alongside image in the body content. @@ -309,7 +309,7 @@ class="lightbox-trigger" data-wp-class--active="context.lightboxEnabled" data-wp-class--hideAnimationEnabled="context.hideAnimationEnabled" data-wp-bind--aria-modal="state.ariaModal" - data-wp-watch="effects.initLightbox" + data-wp-watch="callbacks.initLightbox" data-wp-on--keydown="actions.handleKeydown" data-wp-on--touchstart="actions.handleTouchStart" data-wp-on--touchmove="actions.handleTouchMove" diff --git a/packages/block-library/src/image/view.js b/packages/block-library/src/image/view.js index 11397098be0a28..315ed995f26cfc 100644 --- a/packages/block-library/src/image/view.js +++ b/packages/block-library/src/image/view.js @@ -77,7 +77,7 @@ function handleScroll( ctx ) { } } -const { state, actions, effects } = store( 'core/image', { +const { state, actions, callbacks } = store( 'core/image', { state: { windowWidth: window.innerWidth, windowHeight: window.innerHeight, @@ -200,7 +200,7 @@ const { state, actions, effects } = store( 'core/image', { const { ref } = getElement(); ctx.imageLoaded = true; ctx.imageCurrentSrc = ref.currentSrc; - effects.setButtonStyles(); + callbacks.setButtonStyles(); }, handleTouchStart() { isTouching = true; @@ -225,7 +225,7 @@ const { state, actions, effects } = store( 'core/image', { isTouching = false; }, }, - effects: { + callbacks: { initOriginImage() { const ctx = getContext(); const { ref } = getElement(); diff --git a/packages/block-library/src/navigation/index.php b/packages/block-library/src/navigation/index.php index e82ff842eda918..6550d896656b1b 100644 --- a/packages/block-library/src/navigation/index.php +++ b/packages/block-library/src/navigation/index.php @@ -103,7 +103,7 @@ function block_core_navigation_add_directives_to_submenu( $tags, $block_attribut // Add directives to the parent `
  • `. $tags->set_attribute( 'data-wp-interactive', '{ "namespace": "core/navigation" }' ); $tags->set_attribute( 'data-wp-context', '{ "submenuOpenedBy": {}, "type": "submenu" }' ); - $tags->set_attribute( 'data-wp-watch', 'effects.initMenu' ); + $tags->set_attribute( 'data-wp-watch', 'callbacks.initMenu' ); $tags->set_attribute( 'data-wp-on--focusout', 'actions.handleMenuFocusout' ); $tags->set_attribute( 'data-wp-on--keydown', 'actions.handleMenuKeydown' ); diff --git a/packages/block-library/src/navigation/view.js b/packages/block-library/src/navigation/view.js index cef33f1ad083d0..82ef0c8ef3291f 100644 --- a/packages/block-library/src/navigation/view.js +++ b/packages/block-library/src/navigation/view.js @@ -19,7 +19,7 @@ const focusableSelectors = [ document.addEventListener( 'click', () => {} ); const { state, actions } = store( 'core/navigation', { - effects: { + callbacks: { initMenu() { const ctx = getContext(); const { ref } = getElement(); diff --git a/packages/block-library/src/query-pagination-next/index.php b/packages/block-library/src/query-pagination-next/index.php index 004c45af9f730d..ca134f62192f9e 100644 --- a/packages/block-library/src/query-pagination-next/index.php +++ b/packages/block-library/src/query-pagination-next/index.php @@ -74,7 +74,7 @@ function render_block_core_query_pagination_next( $attributes, $content, $block $p->set_attribute( 'data-wp-key', 'query-pagination-next' ); $p->set_attribute( 'data-wp-on--click', 'core/query::actions.navigate' ); $p->set_attribute( 'data-wp-on--mouseenter', 'core/query::actions.prefetch' ); - $p->set_attribute( 'data-wp-watch', 'core/query::effects.prefetch' ); + $p->set_attribute( 'data-wp-watch', 'core/query::callbacks.prefetch' ); $content = $p->get_updated_html(); } } diff --git a/packages/block-library/src/query-pagination-previous/index.php b/packages/block-library/src/query-pagination-previous/index.php index 1bad6bcdab1b4f..b49130a44d8ddf 100644 --- a/packages/block-library/src/query-pagination-previous/index.php +++ b/packages/block-library/src/query-pagination-previous/index.php @@ -62,7 +62,7 @@ function render_block_core_query_pagination_previous( $attributes, $content, $bl $p->set_attribute( 'data-wp-key', 'query-pagination-previous' ); $p->set_attribute( 'data-wp-on--click', 'core/query::actions.navigate' ); $p->set_attribute( 'data-wp-on--mouseenter', 'core/query::actions.prefetch' ); - $p->set_attribute( 'data-wp-watch', 'core/query::effects.prefetch' ); + $p->set_attribute( 'data-wp-watch', 'core/query::callbacks.prefetch' ); $content = $p->get_updated_html(); } } diff --git a/packages/block-library/src/query/view.js b/packages/block-library/src/query/view.js index a311f8f4c42369..ccf70810047673 100644 --- a/packages/block-library/src/query/view.js +++ b/packages/block-library/src/query/view.js @@ -81,7 +81,7 @@ store( 'core/query', { } }, }, - effects: { + callbacks: { *prefetch() { const { url } = getContext(); const { ref } = getElement(); From 931357f29fe3562a43dcfbcbf49e9ce3666ff0b1 Mon Sep 17 00:00:00 2001 From: David Arenas Date: Tue, 28 Nov 2023 18:05:24 +0100 Subject: [PATCH 59/63] Rename effects to callbacks in unit tests --- phpunit/blocks/render-query-test.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/phpunit/blocks/render-query-test.php b/phpunit/blocks/render-query-test.php index d8b8bd7ae8d1cd..796c4ae098867c 100644 --- a/phpunit/blocks/render-query-test.php +++ b/phpunit/blocks/render-query-test.php @@ -79,13 +79,13 @@ public function test_rendering_query_with_enhanced_pagination() { $this->assertSame( 'query-pagination-previous', $p->get_attribute( 'data-wp-key' ) ); $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::effects.prefetch', $p->get_attribute( 'data-wp-watch' ) ); + $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( '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::effects.prefetch', $p->get_attribute( 'data-wp-watch' ) ); + $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' ) ); From bea164952b01ac0c0d42837c5343721a7089c313 Mon Sep 17 00:00:00 2001 From: David Arenas Date: Tue, 28 Nov 2023 18:35:43 +0100 Subject: [PATCH 60/63] Move callbacks at the end of the store definition --- packages/block-library/src/navigation/view.js | 44 +++++++++---------- 1 file changed, 22 insertions(+), 22 deletions(-) diff --git a/packages/block-library/src/navigation/view.js b/packages/block-library/src/navigation/view.js index 82ef0c8ef3291f..dc7a3c4d00dcd0 100644 --- a/packages/block-library/src/navigation/view.js +++ b/packages/block-library/src/navigation/view.js @@ -19,28 +19,6 @@ const focusableSelectors = [ document.addEventListener( 'click', () => {} ); const { state, actions } = store( 'core/navigation', { - callbacks: { - initMenu() { - const ctx = getContext(); - const { ref } = getElement(); - if ( state.isMenuOpen ) { - const focusableElements = - ref.querySelectorAll( focusableSelectors ); - ctx.modal = ref; - ctx.firstFocusableElement = focusableElements[ 0 ]; - ctx.lastFocusableElement = - focusableElements[ focusableElements.length - 1 ]; - } - }, - focusFirstElement() { - const { ref } = getElement(); - if ( state.isMenuOpen ) { - ref.querySelector( - '.wp-block-navigation-item > *:first-child' - ).focus(); - } - }, - }, state: { get roleAttribute() { const ctx = getContext(); @@ -186,4 +164,26 @@ const { state, actions } = store( 'core/navigation', { } }, }, + callbacks: { + initMenu() { + const ctx = getContext(); + const { ref } = getElement(); + if ( state.isMenuOpen ) { + const focusableElements = + ref.querySelectorAll( focusableSelectors ); + ctx.modal = ref; + ctx.firstFocusableElement = focusableElements[ 0 ]; + ctx.lastFocusableElement = + focusableElements[ focusableElements.length - 1 ]; + } + }, + focusFirstElement() { + const { ref } = getElement(); + if ( state.isMenuOpen ) { + ref.querySelector( + '.wp-block-navigation-item > *:first-child' + ).focus(); + } + }, + }, } ); From c0664b6b1c7fb6589596c050392cbec5a8844fca Mon Sep 17 00:00:00 2001 From: David Arenas Date: Tue, 28 Nov 2023 18:43:35 +0100 Subject: [PATCH 61/63] Remove `layout-init` directive --- packages/interactivity/src/directives.js | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/packages/interactivity/src/directives.js b/packages/interactivity/src/directives.js index 415138011a1ede..0793dc0cc5d5ba 100644 --- a/packages/interactivity/src/directives.js +++ b/packages/interactivity/src/directives.js @@ -79,16 +79,6 @@ export default () => { } ); } ); - // data-wp-layout-init--[name] - directive( - 'layout-init', - ( { directives: { 'layout-init': layoutInit }, evaluate } ) => { - layoutInit.forEach( ( entry ) => { - useLayoutEffect( () => evaluate( entry ), [] ); - } ); - } - ); - // data-wp-init--[name] directive( 'init', ( { directives: { init }, evaluate } ) => { init.forEach( ( entry ) => { From 9869b642dd0a23c9016a08f4398fa778faac7cd0 Mon Sep 17 00:00:00 2001 From: David Arenas Date: Tue, 28 Nov 2023 18:46:10 +0100 Subject: [PATCH 62/63] Add default args for `openMenu` and `closeMenu` --- packages/block-library/src/navigation/view.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/block-library/src/navigation/view.js b/packages/block-library/src/navigation/view.js index dc7a3c4d00dcd0..ba8e6d1a6683a4 100644 --- a/packages/block-library/src/navigation/view.js +++ b/packages/block-library/src/navigation/view.js @@ -137,7 +137,7 @@ const { state, actions } = store( 'core/navigation', { } }, - openMenu( menuOpenedOn ) { + openMenu( menuOpenedOn = 'click' ) { const { type } = getContext(); state.menuOpenedBy[ menuOpenedOn ] = true; if ( type === 'overlay' ) { @@ -146,7 +146,7 @@ const { state, actions } = store( 'core/navigation', { } }, - closeMenu( menuClosedOn ) { + closeMenu( menuClosedOn = 'click' ) { const ctx = getContext(); state.menuOpenedBy[ menuClosedOn ] = false; // Check if the menu is still open or not. From 38f804bc183c098ef457713da0cb1b0a54559d70 Mon Sep 17 00:00:00 2001 From: David Arenas Date: Tue, 28 Nov 2023 18:49:55 +0100 Subject: [PATCH 63/63] Change Interactivity Store script tag ID --- .../interactivity-api/class-wp-interactivity-store.php | 2 +- .../interactivity-api/class-wp-interactivity-store-test.php | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/experimental/interactivity-api/class-wp-interactivity-store.php b/lib/experimental/interactivity-api/class-wp-interactivity-store.php index 89cb58700554a9..c53701b14e8aff 100644 --- a/lib/experimental/interactivity-api/class-wp-interactivity-store.php +++ b/lib/experimental/interactivity-api/class-wp-interactivity-store.php @@ -62,7 +62,7 @@ public static function render() { return; } echo sprintf( - '', + '', wp_json_encode( self::$store, JSON_HEX_TAG | JSON_HEX_AMP ) ); } 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 ); }