From 2efa3287b6f00949affd6f9a3682e3afaad67a5d Mon Sep 17 00:00:00 2001 From: David Arenas Date: Fri, 26 May 2023 11:23:32 +0200 Subject: [PATCH 01/34] Start with package.json and README --- packages/interactivity/README.md | 1 + packages/interactivity/package.json | 35 +++++++++++++++++++++++++++++ 2 files changed, 36 insertions(+) create mode 100644 packages/interactivity/README.md create mode 100644 packages/interactivity/package.json diff --git a/packages/interactivity/README.md b/packages/interactivity/README.md new file mode 100644 index 00000000000000..508cc06588f752 --- /dev/null +++ b/packages/interactivity/README.md @@ -0,0 +1 @@ +# Interactivity diff --git a/packages/interactivity/package.json b/packages/interactivity/package.json new file mode 100644 index 00000000000000..b7a87690043c59 --- /dev/null +++ b/packages/interactivity/package.json @@ -0,0 +1,35 @@ +{ + "name": "@wordpress/interactivity", + "version": "0.1.0", + "description": "API for developing interactive blocks.", + "author": "The WordPress Contributors", + "license": "GPL-2.0-or-later", + "keywords": [ + "wordpress", + "gutenberg", + "interactivity" + ], + "homepage": "https://github.com/WordPress/gutenberg/tree/HEAD/packages/interactivity/README.md", + "repository": { + "type": "git", + "url": "https://github.com/WordPress/gutenberg.git", + "directory": "packages/interactivity" + }, + "bugs": { + "url": "https://github.com/WordPress/gutenberg/labels/%5BFeature%5D%20Interactivity%20API" + }, + "engines": { + "node": ">=12" + }, + "main": "build/index.js", + "module": "build-module/index.js", + "react-native": "src/index", + "dependencies": { + "@preact/signals": "^1.1.3", + "deepsignal": "^1.3.0", + "preact": "^10.13.2" + }, + "publishConfig": { + "access": "public" + } +} From 455396b37b7b1b7aeaa7aa3d0510fe3dae5ccc53 Mon Sep 17 00:00:00 2001 From: David Arenas Date: Fri, 26 May 2023 11:23:32 +0200 Subject: [PATCH 02/34] Add new package to docs/manifest.json --- docs/manifest.json | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/docs/manifest.json b/docs/manifest.json index d6759f051a6791..983915652d39be 100644 --- a/docs/manifest.json +++ b/docs/manifest.json @@ -1673,6 +1673,12 @@ "markdown_source": "../packages/icons/README.md", "parent": "packages" }, + { + "title": "@wordpress/interactivity", + "slug": "packages-interactivity", + "markdown_source": "../packages/interactivity/README.md", + "parent": "packages" + }, { "title": "@wordpress/interface", "slug": "packages-interface", From b48ac7cb6ec9781a37d698d3b157213bdf19089e Mon Sep 17 00:00:00 2001 From: David Arenas Date: Fri, 26 May 2023 11:23:32 +0200 Subject: [PATCH 03/34] Copy-paste runtime files from block-library --- packages/interactivity/src/constants.js | 1 + packages/interactivity/src/directives.js | 186 +++++++++++++++++++++++ packages/interactivity/src/hooks.js | 145 ++++++++++++++++++ packages/interactivity/src/hydration.js | 22 +++ packages/interactivity/src/index.js | 17 +++ packages/interactivity/src/store.js | 45 ++++++ packages/interactivity/src/utils.js | 66 ++++++++ packages/interactivity/src/vdom.js | 94 ++++++++++++ 8 files changed, 576 insertions(+) create mode 100644 packages/interactivity/src/constants.js create mode 100644 packages/interactivity/src/directives.js create mode 100644 packages/interactivity/src/hooks.js create mode 100644 packages/interactivity/src/hydration.js create mode 100644 packages/interactivity/src/index.js create mode 100644 packages/interactivity/src/store.js create mode 100644 packages/interactivity/src/utils.js create mode 100644 packages/interactivity/src/vdom.js diff --git a/packages/interactivity/src/constants.js b/packages/interactivity/src/constants.js new file mode 100644 index 00000000000000..f462753c9f8179 --- /dev/null +++ b/packages/interactivity/src/constants.js @@ -0,0 +1 @@ +export const directivePrefix = 'data-wp-'; diff --git a/packages/interactivity/src/directives.js b/packages/interactivity/src/directives.js new file mode 100644 index 00000000000000..e2a2e34e3451c3 --- /dev/null +++ b/packages/interactivity/src/directives.js @@ -0,0 +1,186 @@ +/** + * External dependencies + */ +import { useContext, useMemo, useEffect } from 'preact/hooks'; +import { deepSignal, peek } from 'deepsignal'; + +/** + * Internal dependencies + */ +import { useSignalEffect } from './utils'; +import { directive } from './hooks'; + +const isObject = ( item ) => + item && typeof item === 'object' && ! Array.isArray( item ); + +const mergeDeepSignals = ( target, source ) => { + for ( const k in source ) { + if ( typeof peek( target, k ) === 'undefined' ) { + target[ `$${ k }` ] = source[ `$${ k }` ]; + } else if ( + isObject( peek( target, k ) ) && + isObject( peek( source, k ) ) + ) { + mergeDeepSignals( + target[ `$${ k }` ].peek(), + source[ `$${ k }` ].peek() + ); + } + } +}; + +export default () => { + // data-wp-context + directive( + 'context', + ( { + directives: { + context: { default: context }, + }, + props: { children }, + context: inherited, + } ) => { + const { Provider } = inherited; + const inheritedValue = useContext( inherited ); + const value = useMemo( () => { + const localValue = deepSignal( context ); + mergeDeepSignals( localValue, inheritedValue ); + return localValue; + }, [ context, inheritedValue ] ); + + return { children }; + }, + { priority: 5 } + ); + + // 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-init.[name] + directive( 'init', ( { directives: { init }, context, evaluate } ) => { + const contextValue = useContext( context ); + Object.values( init ).forEach( ( path ) => { + useEffect( () => { + return evaluate( path, { context: contextValue } ); + }, [] ); + } ); + } ); + + // 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 } ); + }; + } ); + } ); + + // 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, + } ); + const currentClass = element.props.class || ''; + const classFinder = new RegExp( + `(^|\\s)${ name }(\\s|$)`, + 'g' + ); + if ( ! result ) + element.props.class = currentClass + .replace( classFinder, ' ' ) + .trim(); + else if ( ! classFinder.test( currentClass ) ) + element.props.class = currentClass + ? `${ currentClass } ${ name }` + : name; + + useEffect( () => { + // This seems necessary because Preact doesn't change the class + // names 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.classList.remove( name ); + } else { + element.ref.current.classList.add( name ); + } + }, [] ); + } ); + } + ); + + // data-wp-bind.[attribute] + 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; + + // 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( () => { + // 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 === false && attribute[ 4 ] !== '-' ) { + element.ref.current.removeAttribute( attribute ); + } else { + element.ref.current.setAttribute( + attribute, + result === true && attribute[ 4 ] !== '-' + ? '' + : result + ); + } + }, [] ); + } ); + } + ); + + // data-wp-ignore + directive( + 'ignore', + ( { + element: { + type: Type, + props: { innerHTML, ...rest }, + }, + } ) => { + // Preserve the initial inner HTML. + const cached = useMemo( () => innerHTML, [] ); + return ( + + ); + } + ); +}; diff --git a/packages/interactivity/src/hooks.js b/packages/interactivity/src/hooks.js new file mode 100644 index 00000000000000..e309990482ebc2 --- /dev/null +++ b/packages/interactivity/src/hooks.js @@ -0,0 +1,145 @@ +/** + * External dependencies + */ +import { h, options, createContext, cloneElement } from 'preact'; +import { useRef, useMemo } from 'preact/hooks'; +/** + * Internal dependencies + */ +import { rawStore as store } from './store'; + +// Main context. +const context = createContext( {} ); + +// WordPress Directives. +const directiveMap = {}; +const directivePriorities = {}; +export const directive = ( name, cb, { priority = 10 } = {} ) => { + directiveMap[ name ] = cb; + directivePriorities[ name ] = priority; +}; + +// Resolve the path to some property of the store object. +const resolve = ( path, ctx ) => { + let current = { ...store, context: ctx }; + path.split( '.' ).forEach( ( p ) => ( current = current[ p ] ) ); + return current; +}; + +// Generate the evaluate function. +const getEvaluate = + ( { ref } = {} ) => + ( path, extraArgs = {} ) => { + // 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; + }; + +// Separate directives by priority. The resulting array contains objects +// of directives grouped by same priority, and sorted in ascending order. +const usePriorityLevels = ( directives ) => + useMemo( () => { + const byPriority = Object.entries( directives ).reduce( + ( acc, [ name, values ] ) => { + const priority = directivePriorities[ name ]; + if ( ! acc[ priority ] ) acc[ priority ] = {}; + acc[ priority ][ name ] = values; + + return acc; + }, + {} + ); + + return Object.entries( byPriority ) + .sort( ( [ p1 ], [ p2 ] ) => p1 - p2 ) + .map( ( [ , obj ] ) => obj ); + }, [ directives ] ); + +// Directive wrapper. +const Directive = ( { type, directives, props: originalProps } ) => { + const ref = useRef( null ); + const element = h( type, { ...originalProps, ref } ); + const evaluate = useMemo( () => getEvaluate( { ref } ), [] ); + + // Add wrappers recursively for each priority level. + const byPriorityLevel = usePriorityLevels( directives ); + return ( + + ); +}; + +// Priority level wrapper. +const RecursivePriorityLevel = ( { + directives: [ directives, ...rest ], + element, + evaluate, + originalProps, +} ) => { + // This element needs to be a fresh copy so we are not modifying an already + // rendered element with Preact's internal properties initialized. This + // prevents an error with changes in `element.props.children` not being + // reflected in `element.__k`. + element = cloneElement( element ); + + // Recursively render the wrapper for the next priority level. + // + // Note that, even though we're instantiating a vnode with a + // `RecursivePriorityLevel` here, its render function will not be executed + // just yet. Actually, it will be delayed until the current render function + // has finished. That ensures directives in the current priorty level have + // run (and thus modified the passed `element`) before the next level. + const children = + rest.length > 0 ? ( + + ) : ( + element + ); + + const props = { ...originalProps, children }; + const directiveArgs = { directives, props, element, context, evaluate }; + + for ( const d in directives ) { + const wrapper = directiveMap[ d ]?.( directiveArgs ); + if ( wrapper !== undefined ) props.children = wrapper; + } + + return props.children; +}; + +// Preact Options Hook called each time a vnode is created. +const old = options.vnode; +options.vnode = ( vnode ) => { + if ( vnode.props.__directives ) { + const props = vnode.props; + const directives = props.__directives; + delete props.__directives; + vnode.props = { + type: vnode.type, + directives, + props, + }; + vnode.type = Directive; + } + + if ( old ) old( vnode ); +}; diff --git a/packages/interactivity/src/hydration.js b/packages/interactivity/src/hydration.js new file mode 100644 index 00000000000000..2fc34eeb64b9b5 --- /dev/null +++ b/packages/interactivity/src/hydration.js @@ -0,0 +1,22 @@ +/** + * External dependencies + */ +import { hydrate } from 'preact'; +/** + * Internal dependencies + */ +import { toVdom, hydratedIslands } from './vdom'; +import { createRootFragment } from './utils'; +import { directivePrefix } from './constants'; + +export const init = async () => { + document + .querySelectorAll( `[${ directivePrefix }island]` ) + .forEach( ( node ) => { + if ( ! hydratedIslands.has( node ) ) { + const fragment = createRootFragment( node.parentNode, node ); + const vdom = toVdom( node ); + hydrate( vdom, fragment ); + } + } ); +}; diff --git a/packages/interactivity/src/index.js b/packages/interactivity/src/index.js new file mode 100644 index 00000000000000..6dbac1a45e88ca --- /dev/null +++ b/packages/interactivity/src/index.js @@ -0,0 +1,17 @@ +/** + * Internal dependencies + */ +import registerDirectives from './directives'; +import { init } from './hydration'; +export { store } from './store'; + +/** + * Initialize the Interactivity API. + */ +registerDirectives(); + +document.addEventListener( 'DOMContentLoaded', async () => { + await init(); + // eslint-disable-next-line no-console + console.log( 'Interactivity API started' ); +} ); diff --git a/packages/interactivity/src/store.js b/packages/interactivity/src/store.js new file mode 100644 index 00000000000000..d11af901352017 --- /dev/null +++ b/packages/interactivity/src/store.js @@ -0,0 +1,45 @@ +/** + * 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 = () => { + // TODO: change the store tag ID for a better one. + const storeTag = document.querySelector( + `script[type="application/json"]#store` + ); + 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 {}; +}; + +const rawState = getSerializedState(); +export const rawStore = { state: deepSignal( rawState ) }; + +export const store = ( { state, ...block } ) => { + deepMerge( rawStore, block ); + deepMerge( rawState, state ); +}; diff --git a/packages/interactivity/src/utils.js b/packages/interactivity/src/utils.js new file mode 100644 index 00000000000000..21d15da2f94ff9 --- /dev/null +++ b/packages/interactivity/src/utils.js @@ -0,0 +1,66 @@ +/** + * External dependencies + */ +import { useRef, useEffect } from 'preact/hooks'; +import { effect } from '@preact/signals'; + +function afterNextFrame( callback ) { + const done = () => { + window.cancelAnimationFrame( raf ); + setTimeout( callback ); + }; + const raf = window.requestAnimationFrame( done ); +} + +// Using the mangled properties: +// this.c: this._callback +// this.x: this._compute +// https://github.com/preactjs/signals/blob/main/mangle.json +function createFlusher( compute, notify ) { + let flush; + const dispose = effect( function () { + flush = this.c.bind( this ); + this.x = compute; + this.c = notify; + return compute(); + } ); + return { flush, dispose }; +} + +// Version of `useSignalEffect` with a `useEffect`-like execution. This hook +// implementation comes from this PR: +// https://github.com/preactjs/signals/pull/290. +// +// We need to include it here in this repo until the mentioned PR is merged. +export function useSignalEffect( cb ) { + const callback = useRef( cb ); + callback.current = cb; + + useEffect( () => { + const execute = () => callback.current(); + const notify = () => afterNextFrame( eff.flush ); + const eff = createFlusher( execute, notify ); + return eff.dispose; + }, [] ); +} + +// For wrapperless hydration. +// See https://gist.github.com/developit/f4c67a2ede71dc2fab7f357f39cff28c +export const createRootFragment = ( parent, replaceNode ) => { + replaceNode = [].concat( replaceNode ); + const s = replaceNode[ replaceNode.length - 1 ].nextSibling; + function insert( c, r ) { + parent.insertBefore( c, r || s ); + } + return ( parent.__k = { + nodeType: 1, + parentNode: parent, + firstChild: replaceNode[ 0 ], + childNodes: replaceNode, + insertBefore: insert, + appendChild: insert, + removeChild( c ) { + parent.removeChild( c ); + }, + } ); +}; diff --git a/packages/interactivity/src/vdom.js b/packages/interactivity/src/vdom.js new file mode 100644 index 00000000000000..07640319b88a8a --- /dev/null +++ b/packages/interactivity/src/vdom.js @@ -0,0 +1,94 @@ +/** + * External dependencies + */ +import { h } from 'preact'; +/** + * Internal dependencies + */ +import { directivePrefix as p } from './constants'; + +const ignoreAttr = `${ p }ignore`; +const islandAttr = `${ p }island`; +const directiveParser = new RegExp( `${ p }([^.]+)\.?(.*)$` ); + +export const hydratedIslands = new WeakSet(); + +// Recursive function that transforms a DOM tree into vDOM. +export function toVdom( root ) { + const treeWalker = document.createTreeWalker( + root, + 205 // ELEMENT + TEXT + COMMENT + CDATA_SECTION + PROCESSING_INSTRUCTION + ); + + function walk( node ) { + const { attributes, nodeType } = node; + + if ( nodeType === 3 ) return [ node.data ]; + if ( nodeType === 4 ) { + const next = treeWalker.nextSibling(); + node.replaceWith( new window.Text( node.nodeValue ) ); + return [ node.nodeValue, next ]; + } + if ( nodeType === 8 || nodeType === 7 ) { + const next = treeWalker.nextSibling(); + node.remove(); + return [ null, next ]; + } + + const props = {}; + const children = []; + const directives = {}; + let hasDirectives = false; + let ignore = false; + let island = false; + + for ( let i = 0; i < attributes.length; i++ ) { + const n = attributes[ i ].name; + if ( n[ p.length ] && n.slice( 0, p.length ) === p ) { + if ( n === ignoreAttr ) { + ignore = true; + } else if ( n === islandAttr ) { + island = true; + } else { + hasDirectives = true; + let val = 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; + } + } else if ( n === 'ref' ) { + continue; + } + props[ n ] = attributes[ i ].value; + } + + if ( ignore && ! island ) + return [ + h( node.localName, { + ...props, + innerHTML: node.innerHTML, + __directives: { ignore: true }, + } ), + ]; + if ( island ) hydratedIslands.add( node ); + + if ( hasDirectives ) props.__directives = directives; + + let child = treeWalker.firstChild(); + if ( child ) { + while ( child ) { + const [ vnode, nextChild ] = walk( child ); + if ( vnode ) children.push( vnode ); + child = nextChild || treeWalker.nextSibling(); + } + treeWalker.parentNode(); + } + + return [ h( node.localName, props, children ) ]; + } + + return walk( treeWalker.currentNode ); +} From 596e4da556f7b89e5dda506f3ba64ece25574c28 Mon Sep 17 00:00:00 2001 From: David Arenas Date: Fri, 26 May 2023 11:23:32 +0200 Subject: [PATCH 04/34] Add .npmrc file --- packages/interactivity/.npmrc | 1 + 1 file changed, 1 insertion(+) create mode 100644 packages/interactivity/.npmrc diff --git a/packages/interactivity/.npmrc b/packages/interactivity/.npmrc new file mode 100644 index 00000000000000..43c97e719a5a82 --- /dev/null +++ b/packages/interactivity/.npmrc @@ -0,0 +1 @@ +package-lock=false From bbcd7fa5752ac83ec4dae8aa97e36b998b29bdcf Mon Sep 17 00:00:00 2001 From: David Arenas Date: Fri, 26 May 2023 11:23:32 +0200 Subject: [PATCH 05/34] Add interactivity package to dependencies --- package.json | 1 + 1 file changed, 1 insertion(+) diff --git a/package.json b/package.json index b53e6c3505eb6e..0e1aa47ac53f8a 100644 --- a/package.json +++ b/package.json @@ -57,6 +57,7 @@ "@wordpress/html-entities": "file:packages/html-entities", "@wordpress/i18n": "file:packages/i18n", "@wordpress/icons": "file:packages/icons", + "@wordpress/interactivity": "file:packages/interactivity", "@wordpress/interface": "file:packages/interface", "@wordpress/is-shallow-equal": "file:packages/is-shallow-equal", "@wordpress/keyboard-shortcuts": "file:packages/keyboard-shortcuts", From b24a7fbcc697ae7c294ec1be8b1557c6517b37ab Mon Sep 17 00:00:00 2001 From: David Arenas Date: Fri, 26 May 2023 11:23:32 +0200 Subject: [PATCH 06/34] Create a custom webpack config for interactivity --- tools/webpack/interactivity.js | 69 ++++++++++++++++++++++++++++++++++ tools/webpack/packages.js | 3 +- webpack.config.js | 8 +++- 3 files changed, 78 insertions(+), 2 deletions(-) create mode 100644 tools/webpack/interactivity.js diff --git a/tools/webpack/interactivity.js b/tools/webpack/interactivity.js new file mode 100644 index 00000000000000..cc0b4b0be2ba74 --- /dev/null +++ b/tools/webpack/interactivity.js @@ -0,0 +1,69 @@ +/** + * External dependencies + */ +const { join } = require( 'path' ); + +/** + * Internal dependencies + */ +const { baseConfig } = require( './shared' ); + +module.exports = { + ...baseConfig, + watchOptions: { + aggregateTimeout: 200, + }, + name: 'interactivity', + entry: { + runtime: './packages/interactivity/src/index.js', + }, + output: { + devtoolNamespace: 'wp', + filename: './build/interactivity/[name].min.js', + path: join( __dirname, '..', '..' ), + }, + optimization: { + ...baseConfig.optimization, + runtimeChunk: { + name: 'vendors', + }, + splitChunks: { + cacheGroups: { + vendors: { + name: 'vendors', + test: /[\\/]node_modules[\\/]/, + minSize: 0, + chunks: 'all', + }, + }, + }, + }, + module: { + rules: [ + { + test: /\.(j|t)sx?$/, + exclude: /node_modules/, + use: [ + { + loader: require.resolve( 'babel-loader' ), + options: { + cacheDirectory: + process.env.BABEL_CACHE_DIRECTORY || true, + babelrc: false, + configFile: false, + presets: [ + [ + '@babel/preset-react', + { + runtime: 'automatic', + importSource: 'preact', + }, + ], + ], + }, + }, + ], + }, + ], + }, +}; diff --git a/tools/webpack/packages.js b/tools/webpack/packages.js index 30f73a82fa0da2..c8016882e41412 100644 --- a/tools/webpack/packages.js +++ b/tools/webpack/packages.js @@ -75,7 +75,8 @@ const gutenbergPackages = Object.keys( dependencies ) ( packageName ) => ! BUNDLED_PACKAGES.includes( packageName ) && packageName.startsWith( WORDPRESS_NAMESPACE ) && - ! packageName.startsWith( WORDPRESS_NAMESPACE + 'react-native' ) + ! packageName.startsWith( WORDPRESS_NAMESPACE + 'react-native' ) && + ! packageName.startsWith( WORDPRESS_NAMESPACE + 'interactivity' ) ) .map( ( packageName ) => packageName.replace( WORDPRESS_NAMESPACE, '' ) ); diff --git a/webpack.config.js b/webpack.config.js index f1c5ce803adc1b..8558707f4bc9fa 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -3,6 +3,12 @@ */ const blocksConfig = require( './tools/webpack/blocks' ); const developmentConfigs = require( './tools/webpack/development' ); +const interactivity = require( './tools/webpack/interactivity' ); const packagesConfig = require( './tools/webpack/packages' ); -module.exports = [ ...blocksConfig, packagesConfig, ...developmentConfigs ]; +module.exports = [ + ...blocksConfig, + interactivity, + packagesConfig, + ...developmentConfigs, +]; From 97e027893a2ba6a0b43ca1f7bca3b1c8656475eb Mon Sep 17 00:00:00 2001 From: David Arenas Date: Fri, 26 May 2023 11:23:32 +0200 Subject: [PATCH 07/34] Expose interactivity runtime in `wp.interactivity` --- tools/webpack/interactivity.js | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/tools/webpack/interactivity.js b/tools/webpack/interactivity.js index cc0b4b0be2ba74..ef246e27a9221c 100644 --- a/tools/webpack/interactivity.js +++ b/tools/webpack/interactivity.js @@ -15,7 +15,13 @@ module.exports = { }, name: 'interactivity', entry: { - runtime: './packages/interactivity/src/index.js', + runtime: { + import: `./packages/interactivity`, + library: { + name: [ 'wp', 'interactivity' ], + type: 'window', + }, + }, }, output: { devtoolNamespace: 'wp', From 59ba71b2a6f54219bb6c2ce8791220ee2252ae66 Mon Sep 17 00:00:00 2001 From: David Arenas Date: Fri, 26 May 2023 11:23:32 +0200 Subject: [PATCH 08/34] Update package-lock --- package-lock.json | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/package-lock.json b/package-lock.json index 4401d6b669b7a0..e72cd729873f27 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17239,6 +17239,7 @@ "@wordpress/html-entities": "file:packages/html-entities", "@wordpress/i18n": "file:packages/i18n", "@wordpress/icons": "file:packages/icons", + "@wordpress/interactivity": "file:packages/interactivity", "@wordpress/keycodes": "file:packages/keycodes", "@wordpress/notices": "file:packages/notices", "@wordpress/primitives": "file:packages/primitives", @@ -18100,6 +18101,14 @@ "@wordpress/primitives": "file:packages/primitives" } }, + "@wordpress/interactivity": { + "version": "file:packages/interactivity", + "requires": { + "@preact/signals": "^1.1.3", + "deepsignal": "^1.3.0", + "preact": "^10.13.2" + } + }, "@wordpress/interface": { "version": "file:packages/interface", "requires": { From d80af2da64c59b00a36c89a9bf7614277ff0b827 Mon Sep 17 00:00:00 2001 From: David Arenas Date: Fri, 26 May 2023 11:23:32 +0200 Subject: [PATCH 09/34] Add `@wordpress/interactivity` to block-library deps --- packages/block-library/package.json | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/block-library/package.json b/packages/block-library/package.json index f739ba882baef5..3d9469498364c0 100644 --- a/packages/block-library/package.json +++ b/packages/block-library/package.json @@ -52,6 +52,7 @@ "@wordpress/html-entities": "file:../html-entities", "@wordpress/i18n": "file:../i18n", "@wordpress/icons": "file:../icons", + "@wordpress/interactivity": "file:../interactivity", "@wordpress/keycodes": "file:../keycodes", "@wordpress/notices": "file:../notices", "@wordpress/primitives": "file:../primitives", From 43af7ab59afdf255d4871bd6c3e2051bbc27d0e3 Mon Sep 17 00:00:00 2001 From: David Arenas Date: Fri, 26 May 2023 11:23:32 +0200 Subject: [PATCH 10/34] Rename entry point to index --- tools/webpack/interactivity.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tools/webpack/interactivity.js b/tools/webpack/interactivity.js index ef246e27a9221c..3f72a1792fadd0 100644 --- a/tools/webpack/interactivity.js +++ b/tools/webpack/interactivity.js @@ -15,7 +15,7 @@ module.exports = { }, name: 'interactivity', entry: { - runtime: { + index: { import: `./packages/interactivity`, library: { name: [ 'wp', 'interactivity' ], From b19d9f57b3bb1bfbb807cd9d8a85c73585fdfe9a Mon Sep 17 00:00:00 2001 From: David Arenas Date: Fri, 26 May 2023 11:23:32 +0200 Subject: [PATCH 11/34] Remove vendors chunk --- tools/webpack/interactivity.js | 16 ---------------- 1 file changed, 16 deletions(-) diff --git a/tools/webpack/interactivity.js b/tools/webpack/interactivity.js index 3f72a1792fadd0..2ffb732f7a7088 100644 --- a/tools/webpack/interactivity.js +++ b/tools/webpack/interactivity.js @@ -28,22 +28,6 @@ module.exports = { filename: './build/interactivity/[name].min.js', path: join( __dirname, '..', '..' ), }, - optimization: { - ...baseConfig.optimization, - runtimeChunk: { - name: 'vendors', - }, - splitChunks: { - cacheGroups: { - vendors: { - name: 'vendors', - test: /[\\/]node_modules[\\/]/, - minSize: 0, - chunks: 'all', - }, - }, - }, - }, module: { rules: [ { From e971cd6a234cc436af3479692ea8653195183fbe Mon Sep 17 00:00:00 2001 From: David Arenas Date: Fri, 26 May 2023 11:23:32 +0200 Subject: [PATCH 12/34] Add oddly required aliases --- tools/webpack/interactivity.js | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tools/webpack/interactivity.js b/tools/webpack/interactivity.js index 2ffb732f7a7088..0d8b9a8c636cc8 100644 --- a/tools/webpack/interactivity.js +++ b/tools/webpack/interactivity.js @@ -28,6 +28,12 @@ module.exports = { filename: './build/interactivity/[name].min.js', path: join( __dirname, '..', '..' ), }, + resolve: { + alias: { + react: 'preact/compat', + 'react-dom': 'preact/compat', + }, + }, module: { rules: [ { From 02701e9b56bb64c407c897d76f69d5ab4466f671 Mon Sep 17 00:00:00 2001 From: David Arenas Date: Fri, 26 May 2023 11:35:15 +0200 Subject: [PATCH 13/34] Add view prefix to interactivity.js files --- .../src/file/{interactivity.js => view-interactivity.js} | 5 ++++- .../src/image/{interactivity.js => view-interactivity.js} | 4 ++-- .../navigation/{interactivity.js => view-interactivity.js} | 4 ++-- 3 files changed, 8 insertions(+), 5 deletions(-) rename packages/block-library/src/file/{interactivity.js => view-interactivity.js} (68%) rename packages/block-library/src/image/{interactivity.js => view-interactivity.js} (97%) rename packages/block-library/src/navigation/{interactivity.js => view-interactivity.js} (98%) diff --git a/packages/block-library/src/file/interactivity.js b/packages/block-library/src/file/view-interactivity.js similarity index 68% rename from packages/block-library/src/file/interactivity.js rename to packages/block-library/src/file/view-interactivity.js index 8060f7addf3a2e..9d09ca2b7f4340 100644 --- a/packages/block-library/src/file/interactivity.js +++ b/packages/block-library/src/file/view-interactivity.js @@ -1,7 +1,10 @@ +/** + * WordPress dependencies + */ +import { store } from '@wordpress/interactivity'; /** * Internal dependencies */ -import { store } from '../utils/interactivity'; import { browserSupportsPdfs as hasPdfPreview } from './utils'; store( { diff --git a/packages/block-library/src/image/interactivity.js b/packages/block-library/src/image/view-interactivity.js similarity index 97% rename from packages/block-library/src/image/interactivity.js rename to packages/block-library/src/image/view-interactivity.js index 6b6f246b830256..90ff59fdfd5cb2 100644 --- a/packages/block-library/src/image/interactivity.js +++ b/packages/block-library/src/image/view-interactivity.js @@ -1,7 +1,7 @@ /** - * Internal dependencies + * WordPress dependencies */ -import { store } from '../utils/interactivity'; +import { store } from '@wordpress/interactivity'; const focusableSelectors = [ 'a[href]', diff --git a/packages/block-library/src/navigation/interactivity.js b/packages/block-library/src/navigation/view-interactivity.js similarity index 98% rename from packages/block-library/src/navigation/interactivity.js rename to packages/block-library/src/navigation/view-interactivity.js index 80152762c9cd63..e8eed82f62ba82 100644 --- a/packages/block-library/src/navigation/interactivity.js +++ b/packages/block-library/src/navigation/view-interactivity.js @@ -1,7 +1,7 @@ /** - * Internal dependencies + * WordPress dependencies */ -import { store } from '../utils/interactivity'; +import { store } from '@wordpress/interactivity'; const focusableSelectors = [ 'a[href]', From 22d7543c174520e1aa09fd82f565f71cdf8c20d3 Mon Sep 17 00:00:00 2001 From: David Arenas Date: Fri, 26 May 2023 11:44:38 +0200 Subject: [PATCH 14/34] Use view-interactivity files when enabled --- lib/experimental/interactivity-api/blocks.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/experimental/interactivity-api/blocks.php b/lib/experimental/interactivity-api/blocks.php index 3ad6d13d660fb1..5ce801a3308e56 100644 --- a/lib/experimental/interactivity-api/blocks.php +++ b/lib/experimental/interactivity-api/blocks.php @@ -227,7 +227,7 @@ function gutenberg_block_update_interactive_view_script( $metadata ) { in_array( $metadata['name'], array( 'core/file', 'core/navigation', 'core/image' ), true ) && str_contains( $metadata['file'], 'build/block-library/blocks' ) ) { - $metadata['viewScript'] = array( 'file:./interactivity.min.js' ); + $metadata['viewScript'] = array( 'file:./view-interactivity.min.js' ); } return $metadata; } From 04bf309b8b37dee0cbf66810c8e108783ad91a76 Mon Sep 17 00:00:00 2001 From: David Arenas Date: Fri, 26 May 2023 11:45:49 +0200 Subject: [PATCH 15/34] Stop adding defer to Interactivity scripts --- .../interactivity-api/script-loader.php | 19 ------------------- 1 file changed, 19 deletions(-) diff --git a/lib/experimental/interactivity-api/script-loader.php b/lib/experimental/interactivity-api/script-loader.php index 63453713dd18cf..665ab3ea9311be 100644 --- a/lib/experimental/interactivity-api/script-loader.php +++ b/lib/experimental/interactivity-api/script-loader.php @@ -35,22 +35,3 @@ function gutenberg_register_interactivity_scripts( $scripts ) { } } add_action( 'wp_default_scripts', 'gutenberg_register_interactivity_scripts', 10, 1 ); - -/** - * Adds the "defer" attribute to all the interactivity script tags. - * - * @param string $tag The generated script tag. - * @param string $handle The script handle. - * - * @return string The modified script tag. - */ -function gutenberg_interactivity_scripts_add_defer_attribute( $tag, $handle ) { - if ( str_starts_with( $handle, 'wp-interactivity-' ) || str_contains( $tag, '/interactivity.min.js' ) ) { - $p = new WP_HTML_Tag_Processor( $tag ); - $p->next_tag( array( 'tag' => 'script' ) ); - $p->set_attribute( 'defer', true ); - return $p->get_updated_html(); - } - return $tag; -} -add_filter( 'script_loader_tag', 'gutenberg_interactivity_scripts_add_defer_attribute', 10, 2 ); From 91e3fde3a50abc8f8d501d462a375d984cb6e6d2 Mon Sep 17 00:00:00 2001 From: David Arenas Date: Fri, 26 May 2023 11:56:51 +0200 Subject: [PATCH 16/34] Remove webpack config for interactivity.js files --- tools/webpack/blocks.js | 78 ----------------------------------------- 1 file changed, 78 deletions(-) diff --git a/tools/webpack/blocks.js b/tools/webpack/blocks.js index 19ff4e90e214e7..d399ee0d1a34bf 100644 --- a/tools/webpack/blocks.js +++ b/tools/webpack/blocks.js @@ -219,82 +219,4 @@ module.exports = [ } ), ].filter( Boolean ), }, - { - ...baseConfig, - watchOptions: { - aggregateTimeout: 200, - }, - name: 'interactivity', - entry: { - file: './packages/block-library/src/file/interactivity.js', - navigation: - './packages/block-library/src/navigation/interactivity.js', - image: './packages/block-library/src/image/interactivity.js', - }, - output: { - devtoolNamespace: 'wp', - filename: './blocks/[name]/interactivity.min.js', - path: join( __dirname, '..', '..', 'build', 'block-library' ), - }, - optimization: { - ...baseConfig.optimization, - runtimeChunk: { - name: 'vendors', - }, - splitChunks: { - cacheGroups: { - vendors: { - name: 'vendors', - test: /[\\/]node_modules[\\/]/, - filename: './interactivity/[name].min.js', - minSize: 0, - chunks: 'all', - }, - runtime: { - name: 'runtime', - test: /[\\/]utils[\\/]interactivity[\\/]/, - filename: './interactivity/[name].min.js', - chunks: 'all', - minSize: 0, - priority: -10, - }, - }, - }, - }, - module: { - rules: [ - { - test: /\.(j|t)sx?$/, - exclude: /node_modules/, - use: [ - { - loader: require.resolve( 'babel-loader' ), - options: { - cacheDirectory: - process.env.BABEL_CACHE_DIRECTORY || true, - babelrc: false, - configFile: false, - presets: [ - [ - '@babel/preset-react', - { - runtime: 'automatic', - importSource: 'preact', - }, - ], - ], - }, - }, - ], - }, - ], - }, - plugins: [ - ...plugins, - new DependencyExtractionWebpackPlugin( { - __experimentalInjectInteractivityRuntime: true, - injectPolyfill: false, - } ), - ].filter( Boolean ), - }, ]; From 75159c4cdaa5431d755dded398fcd0062402a501 Mon Sep 17 00:00:00 2001 From: David Arenas Date: Fri, 26 May 2023 11:57:43 +0200 Subject: [PATCH 17/34] Remove interactivity runtime from block-library --- .../src/utils/interactivity/constants.js | 1 - .../src/utils/interactivity/directives.js | 200 ------------------ .../src/utils/interactivity/hooks.js | 145 ------------- .../src/utils/interactivity/hydration.js | 22 -- .../src/utils/interactivity/index.js | 17 -- .../src/utils/interactivity/portals.js | 98 --------- .../src/utils/interactivity/store.js | 45 ---- .../src/utils/interactivity/utils.js | 66 ------ .../src/utils/interactivity/vdom.js | 94 -------- 9 files changed, 688 deletions(-) delete mode 100644 packages/block-library/src/utils/interactivity/constants.js delete mode 100644 packages/block-library/src/utils/interactivity/directives.js delete mode 100644 packages/block-library/src/utils/interactivity/hooks.js delete mode 100644 packages/block-library/src/utils/interactivity/hydration.js delete mode 100644 packages/block-library/src/utils/interactivity/index.js delete mode 100644 packages/block-library/src/utils/interactivity/portals.js delete mode 100644 packages/block-library/src/utils/interactivity/store.js delete mode 100644 packages/block-library/src/utils/interactivity/utils.js delete mode 100644 packages/block-library/src/utils/interactivity/vdom.js diff --git a/packages/block-library/src/utils/interactivity/constants.js b/packages/block-library/src/utils/interactivity/constants.js deleted file mode 100644 index f462753c9f8179..00000000000000 --- a/packages/block-library/src/utils/interactivity/constants.js +++ /dev/null @@ -1 +0,0 @@ -export const directivePrefix = 'data-wp-'; diff --git a/packages/block-library/src/utils/interactivity/directives.js b/packages/block-library/src/utils/interactivity/directives.js deleted file mode 100644 index b2415293a2bf0e..00000000000000 --- a/packages/block-library/src/utils/interactivity/directives.js +++ /dev/null @@ -1,200 +0,0 @@ -/** - * External dependencies - */ -import { useContext, useMemo, useEffect } from 'preact/hooks'; -import { deepSignal, peek } from 'deepsignal'; -/** - * Internal dependencies - */ -import { createPortal } from './portals.js'; - -/** - * Internal dependencies - */ -import { useSignalEffect } from './utils'; -import { directive } from './hooks'; - -const isObject = ( item ) => - item && typeof item === 'object' && ! Array.isArray( item ); - -const mergeDeepSignals = ( target, source ) => { - for ( const k in source ) { - if ( typeof peek( target, k ) === 'undefined' ) { - target[ `$${ k }` ] = source[ `$${ k }` ]; - } else if ( - isObject( peek( target, k ) ) && - isObject( peek( source, k ) ) - ) { - mergeDeepSignals( - target[ `$${ k }` ].peek(), - source[ `$${ k }` ].peek() - ); - } - } -}; - -export default () => { - // data-wp-context - directive( - 'context', - ( { - directives: { - context: { default: context }, - }, - props: { children }, - context: inherited, - } ) => { - const { Provider } = inherited; - const inheritedValue = useContext( inherited ); - const value = useMemo( () => { - const localValue = deepSignal( context ); - mergeDeepSignals( localValue, inheritedValue ); - return localValue; - }, [ context, inheritedValue ] ); - - return { children }; - }, - { priority: 5 } - ); - - // data-wp-body - directive( 'body', ( { props: { children }, context: inherited } ) => { - const { Provider } = inherited; - const inheritedValue = useContext( inherited ); - 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-init.[name] - directive( 'init', ( { directives: { init }, context, evaluate } ) => { - const contextValue = useContext( context ); - Object.values( init ).forEach( ( path ) => { - useEffect( () => { - return evaluate( path, { context: contextValue } ); - }, [] ); - } ); - } ); - - // 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 } ); - }; - } ); - } ); - - // 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, - } ); - const currentClass = element.props.class || ''; - const classFinder = new RegExp( - `(^|\\s)${ name }(\\s|$)`, - 'g' - ); - if ( ! result ) - element.props.class = currentClass - .replace( classFinder, ' ' ) - .trim(); - else if ( ! classFinder.test( currentClass ) ) - element.props.class = currentClass - ? `${ currentClass } ${ name }` - : name; - - useEffect( () => { - // This seems necessary because Preact doesn't change the class - // names 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.classList.remove( name ); - } else { - element.ref.current.classList.add( name ); - } - }, [] ); - } ); - } - ); - - // data-wp-bind.[attribute] - 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; - - // 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( () => { - // 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 === false && attribute[ 4 ] !== '-' ) { - element.ref.current.removeAttribute( attribute ); - } else { - element.ref.current.setAttribute( - attribute, - result === true && attribute[ 4 ] !== '-' - ? '' - : result - ); - } - }, [] ); - } ); - } - ); - - // data-wp-ignore - directive( - 'ignore', - ( { - element: { - type: Type, - props: { innerHTML, ...rest }, - }, - } ) => { - // Preserve the initial inner HTML. - const cached = useMemo( () => innerHTML, [] ); - return ( - - ); - } - ); -}; diff --git a/packages/block-library/src/utils/interactivity/hooks.js b/packages/block-library/src/utils/interactivity/hooks.js deleted file mode 100644 index e309990482ebc2..00000000000000 --- a/packages/block-library/src/utils/interactivity/hooks.js +++ /dev/null @@ -1,145 +0,0 @@ -/** - * External dependencies - */ -import { h, options, createContext, cloneElement } from 'preact'; -import { useRef, useMemo } from 'preact/hooks'; -/** - * Internal dependencies - */ -import { rawStore as store } from './store'; - -// Main context. -const context = createContext( {} ); - -// WordPress Directives. -const directiveMap = {}; -const directivePriorities = {}; -export const directive = ( name, cb, { priority = 10 } = {} ) => { - directiveMap[ name ] = cb; - directivePriorities[ name ] = priority; -}; - -// Resolve the path to some property of the store object. -const resolve = ( path, ctx ) => { - let current = { ...store, context: ctx }; - path.split( '.' ).forEach( ( p ) => ( current = current[ p ] ) ); - return current; -}; - -// Generate the evaluate function. -const getEvaluate = - ( { ref } = {} ) => - ( path, extraArgs = {} ) => { - // 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; - }; - -// Separate directives by priority. The resulting array contains objects -// of directives grouped by same priority, and sorted in ascending order. -const usePriorityLevels = ( directives ) => - useMemo( () => { - const byPriority = Object.entries( directives ).reduce( - ( acc, [ name, values ] ) => { - const priority = directivePriorities[ name ]; - if ( ! acc[ priority ] ) acc[ priority ] = {}; - acc[ priority ][ name ] = values; - - return acc; - }, - {} - ); - - return Object.entries( byPriority ) - .sort( ( [ p1 ], [ p2 ] ) => p1 - p2 ) - .map( ( [ , obj ] ) => obj ); - }, [ directives ] ); - -// Directive wrapper. -const Directive = ( { type, directives, props: originalProps } ) => { - const ref = useRef( null ); - const element = h( type, { ...originalProps, ref } ); - const evaluate = useMemo( () => getEvaluate( { ref } ), [] ); - - // Add wrappers recursively for each priority level. - const byPriorityLevel = usePriorityLevels( directives ); - return ( - - ); -}; - -// Priority level wrapper. -const RecursivePriorityLevel = ( { - directives: [ directives, ...rest ], - element, - evaluate, - originalProps, -} ) => { - // This element needs to be a fresh copy so we are not modifying an already - // rendered element with Preact's internal properties initialized. This - // prevents an error with changes in `element.props.children` not being - // reflected in `element.__k`. - element = cloneElement( element ); - - // Recursively render the wrapper for the next priority level. - // - // Note that, even though we're instantiating a vnode with a - // `RecursivePriorityLevel` here, its render function will not be executed - // just yet. Actually, it will be delayed until the current render function - // has finished. That ensures directives in the current priorty level have - // run (and thus modified the passed `element`) before the next level. - const children = - rest.length > 0 ? ( - - ) : ( - element - ); - - const props = { ...originalProps, children }; - const directiveArgs = { directives, props, element, context, evaluate }; - - for ( const d in directives ) { - const wrapper = directiveMap[ d ]?.( directiveArgs ); - if ( wrapper !== undefined ) props.children = wrapper; - } - - return props.children; -}; - -// Preact Options Hook called each time a vnode is created. -const old = options.vnode; -options.vnode = ( vnode ) => { - if ( vnode.props.__directives ) { - const props = vnode.props; - const directives = props.__directives; - delete props.__directives; - vnode.props = { - type: vnode.type, - directives, - props, - }; - vnode.type = Directive; - } - - if ( old ) old( vnode ); -}; diff --git a/packages/block-library/src/utils/interactivity/hydration.js b/packages/block-library/src/utils/interactivity/hydration.js deleted file mode 100644 index 2fc34eeb64b9b5..00000000000000 --- a/packages/block-library/src/utils/interactivity/hydration.js +++ /dev/null @@ -1,22 +0,0 @@ -/** - * External dependencies - */ -import { hydrate } from 'preact'; -/** - * Internal dependencies - */ -import { toVdom, hydratedIslands } from './vdom'; -import { createRootFragment } from './utils'; -import { directivePrefix } from './constants'; - -export const init = async () => { - document - .querySelectorAll( `[${ directivePrefix }island]` ) - .forEach( ( node ) => { - if ( ! hydratedIslands.has( node ) ) { - const fragment = createRootFragment( node.parentNode, node ); - const vdom = toVdom( node ); - hydrate( vdom, fragment ); - } - } ); -}; diff --git a/packages/block-library/src/utils/interactivity/index.js b/packages/block-library/src/utils/interactivity/index.js deleted file mode 100644 index 6dbac1a45e88ca..00000000000000 --- a/packages/block-library/src/utils/interactivity/index.js +++ /dev/null @@ -1,17 +0,0 @@ -/** - * Internal dependencies - */ -import registerDirectives from './directives'; -import { init } from './hydration'; -export { store } from './store'; - -/** - * Initialize the Interactivity API. - */ -registerDirectives(); - -document.addEventListener( 'DOMContentLoaded', async () => { - await init(); - // eslint-disable-next-line no-console - console.log( 'Interactivity API started' ); -} ); diff --git a/packages/block-library/src/utils/interactivity/portals.js b/packages/block-library/src/utils/interactivity/portals.js deleted file mode 100644 index ccb293d6c20e80..00000000000000 --- a/packages/block-library/src/utils/interactivity/portals.js +++ /dev/null @@ -1,98 +0,0 @@ -/** - * External dependencies - */ -import { createElement, render } from 'preact'; - -/** - * @param {import('../../src/index').RenderableProps<{ context: any }>} props - */ -function ContextProvider( props ) { - this.getChildContext = () => props.context; - return props.children; -} - -/** - * Portal component - * - * @this {import('./internal').Component} - * @param {object | null | undefined} props - * - * TODO: use createRoot() instead of fake root - */ -function Portal( props ) { - const _this = this; - const container = props._container; - - _this.componentWillUnmount = function () { - render( null, _this._temp ); - _this._temp = null; - _this._container = null; - }; - - // When we change container we should clear our old container and - // indicate a new mount. - if ( _this._container && _this._container !== container ) { - _this.componentWillUnmount(); - } - - // When props.vnode is undefined/false/null we are dealing with some kind of - // conditional vnode. This should not trigger a render. - if ( props._vnode ) { - if ( ! _this._temp ) { - _this._container = container; - - // Create a fake DOM parent node that manages a subset of `container`'s children: - _this._temp = { - nodeType: 1, - parentNode: container, - childNodes: [], - appendChild( child ) { - this.childNodes.push( child ); - _this._container.appendChild( child ); - }, - insertBefore( child ) { - this.childNodes.push( child ); - _this._container.appendChild( child ); - }, - removeChild( child ) { - this.childNodes.splice( - // eslint-disable-next-line no-bitwise - this.childNodes.indexOf( child ) >>> 1, - 1 - ); - _this._container.removeChild( child ); - }, - }; - } - - // Render our wrapping element into temp. - render( - createElement( - ContextProvider, - { context: _this.context }, - props._vnode - ), - _this._temp - ); - } - // When we come from a conditional render, on a mounted - // portal we should clear the DOM. - else if ( _this._temp ) { - _this.componentWillUnmount(); - } -} - -/** - * Create a `Portal` to continue rendering the vnode tree at a different DOM node - * - * @param {import('./internal').VNode} vnode The vnode to render - * @param {import('./internal').PreactElement} container The DOM node to continue rendering in to. - */ -export function createPortal( vnode, container ) { - const el = createElement( Portal, { - _vnode: vnode, - _container: container, - } ); - el.containerInfo = container; - return el; -} diff --git a/packages/block-library/src/utils/interactivity/store.js b/packages/block-library/src/utils/interactivity/store.js deleted file mode 100644 index d11af901352017..00000000000000 --- a/packages/block-library/src/utils/interactivity/store.js +++ /dev/null @@ -1,45 +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 = () => { - // TODO: change the store tag ID for a better one. - const storeTag = document.querySelector( - `script[type="application/json"]#store` - ); - 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 {}; -}; - -const rawState = getSerializedState(); -export const rawStore = { state: deepSignal( rawState ) }; - -export const store = ( { state, ...block } ) => { - deepMerge( rawStore, block ); - deepMerge( rawState, state ); -}; diff --git a/packages/block-library/src/utils/interactivity/utils.js b/packages/block-library/src/utils/interactivity/utils.js deleted file mode 100644 index 21d15da2f94ff9..00000000000000 --- a/packages/block-library/src/utils/interactivity/utils.js +++ /dev/null @@ -1,66 +0,0 @@ -/** - * External dependencies - */ -import { useRef, useEffect } from 'preact/hooks'; -import { effect } from '@preact/signals'; - -function afterNextFrame( callback ) { - const done = () => { - window.cancelAnimationFrame( raf ); - setTimeout( callback ); - }; - const raf = window.requestAnimationFrame( done ); -} - -// Using the mangled properties: -// this.c: this._callback -// this.x: this._compute -// https://github.com/preactjs/signals/blob/main/mangle.json -function createFlusher( compute, notify ) { - let flush; - const dispose = effect( function () { - flush = this.c.bind( this ); - this.x = compute; - this.c = notify; - return compute(); - } ); - return { flush, dispose }; -} - -// Version of `useSignalEffect` with a `useEffect`-like execution. This hook -// implementation comes from this PR: -// https://github.com/preactjs/signals/pull/290. -// -// We need to include it here in this repo until the mentioned PR is merged. -export function useSignalEffect( cb ) { - const callback = useRef( cb ); - callback.current = cb; - - useEffect( () => { - const execute = () => callback.current(); - const notify = () => afterNextFrame( eff.flush ); - const eff = createFlusher( execute, notify ); - return eff.dispose; - }, [] ); -} - -// For wrapperless hydration. -// See https://gist.github.com/developit/f4c67a2ede71dc2fab7f357f39cff28c -export const createRootFragment = ( parent, replaceNode ) => { - replaceNode = [].concat( replaceNode ); - const s = replaceNode[ replaceNode.length - 1 ].nextSibling; - function insert( c, r ) { - parent.insertBefore( c, r || s ); - } - return ( parent.__k = { - nodeType: 1, - parentNode: parent, - firstChild: replaceNode[ 0 ], - childNodes: replaceNode, - insertBefore: insert, - appendChild: insert, - removeChild( c ) { - parent.removeChild( c ); - }, - } ); -}; diff --git a/packages/block-library/src/utils/interactivity/vdom.js b/packages/block-library/src/utils/interactivity/vdom.js deleted file mode 100644 index 07640319b88a8a..00000000000000 --- a/packages/block-library/src/utils/interactivity/vdom.js +++ /dev/null @@ -1,94 +0,0 @@ -/** - * External dependencies - */ -import { h } from 'preact'; -/** - * Internal dependencies - */ -import { directivePrefix as p } from './constants'; - -const ignoreAttr = `${ p }ignore`; -const islandAttr = `${ p }island`; -const directiveParser = new RegExp( `${ p }([^.]+)\.?(.*)$` ); - -export const hydratedIslands = new WeakSet(); - -// Recursive function that transforms a DOM tree into vDOM. -export function toVdom( root ) { - const treeWalker = document.createTreeWalker( - root, - 205 // ELEMENT + TEXT + COMMENT + CDATA_SECTION + PROCESSING_INSTRUCTION - ); - - function walk( node ) { - const { attributes, nodeType } = node; - - if ( nodeType === 3 ) return [ node.data ]; - if ( nodeType === 4 ) { - const next = treeWalker.nextSibling(); - node.replaceWith( new window.Text( node.nodeValue ) ); - return [ node.nodeValue, next ]; - } - if ( nodeType === 8 || nodeType === 7 ) { - const next = treeWalker.nextSibling(); - node.remove(); - return [ null, next ]; - } - - const props = {}; - const children = []; - const directives = {}; - let hasDirectives = false; - let ignore = false; - let island = false; - - for ( let i = 0; i < attributes.length; i++ ) { - const n = attributes[ i ].name; - if ( n[ p.length ] && n.slice( 0, p.length ) === p ) { - if ( n === ignoreAttr ) { - ignore = true; - } else if ( n === islandAttr ) { - island = true; - } else { - hasDirectives = true; - let val = 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; - } - } else if ( n === 'ref' ) { - continue; - } - props[ n ] = attributes[ i ].value; - } - - if ( ignore && ! island ) - return [ - h( node.localName, { - ...props, - innerHTML: node.innerHTML, - __directives: { ignore: true }, - } ), - ]; - if ( island ) hydratedIslands.add( node ); - - if ( hasDirectives ) props.__directives = directives; - - let child = treeWalker.firstChild(); - if ( child ) { - while ( child ) { - const [ vnode, nextChild ] = walk( child ); - if ( vnode ) children.push( vnode ); - child = nextChild || treeWalker.nextSibling(); - } - treeWalker.parentNode(); - } - - return [ h( node.localName, props, children ) ]; - } - - return walk( treeWalker.currentNode ); -} From df8563bc85464ee831be2fd79298692d6bba8179 Mon Sep 17 00:00:00 2001 From: David Arenas Date: Fri, 26 May 2023 12:00:18 +0200 Subject: [PATCH 18/34] Remove interactivity runtime from sideEffects --- packages/block-library/package.json | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/block-library/package.json b/packages/block-library/package.json index 3d9469498364c0..182b53bf35e04d 100644 --- a/packages/block-library/package.json +++ b/packages/block-library/package.json @@ -27,8 +27,7 @@ "sideEffects": [ "build-style/**", "src/**/*.scss", - "{src,build,build-module}/*/init.js", - "{src,build,build-module}/utils/interactivity/index.js" + "{src,build,build-module}/*/init.js" ], "dependencies": { "@babel/runtime": "^7.16.0", From 7b1953fddec18df81ff7b0a73edd7146f6cac45c Mon Sep 17 00:00:00 2001 From: David Arenas Date: Fri, 26 May 2023 12:08:16 +0200 Subject: [PATCH 19/34] Undo temporary fix for Interactivity API in dependency-extraction-webpack-plugin --- .../dependency-extraction-webpack-plugin/lib/index.js | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/packages/dependency-extraction-webpack-plugin/lib/index.js b/packages/dependency-extraction-webpack-plugin/lib/index.js index 3da2286ddbd57d..581274c3684f93 100644 --- a/packages/dependency-extraction-webpack-plugin/lib/index.js +++ b/packages/dependency-extraction-webpack-plugin/lib/index.js @@ -27,7 +27,6 @@ class DependencyExtractionWebpackPlugin { combinedOutputFile: null, externalizedReport: false, injectPolyfill: false, - __experimentalInjectInteractivityRuntime: false, outputFormat: 'php', outputFilename: null, useDefaults: true, @@ -143,7 +142,6 @@ class DependencyExtractionWebpackPlugin { combinedOutputFile, externalizedReport, injectPolyfill, - __experimentalInjectInteractivityRuntime, outputFormat, outputFilename, } = this.options; @@ -186,14 +184,6 @@ class DependencyExtractionWebpackPlugin { if ( injectPolyfill ) { chunkDeps.add( 'wp-polyfill' ); } - // Temporary fix for Interactivity API until it gets moved to its package. - if ( __experimentalInjectInteractivityRuntime ) { - if ( ! chunkJSFile.startsWith( './interactivity/' ) ) { - chunkDeps.add( 'wp-interactivity-runtime' ); - } else if ( './interactivity/runtime.min.js' === chunkJSFile ) { - chunkDeps.add( 'wp-interactivity-vendors' ); - } - } const processModule = ( { userRequest } ) => { if ( this.externalizedDeps.has( userRequest ) ) { From 8a106364ff2778b62e7d8fde86ff013d447e7c59 Mon Sep 17 00:00:00 2001 From: David Arenas Date: Fri, 26 May 2023 12:23:52 +0200 Subject: [PATCH 20/34] Remove script loader for Interactivity API runtime --- .../interactivity-api/script-loader.php | 37 ------------------- lib/load.php | 1 - 2 files changed, 38 deletions(-) delete mode 100644 lib/experimental/interactivity-api/script-loader.php diff --git a/lib/experimental/interactivity-api/script-loader.php b/lib/experimental/interactivity-api/script-loader.php deleted file mode 100644 index 665ab3ea9311be..00000000000000 --- a/lib/experimental/interactivity-api/script-loader.php +++ /dev/null @@ -1,37 +0,0 @@ - Date: Fri, 26 May 2023 12:41:54 +0200 Subject: [PATCH 21/34] Remove block-librar/interactivity from build_files --- bin/build-plugin-zip.sh | 1 - 1 file changed, 1 deletion(-) diff --git a/bin/build-plugin-zip.sh b/bin/build-plugin-zip.sh index 131e434d1383d0..4ba931c4a4aeb6 100755 --- a/bin/build-plugin-zip.sh +++ b/bin/build-plugin-zip.sh @@ -83,7 +83,6 @@ build_files=$( build/block-library/blocks/*.php \ build/block-library/blocks/*/block.json \ build/block-library/blocks/*/*.{js,js.map,css,asset.php} \ - build/block-library/interactivity/*.{js,js.map,asset.php} \ build/edit-widgets/blocks/*/block.json \ build/widgets/blocks/*.php \ build/widgets/blocks/*/block.json \ From ea033b11c60cebb902ea910e37a861381a893db0 Mon Sep 17 00:00:00 2001 From: David Arenas Date: Fri, 26 May 2023 13:58:15 +0200 Subject: [PATCH 22/34] Add src/index.js to Interactivity API entry file --- tools/webpack/interactivity.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tools/webpack/interactivity.js b/tools/webpack/interactivity.js index 0d8b9a8c636cc8..25331b5bdbd749 100644 --- a/tools/webpack/interactivity.js +++ b/tools/webpack/interactivity.js @@ -16,7 +16,7 @@ module.exports = { name: 'interactivity', entry: { index: { - import: `./packages/interactivity`, + import: `./packages/interactivity/src/index.js`, library: { name: [ 'wp', 'interactivity' ], type: 'window', From d9561b82c8c8a5b5b538f1235bf3311a09371fc2 Mon Sep 17 00:00:00 2001 From: David Arenas Date: Fri, 26 May 2023 17:51:34 +0200 Subject: [PATCH 23/34] Remove unnecessary aliases --- tools/webpack/interactivity.js | 6 ------ 1 file changed, 6 deletions(-) diff --git a/tools/webpack/interactivity.js b/tools/webpack/interactivity.js index 25331b5bdbd749..12792cd3876599 100644 --- a/tools/webpack/interactivity.js +++ b/tools/webpack/interactivity.js @@ -28,12 +28,6 @@ module.exports = { filename: './build/interactivity/[name].min.js', path: join( __dirname, '..', '..' ), }, - resolve: { - alias: { - react: 'preact/compat', - 'react-dom': 'preact/compat', - }, - }, module: { rules: [ { From bec4852a2057211d97bad5643cce3161d0d44ada Mon Sep 17 00:00:00 2001 From: David Arenas Date: Fri, 26 May 2023 19:15:26 +0200 Subject: [PATCH 24/34] Restore data-wp-body directive --- packages/interactivity/src/directives.js | 11 +++ packages/interactivity/src/portals.js | 98 ++++++++++++++++++++++++ 2 files changed, 109 insertions(+) create mode 100644 packages/interactivity/src/portals.js diff --git a/packages/interactivity/src/directives.js b/packages/interactivity/src/directives.js index e2a2e34e3451c3..14151945674222 100644 --- a/packages/interactivity/src/directives.js +++ b/packages/interactivity/src/directives.js @@ -7,6 +7,7 @@ import { deepSignal, peek } from 'deepsignal'; /** * Internal dependencies */ +import { createPortal } from './portals'; import { useSignalEffect } from './utils'; import { directive } from './hooks'; @@ -53,6 +54,16 @@ export default () => { { priority: 5 } ); + // data-wp-body + directive( 'body', ( { props: { children }, context: inherited } ) => { + const { Provider } = inherited; + const inheritedValue = useContext( inherited ); + return createPortal( + { children }, + document.body + ); + } ); + // data-wp-effect.[name] directive( 'effect', ( { directives: { effect }, context, evaluate } ) => { const contextValue = useContext( context ); diff --git a/packages/interactivity/src/portals.js b/packages/interactivity/src/portals.js new file mode 100644 index 00000000000000..ccb293d6c20e80 --- /dev/null +++ b/packages/interactivity/src/portals.js @@ -0,0 +1,98 @@ +/** + * External dependencies + */ +import { createElement, render } from 'preact'; + +/** + * @param {import('../../src/index').RenderableProps<{ context: any }>} props + */ +function ContextProvider( props ) { + this.getChildContext = () => props.context; + return props.children; +} + +/** + * Portal component + * + * @this {import('./internal').Component} + * @param {object | null | undefined} props + * + * TODO: use createRoot() instead of fake root + */ +function Portal( props ) { + const _this = this; + const container = props._container; + + _this.componentWillUnmount = function () { + render( null, _this._temp ); + _this._temp = null; + _this._container = null; + }; + + // When we change container we should clear our old container and + // indicate a new mount. + if ( _this._container && _this._container !== container ) { + _this.componentWillUnmount(); + } + + // When props.vnode is undefined/false/null we are dealing with some kind of + // conditional vnode. This should not trigger a render. + if ( props._vnode ) { + if ( ! _this._temp ) { + _this._container = container; + + // Create a fake DOM parent node that manages a subset of `container`'s children: + _this._temp = { + nodeType: 1, + parentNode: container, + childNodes: [], + appendChild( child ) { + this.childNodes.push( child ); + _this._container.appendChild( child ); + }, + insertBefore( child ) { + this.childNodes.push( child ); + _this._container.appendChild( child ); + }, + removeChild( child ) { + this.childNodes.splice( + // eslint-disable-next-line no-bitwise + this.childNodes.indexOf( child ) >>> 1, + 1 + ); + _this._container.removeChild( child ); + }, + }; + } + + // Render our wrapping element into temp. + render( + createElement( + ContextProvider, + { context: _this.context }, + props._vnode + ), + _this._temp + ); + } + // When we come from a conditional render, on a mounted + // portal we should clear the DOM. + else if ( _this._temp ) { + _this.componentWillUnmount(); + } +} + +/** + * Create a `Portal` to continue rendering the vnode tree at a different DOM node + * + * @param {import('./internal').VNode} vnode The vnode to render + * @param {import('./internal').PreactElement} container The DOM node to continue rendering in to. + */ +export function createPortal( vnode, container ) { + const el = createElement( Portal, { + _vnode: vnode, + _container: container, + } ); + el.containerInfo = container; + return el; +} From 8cb572c086db886fa848590c17346f0566bdd251 Mon Sep 17 00:00:00 2001 From: Luis Herranz Date: Fri, 2 Jun 2023 12:35:30 +0200 Subject: [PATCH 25/34] Interactivity API: add `wp_store` (#51191) * Add `wp_store` to the Interactivity API * Rename WP_Interactivity_Store and move filter to scripts file * Remove todos to change the store id --- .../class-wp-interactivity-store.php | 73 ++++++++ .../interactivity-api/scripts.php | 28 +++ lib/experimental/interactivity-api/store.php | 26 +++ lib/load.php | 3 + packages/interactivity/src/store.js | 3 +- .../class-wp-interactivity-store-test.php | 168 ++++++++++++++++++ 6 files changed, 299 insertions(+), 2 deletions(-) create mode 100644 lib/experimental/interactivity-api/class-wp-interactivity-store.php create mode 100644 lib/experimental/interactivity-api/scripts.php create mode 100644 lib/experimental/interactivity-api/store.php create mode 100644 phpunit/experimental/interactivity-api/class-wp-interactivity-store-test.php diff --git a/lib/experimental/interactivity-api/class-wp-interactivity-store.php b/lib/experimental/interactivity-api/class-wp-interactivity-store.php new file mode 100644 index 00000000000000..46ca574e667c4c --- /dev/null +++ b/lib/experimental/interactivity-api/class-wp-interactivity-store.php @@ -0,0 +1,73 @@ +$store"; + } +} diff --git a/lib/experimental/interactivity-api/scripts.php b/lib/experimental/interactivity-api/scripts.php new file mode 100644 index 00000000000000..e95bf518c75f73 --- /dev/null +++ b/lib/experimental/interactivity-api/scripts.php @@ -0,0 +1,28 @@ +get_all_registered(); + foreach ( array_values( $registered_blocks ) as $block ) { + if ( isset( $block->supports['interactivity'] ) && $block->supports['interactivity'] ) { + foreach ( $block->view_script_handles as $handle ) { + wp_script_add_data( $handle, 'group', 1 ); + } + } + } +} +add_action( 'wp_enqueue_scripts', 'gutenberg_interactivity_move_interactive_scripts_to_the_footer', 11 ); diff --git a/lib/experimental/interactivity-api/store.php b/lib/experimental/interactivity-api/store.php new file mode 100644 index 00000000000000..9554cef15c1a4a --- /dev/null +++ b/lib/experimental/interactivity-api/store.php @@ -0,0 +1,26 @@ + { }; const getSerializedState = () => { - // TODO: change the store tag ID for a better one. const storeTag = document.querySelector( - `script[type="application/json"]#store` + `script[type="application/json"]#wp-interactivity-store-data` ); if ( ! storeTag ) return {}; try { diff --git a/phpunit/experimental/interactivity-api/class-wp-interactivity-store-test.php b/phpunit/experimental/interactivity-api/class-wp-interactivity-store-test.php new file mode 100644 index 00000000000000..b066a97851f4c1 --- /dev/null +++ b/phpunit/experimental/interactivity-api/class-wp-interactivity-store-test.php @@ -0,0 +1,168 @@ +assertEmpty( WP_Interactivity_Store::get_data() ); + } + + public function test_store_can_be_merged() { + $data = array( + 'state' => array( + 'core' => array( + 'a' => 1, + 'b' => 2, + 'nested' => array( + 'c' => 3, + ), + ), + ), + ); + WP_Interactivity_Store::merge_data( $data ); + $this->assertSame( $data, WP_Interactivity_Store::get_data() ); + } + + public function test_store_can_be_extended() { + WP_Interactivity_Store::merge_data( + array( + 'state' => array( + 'core' => array( + 'a' => 1, + ), + ), + ) + ); + WP_Interactivity_Store::merge_data( + array( + 'state' => array( + 'core' => array( + 'b' => 2, + ), + 'custom' => array( + 'c' => 3, + ), + ), + ) + ); + $this->assertSame( + array( + 'state' => array( + 'core' => array( + 'a' => 1, + 'b' => 2, + ), + 'custom' => array( + 'c' => 3, + ), + ), + ), + WP_Interactivity_Store::get_data() + ); + } + + public function test_store_existing_props_should_be_overwritten() { + WP_Interactivity_Store::merge_data( + array( + 'state' => array( + 'core' => array( + 'a' => 1, + ), + ), + ) + ); + WP_Interactivity_Store::merge_data( + array( + 'state' => array( + 'core' => array( + 'a' => 'overwritten', + ), + ), + ) + ); + $this->assertSame( + array( + 'state' => array( + 'core' => array( + 'a' => 'overwritten', + ), + ), + ), + WP_Interactivity_Store::get_data() + ); + } + + public function test_store_existing_indexed_arrays_should_be_replaced() { + WP_Interactivity_Store::merge_data( + array( + 'state' => array( + 'core' => array( + 'a' => array( 1, 2 ), + ), + ), + ) + ); + WP_Interactivity_Store::merge_data( + array( + 'state' => array( + 'core' => array( + 'a' => array( 3, 4 ), + ), + ), + ) + ); + $this->assertSame( + array( + 'state' => array( + 'core' => array( + 'a' => array( 3, 4 ), + ), + ), + ), + WP_Interactivity_Store::get_data() + ); + } + + public function test_store_should_be_correctly_rendered() { + WP_Interactivity_Store::merge_data( + array( + 'state' => array( + 'core' => array( + 'a' => 1, + ), + ), + ) + ); + WP_Interactivity_Store::merge_data( + array( + 'state' => array( + 'core' => array( + 'b' => 2, + ), + ), + ) + ); + ob_start(); + WP_Interactivity_Store::render(); + $rendered = ob_get_clean(); + $this->assertSame( + '', + $rendered + ); + } +} From 1b7ed17eec267ba1149b03f4dd8cfe9c577afb56 Mon Sep 17 00:00:00 2001 From: Luis Herranz Date: Tue, 6 Jun 2023 18:19:28 +0200 Subject: [PATCH 26/34] Rename syntax to -- and data-wp-interactive (#51241) --- packages/interactivity/src/constants.js | 2 +- packages/interactivity/src/directives.js | 28 +++++++++++++++++++----- packages/interactivity/src/hydration.js | 2 +- packages/interactivity/src/vdom.js | 25 +++++++++++++++++---- 4 files changed, 46 insertions(+), 11 deletions(-) diff --git a/packages/interactivity/src/constants.js b/packages/interactivity/src/constants.js index f462753c9f8179..669e94263fb9ca 100644 --- a/packages/interactivity/src/constants.js +++ b/packages/interactivity/src/constants.js @@ -1 +1 @@ -export const directivePrefix = 'data-wp-'; +export const directivePrefix = 'wp'; diff --git a/packages/interactivity/src/directives.js b/packages/interactivity/src/directives.js index 14151945674222..5ecdcf6c4d6b93 100644 --- a/packages/interactivity/src/directives.js +++ b/packages/interactivity/src/directives.js @@ -64,7 +64,7 @@ export default () => { ); } ); - // data-wp-effect.[name] + // data-wp-effect--[name] directive( 'effect', ( { directives: { effect }, context, evaluate } ) => { const contextValue = useContext( context ); Object.values( effect ).forEach( ( path ) => { @@ -74,7 +74,7 @@ export default () => { } ); } ); - // data-wp-init.[name] + // data-wp-init--[name] directive( 'init', ( { directives: { init }, context, evaluate } ) => { const contextValue = useContext( context ); Object.values( init ).forEach( ( path ) => { @@ -84,7 +84,7 @@ export default () => { } ); } ); - // data-wp-on.[event] + // data-wp-on--[event] directive( 'on', ( { directives: { on }, element, evaluate, context } ) => { const contextValue = useContext( context ); Object.entries( on ).forEach( ( [ name, path ] ) => { @@ -94,7 +94,7 @@ export default () => { } ); } ); - // data-wp-class.[classname] + // data-wp-class--[classname] directive( 'class', ( { @@ -139,7 +139,7 @@ export default () => { } ); - // data-wp-bind.[attribute] + // data-wp-bind--[attribute] directive( 'bind', ( { directives: { bind }, element, context, evaluate } ) => { @@ -175,6 +175,24 @@ 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, + } ); + } + ); + // data-wp-ignore directive( 'ignore', diff --git a/packages/interactivity/src/hydration.js b/packages/interactivity/src/hydration.js index 2fc34eeb64b9b5..e5a8e5128a1d14 100644 --- a/packages/interactivity/src/hydration.js +++ b/packages/interactivity/src/hydration.js @@ -11,7 +11,7 @@ import { directivePrefix } from './constants'; export const init = async () => { document - .querySelectorAll( `[${ directivePrefix }island]` ) + .querySelectorAll( `[data-${ directivePrefix }-interactive]` ) .forEach( ( node ) => { if ( ! hydratedIslands.has( node ) ) { const fragment = createRootFragment( node.parentNode, node ); diff --git a/packages/interactivity/src/vdom.js b/packages/interactivity/src/vdom.js index 07640319b88a8a..fe09f492dbd566 100644 --- a/packages/interactivity/src/vdom.js +++ b/packages/interactivity/src/vdom.js @@ -7,9 +7,23 @@ import { h } from 'preact'; */ import { directivePrefix as p } from './constants'; -const ignoreAttr = `${ p }ignore`; -const islandAttr = `${ p }island`; -const directiveParser = new RegExp( `${ p }([^.]+)\.?(.*)$` ); +const ignoreAttr = `data-${ p }-ignore`; +const islandAttr = `data-${ p }-interactive`; +const fullPrefix = `data-${ p }-`; + +// Regular expression for directive parsing. +const directiveParser = new RegExp( + `^data-${ p }-` + // ${p} must be a prefix string, like 'wp'. + // Match alphanumeric characters including hyphen-separated + // segments. It excludes underscore intentionally to prevent confusion. + // E.g., "custom-directive". + '([a-z0-9]+(?:-[a-z0-9]+)*)' + + // (Optional) Match '--' followed by any alphanumeric charachters. It + // excludes underscore intentionally to prevent confusion, but it can + // contain multiple hyphens. E.g., "--custom-prefix--with-more-info". + '(?:--([a-z0-9][a-z0-9-]+))?$', + 'i' // Case insensitive. +); export const hydratedIslands = new WeakSet(); @@ -44,7 +58,10 @@ export function toVdom( root ) { for ( let i = 0; i < attributes.length; i++ ) { const n = attributes[ i ].name; - if ( n[ p.length ] && n.slice( 0, p.length ) === p ) { + if ( + n[ fullPrefix.length ] && + n.slice( 0, fullPrefix.length ) === fullPrefix + ) { if ( n === ignoreAttr ) { ignore = true; } else if ( n === islandAttr ) { From e740fff4ff73c5a10dd18afd9e0425a6742833a0 Mon Sep 17 00:00:00 2001 From: Luis Herranz Date: Tue, 6 Jun 2023 18:31:38 +0200 Subject: [PATCH 27/34] Interactivity API: initial support for SSR (#51229) * Initial version working with basic support for wp-bind * Add wp-context * Add wp-class * Add wp-style * Add wp-text * Add directive processing tests * Add WP_Directive_Processor class tests * Add wp-bind tests * Add wp-context tests * Add wp-class tests * Add wp-style tests * Add wp-text tests * Add evaluate tests * Fix PHP lint * Prevent errors with incorrect JSON objects * Add support for functions in the server --- lib/experimental/interactivity-api/blocks.php | 3 +- .../class-wp-directive-context.php | 77 ++++++++ .../class-wp-directive-processor.php | 176 ++++++++++++++++++ .../directive-processing.php | 156 ++++++++++++++++ .../interactivity-api/directives/wp-bind.php | 32 ++++ .../interactivity-api/directives/wp-class.php | 36 ++++ .../directives/wp-context.php | 30 +++ .../interactivity-api/directives/wp-style.php | 72 +++++++ .../interactivity-api/directives/wp-text.php | 27 +++ lib/experimental/interactivity-api/store.php | 2 +- lib/load.php | 8 + .../class-wp-directive-processor-test.php | 132 +++++++++++++ .../class-wp-interactivity-store-test.php | 2 +- .../directive-processing-test.php | 156 ++++++++++++++++ .../directives/wp-bind-test.php | 46 +++++ .../directives/wp-class-test.php | 98 ++++++++++ .../directives/wp-context-test.php | 77 ++++++++ .../directives/wp-style-test.php | 46 +++++ .../directives/wp-text-test.php | 45 +++++ 19 files changed, 1218 insertions(+), 3 deletions(-) create mode 100644 lib/experimental/interactivity-api/class-wp-directive-context.php create mode 100644 lib/experimental/interactivity-api/class-wp-directive-processor.php create mode 100644 lib/experimental/interactivity-api/directive-processing.php create mode 100644 lib/experimental/interactivity-api/directives/wp-bind.php create mode 100644 lib/experimental/interactivity-api/directives/wp-class.php create mode 100644 lib/experimental/interactivity-api/directives/wp-context.php create mode 100644 lib/experimental/interactivity-api/directives/wp-style.php create mode 100644 lib/experimental/interactivity-api/directives/wp-text.php create mode 100644 phpunit/experimental/interactivity-api/class-wp-directive-processor-test.php create mode 100644 phpunit/experimental/interactivity-api/directive-processing-test.php create mode 100644 phpunit/experimental/interactivity-api/directives/wp-bind-test.php create mode 100644 phpunit/experimental/interactivity-api/directives/wp-class-test.php create mode 100644 phpunit/experimental/interactivity-api/directives/wp-context-test.php create mode 100644 phpunit/experimental/interactivity-api/directives/wp-style-test.php create mode 100644 phpunit/experimental/interactivity-api/directives/wp-text-test.php diff --git a/lib/experimental/interactivity-api/blocks.php b/lib/experimental/interactivity-api/blocks.php index 2cee6e9499568b..62b17b43ed5ce6 100644 --- a/lib/experimental/interactivity-api/blocks.php +++ b/lib/experimental/interactivity-api/blocks.php @@ -3,7 +3,8 @@ * Extend WordPress core navigation block to use the Interactivity API. * Interactivity API directives are added using the Tag Processor while it is experimental. * - * @package gutenberg + * @package Gutenberg + * @subpackage Interactivity API */ /** diff --git a/lib/experimental/interactivity-api/class-wp-directive-context.php b/lib/experimental/interactivity-api/class-wp-directive-context.php new file mode 100644 index 00000000000000..7186922d137a89 --- /dev/null +++ b/lib/experimental/interactivity-api/class-wp-directive-context.php @@ -0,0 +1,77 @@ + + * + *
+ * + *
+ * + * + */ +class WP_Directive_Context { + /** + * The stack used to store contexts internally. + * + * @var array An array of contexts. + */ + protected $stack = array( array() ); + + /** + * Constructor. + * + * Accepts a context as an argument to initialize this with. + * + * @param array $context A context. + */ + function __construct( $context = array() ) { + $this->set_context( $context ); + } + + /** + * Return the current context. + * + * @return array The current context. + */ + public function get_context() { + return end( $this->stack ); + } + + /** + * Set the current context. + * + * @param array $context The context to be set. + * + * @return void + */ + public function set_context( $context ) { + if ( $context ) { + array_push( $this->stack, array_replace_recursive( $this->get_context(), $context ) ); + } + } + + /** + * Reset the context to its previous state. + * + * @return void + */ + public function rewind_context() { + array_pop( $this->stack ); + } +} diff --git a/lib/experimental/interactivity-api/class-wp-directive-processor.php b/lib/experimental/interactivity-api/class-wp-directive-processor.php new file mode 100644 index 00000000000000..2315f6e286702b --- /dev/null +++ b/lib/experimental/interactivity-api/class-wp-directive-processor.php @@ -0,0 +1,176 @@ +get_tag(); + + if ( self::is_html_void_element( $tag_name ) ) { + return false; + } + + while ( $this->next_tag( + array( + 'tag_name' => $tag_name, + 'tag_closers' => 'visit', + ) + ) ) { + if ( ! $this->is_tag_closer() ) { + $depth++; + continue; + } + + if ( 0 === $depth ) { + return true; + } + + $depth--; + } + + return false; + } + + /** + * Return the content between two balanced tags. + * + * When called on an opening tag, return the HTML content found between that + * opening tag and its matching closing tag. + * + * @return string The content between the current opening and its matching + * closing tag. + */ + public function get_inner_html() { + $bookmarks = $this->get_balanced_tag_bookmarks(); + if ( ! $bookmarks ) { + return false; + } + list( $start_name, $end_name ) = $bookmarks; + + $start = $this->bookmarks[ $start_name ]->end + 1; + $end = $this->bookmarks[ $end_name ]->start; + + $this->seek( $start_name ); // Return to original position. + $this->release_bookmark( $start_name ); + $this->release_bookmark( $end_name ); + + return substr( $this->html, $start, $end - $start ); + } + + /** + * Set the content between two balanced tags. + * + * When called on an opening tag, set the HTML content found between that + * opening tag and its matching closing tag. + * + * @param string $new_html The string to replace the content between the + * matching tags with. + * + * @return bool Whether the content was successfully replaced. + */ + public function set_inner_html( $new_html ) { + $this->get_updated_html(); // Apply potential previous updates. + + $bookmarks = $this->get_balanced_tag_bookmarks(); + if ( ! $bookmarks ) { + return false; + } + list( $start_name, $end_name ) = $bookmarks; + + $start = $this->bookmarks[ $start_name ]->end + 1; + $end = $this->bookmarks[ $end_name ]->start; + + $this->seek( $start_name ); // Return to original position. + $this->release_bookmark( $start_name ); + $this->release_bookmark( $end_name ); + + $this->lexical_updates[] = new WP_HTML_Text_Replacement( $start, $end, $new_html ); + return true; + } + + /** + * Return a pair of bookmarks for the current opening tag and the matching + * closing tag. + * + * @return array|false A pair of bookmarks, or false if there's no matching + * closing tag. + */ + public function get_balanced_tag_bookmarks() { + $i = 0; + while ( array_key_exists( 'start' . $i, $this->bookmarks ) ) { + ++$i; + } + $start_name = 'start' . $i; + + $this->set_bookmark( $start_name ); + if ( ! $this->next_balanced_closer() ) { + $this->release_bookmark( $start_name ); + return false; + } + + $i = 0; + while ( array_key_exists( 'end' . $i, $this->bookmarks ) ) { + ++$i; + } + $end_name = 'end' . $i; + $this->set_bookmark( $end_name ); + + return array( $start_name, $end_name ); + } + + /** + * Whether a given HTML element is void (e.g.
). + * + * @param string $tag_name The element in question. + * @return bool True if the element is void. + * + * @see https://html.spec.whatwg.org/#elements-2 + */ + public static function is_html_void_element( $tag_name ) { + switch ( $tag_name ) { + case 'AREA': + case 'BASE': + case 'BR': + case 'COL': + case 'EMBED': + case 'HR': + case 'IMG': + case 'INPUT': + case 'LINK': + case 'META': + case 'SOURCE': + case 'TRACK': + case 'WBR': + return true; + + default: + return false; + } + } +} diff --git a/lib/experimental/interactivity-api/directive-processing.php b/lib/experimental/interactivity-api/directive-processing.php new file mode 100644 index 00000000000000..594e592a22137f --- /dev/null +++ b/lib/experimental/interactivity-api/directive-processing.php @@ -0,0 +1,156 @@ + 'gutenberg_interactivity_process_wp_bind', + 'data-wp-context' => 'gutenberg_interactivity_process_wp_context', + 'data-wp-class' => 'gutenberg_interactivity_process_wp_class', + 'data-wp-style' => 'gutenberg_interactivity_process_wp_style', + 'data-wp-text' => 'gutenberg_interactivity_process_wp_text', + ); + + $tags = new WP_Directive_Processor( $block_content ); + $tags = gutenberg_interactivity_process_directives( $tags, 'data-wp-', $directives ); + return $tags->get_updated_html(); +} +add_filter( 'render_block', 'gutenberg_interactivity_process_directives_in_root_blocks', 10, 2 ); + +/** + * Mark the inner blocks with a temporary property so we can discard them later, + * and process only the root blocks. + * + * @param array $parsed_block The parsed block. + * @param array $source_block The source block. + * @param array $parent_block The parent block. + * + * @return array The parsed block. + */ +function gutenberg_interactivity_mark_inner_blocks( $parsed_block, $source_block, $parent_block ) { + if ( isset( $parent_block ) ) { + $parsed_block['is_inner_block'] = true; + } + return $parsed_block; +} +add_filter( 'render_block_data', 'gutenberg_interactivity_mark_inner_blocks', 10, 3 ); + +/** + * Process directives. + * + * @param WP_Directive_Processor $tags An instance of the WP_Directive_Processor. + * @param string $prefix Attribute prefix. + * @param string[] $directives Directives. + * + * @return WP_Directive_Processor The modified instance of the + * WP_Directive_Processor. + */ +function gutenberg_interactivity_process_directives( $tags, $prefix, $directives ) { + $context = new WP_Directive_Context; + $tag_stack = array(); + + while ( $tags->next_tag( array( 'tag_closers' => 'visit' ) ) ) { + $tag_name = strtolower( $tags->get_tag() ); + + // Is this a tag that closes the latest opening tag? + if ( $tags->is_tag_closer() ) { + if ( 0 === count( $tag_stack ) ) { + continue; + } + + list( $latest_opening_tag_name, $attributes ) = end( $tag_stack ); + if ( $latest_opening_tag_name === $tag_name ) { + array_pop( $tag_stack ); + + // If the matching opening tag didn't have any attribute directives, + // we move on. + if ( 0 === count( $attributes ) ) { + continue; + } + } + } else { + // Helper that removes the part after the double hyphen before looking for + // the directive processor inside `$attribute_directives`. + $get_directive_type = function ( $attr ) { + return explode( '--', $attr )[0]; + }; + + $attributes = $tags->get_attribute_names_with_prefix( $prefix ); + $attributes = array_map( $get_directive_type, $attributes ); + $attributes = array_intersect( $attributes, array_keys( $directives ) ); + + // If this is an open tag, and if it either has attribute directives, or + // if we're inside a tag that does, take note of this tag and its + // attribute directives so we can call its directive processor once we + // encounter the matching closing tag. + if ( + ! WP_Directive_Processor::is_html_void_element( $tags->get_tag() ) && + ( 0 !== count( $attributes ) || 0 !== count( $tag_stack ) ) + ) { + $tag_stack[] = array( $tag_name, $attributes ); + } + } + + foreach ( $attributes as $attribute ) { + call_user_func( $directives[ $attribute ], $tags, $context ); + } + } + + return $tags; +} + +/** + * Resolve the reference using the store and the context from the provided path. + * + * @param string $path Path. + * @param array $context Context data. + * @return mixed + */ +function gutenberg_interactivity_evaluate_reference( $path, array $context = array() ) { + $store = array_merge( + WP_Interactivity_Store::get_data(), + array( 'context' => $context ) + ); + + if ( strpos( $path, '!' ) === 0 ) { + $path = substr( $path, 1 ); + $has_negation_operator = true; + } + + $array = explode( '.', $path ); + $current = $store; + foreach ( $array as $p ) { + if ( isset( $current[ $p ] ) ) { + $current = $current[ $p ]; + } else { + return null; + } + } + + // Check if $current is a function and if so, call it passing the store. + if ( is_callable( $current ) ) { + $current = call_user_func( $current, $store ); + } + + // Return the opposite if it has a negator operator (!). + return isset( $has_negation_operator ) ? ! $current : $current; +} diff --git a/lib/experimental/interactivity-api/directives/wp-bind.php b/lib/experimental/interactivity-api/directives/wp-bind.php new file mode 100644 index 00000000000000..d65f1df16ac640 --- /dev/null +++ b/lib/experimental/interactivity-api/directives/wp-bind.php @@ -0,0 +1,32 @@ +is_tag_closer() ) { + return; + } + + $prefixed_attributes = $tags->get_attribute_names_with_prefix( 'data-wp-bind--' ); + + foreach ( $prefixed_attributes as $attr ) { + list( , $bound_attr ) = explode( '--', $attr ); + if ( empty( $bound_attr ) ) { + continue; + } + + $expr = $tags->get_attribute( $attr ); + $value = gutenberg_interactivity_evaluate_reference( $expr, $context->get_context() ); + $tags->set_attribute( $bound_attr, $value ); + } +} diff --git a/lib/experimental/interactivity-api/directives/wp-class.php b/lib/experimental/interactivity-api/directives/wp-class.php new file mode 100644 index 00000000000000..93c3b4d4a00998 --- /dev/null +++ b/lib/experimental/interactivity-api/directives/wp-class.php @@ -0,0 +1,36 @@ +is_tag_closer() ) { + return; + } + + $prefixed_attributes = $tags->get_attribute_names_with_prefix( 'data-wp-class--' ); + + foreach ( $prefixed_attributes as $attr ) { + list( , $class_name ) = explode( '--', $attr ); + if ( empty( $class_name ) ) { + continue; + } + + $expr = $tags->get_attribute( $attr ); + $add_class = gutenberg_interactivity_evaluate_reference( $expr, $context->get_context() ); + if ( $add_class ) { + $tags->add_class( $class_name ); + } else { + $tags->remove_class( $class_name ); + } + } +} diff --git a/lib/experimental/interactivity-api/directives/wp-context.php b/lib/experimental/interactivity-api/directives/wp-context.php new file mode 100644 index 00000000000000..68a436aaaca8de --- /dev/null +++ b/lib/experimental/interactivity-api/directives/wp-context.php @@ -0,0 +1,30 @@ +is_tag_closer() ) { + $context->rewind_context(); + return; + } + + $value = $tags->get_attribute( 'data-wp-context' ); + if ( null === $value ) { + // No data-wp-context directive. + return; + } + + $new_context = json_decode( $value, true ); + // TODO: Error handling. + + $context->set_context( $new_context ); +} diff --git a/lib/experimental/interactivity-api/directives/wp-style.php b/lib/experimental/interactivity-api/directives/wp-style.php new file mode 100644 index 00000000000000..f87a85f099a0ea --- /dev/null +++ b/lib/experimental/interactivity-api/directives/wp-style.php @@ -0,0 +1,72 @@ +is_tag_closer() ) { + return; + } + + $prefixed_attributes = $tags->get_attribute_names_with_prefix( 'data-wp-style--' ); + + foreach ( $prefixed_attributes as $attr ) { + list( , $style_name ) = explode( '--', $attr ); + if ( empty( $style_name ) ) { + continue; + } + + $expr = $tags->get_attribute( $attr ); + $style_value = gutenberg_interactivity_evaluate_reference( $expr, $context->get_context() ); + if ( $style_value ) { + $style_attr = $tags->get_attribute( 'style' ); + $style_attr = gutenberg_interactivity_set_style( $style_attr, $style_name, $style_value ); + $tags->set_attribute( 'style', $style_attr ); + } else { + // TODO: Do we want to unset styles if they're null? + } + } +} + +/** + * Set style. + * + * @param string $style Existing style to amend. + * @param string $name Style property name. + * @param string $value Style property value. + * @return string Amended styles. + */ +function gutenberg_interactivity_set_style( $style, $name, $value ) { + $style_assignments = explode( ';', $style ); + $modified = false; + foreach ( $style_assignments as $style_assignment ) { + list( $style_name ) = explode( ':', $style_assignment ); + if ( trim( $style_name ) === $name ) { + // TODO: Retain surrounding whitespace from $style_value, if any. + $style_assignment = $style_name . ': ' . $value; + $modified = true; + break; + } + } + + if ( ! $modified ) { + $new_style_assignment = $name . ': ' . $value; + // If the last element is empty or whitespace-only, we insert + // the new "key: value" pair before it. + if ( empty( trim( end( $style_assignments ) ) ) ) { + array_splice( $style_assignments, - 1, 0, $new_style_assignment ); + } else { + array_push( $style_assignments, $new_style_assignment ); + } + } + return implode( ';', $style_assignments ); +} diff --git a/lib/experimental/interactivity-api/directives/wp-text.php b/lib/experimental/interactivity-api/directives/wp-text.php new file mode 100644 index 00000000000000..b0cfc98a74e702 --- /dev/null +++ b/lib/experimental/interactivity-api/directives/wp-text.php @@ -0,0 +1,27 @@ +is_tag_closer() ) { + return; + } + + $value = $tags->get_attribute( 'data-wp-text' ); + if ( null === $value ) { + return; + } + + $text = gutenberg_interactivity_evaluate_reference( $value, $context->get_context() ); + $tags->set_inner_html( esc_html( $text ) ); +} diff --git a/lib/experimental/interactivity-api/store.php b/lib/experimental/interactivity-api/store.php index 9554cef15c1a4a..88c4b2ebd1038a 100644 --- a/lib/experimental/interactivity-api/store.php +++ b/lib/experimental/interactivity-api/store.php @@ -1,6 +1,6 @@ outside
inside
'; + + public function test_next_balanced_closer_stays_on_void_tag() { + $tags = new WP_Directive_Processor( self::HTML ); + + $tags->next_tag( 'img' ); + $result = $tags->next_balanced_closer(); + $this->assertSame( 'IMG', $tags->get_tag() ); + $this->assertFalse( $result ); + } + + public function test_next_balanced_closer_proceeds_to_correct_tag() { + $tags = new WP_Directive_Processor( self::HTML ); + + $tags->next_tag( 'section' ); + $tags->next_balanced_closer(); + $this->assertSame( 'SECTION', $tags->get_tag() ); + $this->assertTrue( $tags->is_tag_closer() ); + } + + public function test_next_balanced_closer_proceeds_to_correct_tag_for_nested_tag() { + $tags = new WP_Directive_Processor( self::HTML ); + + $tags->next_tag( 'div' ); + $tags->next_tag( 'div' ); + $tags->next_balanced_closer(); + $this->assertSame( 'DIV', $tags->get_tag() ); + $this->assertTrue( $tags->is_tag_closer() ); + } + + public function test_get_inner_html_returns_correct_result() { + $tags = new WP_Directive_Processor( self::HTML ); + + $tags->next_tag( 'section' ); + $this->assertSame( '
inside
', $tags->get_inner_html() ); + } + + public function test_set_inner_html_on_void_element_has_no_effect() { + $tags = new WP_Directive_Processor( self::HTML ); + + $tags->next_tag( 'img' ); + $content = $tags->set_inner_html( 'This is the new img content' ); + $this->assertFalse( $content ); + $this->assertSame( self::HTML, $tags->get_updated_html() ); + } + + public function test_set_inner_html_sets_content_correctly() { + $tags = new WP_Directive_Processor( self::HTML ); + + $tags->next_tag( 'section' ); + $tags->set_inner_html( 'This is the new section content.' ); + $this->assertSame( '
outside
This is the new section content.
', $tags->get_updated_html() ); + } + + public function test_set_inner_html_updates_bookmarks_correctly() { + $tags = new WP_Directive_Processor( self::HTML ); + + $tags->next_tag( 'div' ); + $tags->set_bookmark( 'start' ); + $tags->next_tag( 'img' ); + $this->assertSame( 'IMG', $tags->get_tag() ); + $tags->set_bookmark( 'after' ); + $tags->seek( 'start' ); + + $tags->set_inner_html( 'This is the new div content.' ); + $this->assertSame( '
This is the new div content.
inside
', $tags->get_updated_html() ); + $tags->seek( 'after' ); + $this->assertSame( 'IMG', $tags->get_tag() ); + } + + public function test_set_inner_html_subsequent_updates_on_the_same_tag_work() { + $tags = new WP_Directive_Processor( self::HTML ); + + $tags->next_tag( 'section' ); + $tags->set_inner_html( 'This is the new section content.' ); + $tags->set_inner_html( 'This is the even newer section content.' ); + $this->assertSame( '
outside
This is the even newer section content.
', $tags->get_updated_html() ); + } + + public function test_set_inner_html_followed_by_set_attribute_works() { + $tags = new WP_Directive_Processor( self::HTML ); + + $tags->next_tag( 'section' ); + $tags->set_inner_html( 'This is the new section content.' ); + $tags->set_attribute( 'id', 'thesection' ); + $this->assertSame( '
outside
This is the new section content.
', $tags->get_updated_html() ); + } + + public function test_set_inner_html_preceded_by_set_attribute_works() { + $tags = new WP_Directive_Processor( self::HTML ); + + $tags->next_tag( 'section' ); + $tags->set_attribute( 'id', 'thesection' ); + $tags->set_inner_html( 'This is the new section content.' ); + $this->assertSame( '
outside
This is the new section content.
', $tags->get_updated_html() ); + } + + /** + * TODO: Review this, how that the code is in Gutenberg. + */ + public function test_set_inner_html_invalidates_bookmarks_that_point_to_replaced_content() { + $this->markTestSkipped( "This requires on bookmark invalidation, which is only in GB's WP 6.3 compat layer." ); + + $tags = new WP_Directive_Processor( self::HTML ); + + $tags->next_tag( 'section' ); + $tags->set_bookmark( 'start' ); + $tags->next_tag( 'img' ); + $tags->set_bookmark( 'replaced' ); + $tags->seek( 'start' ); + + $tags->set_inner_html( 'This is the new section content.' ); + $this->assertSame( '
outside
This is the new section content.
', $tags->get_updated_html() ); + + $this->expectExceptionMessage( 'Invalid bookmark name' ); + $successful_seek = $tags->seek( 'replaced' ); + $this->assertFalse( $successful_seek ); + } +} 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 b066a97851f4c1..84286457f26129 100644 --- a/phpunit/experimental/interactivity-api/class-wp-interactivity-store-test.php +++ b/phpunit/experimental/interactivity-api/class-wp-interactivity-store-test.php @@ -9,7 +9,7 @@ /** * Tests for the `WP_Interactivity_Store` class. * - * @group directives + * @group interactivity-api * @covers WP_Interactivity_Store */ class WP_Interactivity_Store_Test extends WP_UnitTestCase { diff --git a/phpunit/experimental/interactivity-api/directive-processing-test.php b/phpunit/experimental/interactivity-api/directive-processing-test.php new file mode 100644 index 00000000000000..811305f1b71983 --- /dev/null +++ b/phpunit/experimental/interactivity-api/directive-processing-test.php @@ -0,0 +1,156 @@ +createMock( Helper_Class::class ); + + $test_helper->expects( $this->exactly( 2 ) ) + ->method( 'process_foo_test' ) + ->with( + $this->callback( + function( $p ) { + return 'DIV' === $p->get_tag() && ( + // Either this is a closing tag... + $p->is_tag_closer() || + // ...or it is an open tag, and has the directive attribute set. + ( ! $p->is_tag_closer() && 'abc' === $p->get_attribute( 'foo-test' ) ) + ); + } + ) + ); + + $directives = array( + 'foo-test' => array( $test_helper, 'process_foo_test' ), + ); + + $markup = '
Example:
This is a test>
Here is a nested div
'; + $tags = new WP_HTML_Tag_Processor( $markup ); + gutenberg_interactivity_process_directives( $tags, 'foo-', $directives ); + } + + public function test_directives_with_double_hyphen_processed_correctly() { + $test_helper = $this->createMock( Helper_Class::class ); + $test_helper->expects( $this->atLeastOnce() ) + ->method( 'process_foo_test' ); + + $directives = array( + 'foo-test' => array( $test_helper, 'process_foo_test' ), + ); + + $markup = '
'; + $tags = new WP_HTML_Tag_Processor( $markup ); + gutenberg_interactivity_process_directives( $tags, 'foo-', $directives ); + } +} + +/** + * Tests for the gutenberg_interactivity_evaluate_reference function. + * + * @group interactivity-api + * @covers gutenberg_interactivity_evaluate_reference + */ +class Tests_Utils_Evaluate extends WP_UnitTestCase { + public function test_evaluate_function_should_access_state() { + // Init a simple store. + wp_store( + array( + 'state' => array( + 'core' => array( + 'number' => 1, + 'bool' => true, + 'nested' => array( + 'string' => 'hi', + ), + ), + ), + ) + ); + $this->assertSame( 1, gutenberg_interactivity_evaluate_reference( 'state.core.number' ) ); + $this->assertTrue( gutenberg_interactivity_evaluate_reference( 'state.core.bool' ) ); + $this->assertSame( 'hi', gutenberg_interactivity_evaluate_reference( 'state.core.nested.string' ) ); + $this->assertFalse( gutenberg_interactivity_evaluate_reference( '!state.core.bool' ) ); + } + + public function test_evaluate_function_should_access_passed_context() { + $context = array( + 'local' => array( + 'number' => 2, + 'bool' => false, + 'nested' => array( + 'string' => 'bye', + ), + ), + ); + $this->assertSame( 2, gutenberg_interactivity_evaluate_reference( 'context.local.number', $context ) ); + $this->assertFalse( gutenberg_interactivity_evaluate_reference( 'context.local.bool', $context ) ); + $this->assertTrue( gutenberg_interactivity_evaluate_reference( '!context.local.bool', $context ) ); + $this->assertSame( 'bye', gutenberg_interactivity_evaluate_reference( 'context.local.nested.string', $context ) ); + // Previously defined state is also accessible. + $this->assertSame( 1, gutenberg_interactivity_evaluate_reference( 'state.core.number' ) ); + $this->assertTrue( gutenberg_interactivity_evaluate_reference( 'state.core.bool' ) ); + $this->assertSame( 'hi', gutenberg_interactivity_evaluate_reference( 'state.core.nested.string' ) ); + } + + public function test_evaluate_function_should_return_null_for_unresolved_paths() { + $this->assertNull( gutenberg_interactivity_evaluate_reference( 'this.property.doesnt.exist' ) ); + } + + public function test_evaluate_function_should_execute_functions() { + $context = new WP_Directive_Context( array( 'count' => 2 ) ); + $helper = new Helper_Class; + + wp_store( + array( + 'state' => array( + 'count' => 3, + ), + 'selectors' => array( + 'anonymous_function' => function( $store ) { + return $store['state']['count'] + $store['context']['count']; + }, + 'function_name' => 'gutenberg_test_process_directives_helper_increment', + 'class_method' => array( $helper, 'increment' ), + 'class_static_method' => 'Helper_Class::static_increment', + 'class_static_method_as_array' => array( 'Helper_Class', 'static_increment' ), + ), + ) + ); + + $this->assertSame( 5, gutenberg_interactivity_evaluate_reference( 'selectors.anonymous_function', $context->get_context() ) ); + $this->assertSame( 5, gutenberg_interactivity_evaluate_reference( 'selectors.function_name', $context->get_context() ) ); + $this->assertSame( 5, gutenberg_interactivity_evaluate_reference( 'selectors.class_static_method', $context->get_context() ) ); + $this->assertSame( 5, gutenberg_interactivity_evaluate_reference( 'selectors.class_static_method_as_array', $context->get_context() ) ); + } +} diff --git a/phpunit/experimental/interactivity-api/directives/wp-bind-test.php b/phpunit/experimental/interactivity-api/directives/wp-bind-test.php new file mode 100644 index 00000000000000..bfb4c428cd9466 --- /dev/null +++ b/phpunit/experimental/interactivity-api/directives/wp-bind-test.php @@ -0,0 +1,46 @@ +'; + $tags = new WP_HTML_Tag_Processor( $markup ); + $tags->next_tag(); + + $context_before = new WP_Directive_Context( array( 'myblock' => array( 'imageSource' => './wordpress.png' ) ) ); + $context = $context_before; + gutenberg_interactivity_process_wp_bind( $tags, $context ); + + $this->assertSame( + '', + $tags->get_updated_html() + ); + $this->assertSame( './wordpress.png', $tags->get_attribute( 'src' ) ); + $this->assertSame( $context_before->get_context(), $context->get_context(), 'data-wp-bind directive changed context' ); + } + + public function test_directive_ignores_empty_bound_attribute() { + $markup = ''; + $tags = new WP_HTML_Tag_Processor( $markup ); + $tags->next_tag(); + + $context_before = new WP_Directive_Context( array( 'myblock' => array( 'imageSource' => './wordpress.png' ) ) ); + $context = $context_before; + gutenberg_interactivity_process_wp_bind( $tags, $context ); + + $this->assertSame( $markup, $tags->get_updated_html() ); + $this->assertNull( $tags->get_attribute( 'src' ) ); + $this->assertSame( $context_before->get_context(), $context->get_context(), 'data-wp-bind directive changed context' ); + } +} diff --git a/phpunit/experimental/interactivity-api/directives/wp-class-test.php b/phpunit/experimental/interactivity-api/directives/wp-class-test.php new file mode 100644 index 00000000000000..419546c6d9ef8b --- /dev/null +++ b/phpunit/experimental/interactivity-api/directives/wp-class-test.php @@ -0,0 +1,98 @@ +Test'; + $tags = new WP_HTML_Tag_Processor( $markup ); + $tags->next_tag(); + + $context_before = new WP_Directive_Context( array( 'myblock' => array( 'isRed' => true ) ) ); + $context = $context_before; + gutenberg_interactivity_process_wp_class( $tags, $context ); + + $this->assertSame( + '
Test
', + $tags->get_updated_html() + ); + $this->assertStringContainsString( 'red', $tags->get_attribute( 'class' ) ); + $this->assertSame( $context_before->get_context(), $context->get_context(), 'data-wp-class directive changed context' ); + } + + public function test_directive_removes_class() { + $markup = '
Test
'; + $tags = new WP_HTML_Tag_Processor( $markup ); + $tags->next_tag(); + + $context_before = new WP_Directive_Context( array( 'myblock' => array( 'isBlue' => false ) ) ); + $context = $context_before; + gutenberg_interactivity_process_wp_class( $tags, $context ); + + $this->assertSame( + '
Test
', + $tags->get_updated_html() + ); + $this->assertStringNotContainsString( 'blue', $tags->get_attribute( 'class' ) ); + $this->assertSame( $context_before->get_context(), $context->get_context(), 'data-wp-class directive changed context' ); + } + + public function test_directive_removes_empty_class_attribute() { + $markup = '
Test
'; + $tags = new WP_HTML_Tag_Processor( $markup ); + $tags->next_tag(); + + $context_before = new WP_Directive_Context( array( 'myblock' => array( 'isBlue' => false ) ) ); + $context = $context_before; + gutenberg_interactivity_process_wp_class( $tags, $context ); + + $this->assertSame( + // WP_HTML_Tag_Processor has a TODO note to prune whitespace after classname removal. + '
Test
', + $tags->get_updated_html() + ); + $this->assertNull( $tags->get_attribute( 'class' ) ); + $this->assertSame( $context_before->get_context(), $context->get_context(), 'data-wp-class directive changed context' ); + } + + public function test_directive_does_not_remove_non_existant_class() { + $markup = '
Test
'; + $tags = new WP_HTML_Tag_Processor( $markup ); + $tags->next_tag(); + + $context_before = new WP_Directive_Context( array( 'myblock' => array( 'isBlue' => false ) ) ); + $context = $context_before; + gutenberg_interactivity_process_wp_class( $tags, $context ); + + $this->assertSame( + '
Test
', + $tags->get_updated_html() + ); + $this->assertSame( 'green red', $tags->get_attribute( 'class' ) ); + $this->assertSame( $context_before->get_context(), $context->get_context(), 'data-wp-class directive changed context' ); + } + + public function test_directive_ignores_empty_class_name() { + $markup = '
Test
'; + $tags = new WP_HTML_Tag_Processor( $markup ); + $tags->next_tag(); + + $context_before = new WP_Directive_Context( array( 'myblock' => array( 'isRed' => true ) ) ); + $context = $context_before; + gutenberg_interactivity_process_wp_class( $tags, $context ); + + $this->assertSame( $markup, $tags->get_updated_html() ); + $this->assertStringNotContainsString( 'red', $tags->get_attribute( 'class' ) ); + $this->assertSame( $context_before->get_context(), $context->get_context(), 'data-wp-class directive changed context' ); + } +} diff --git a/phpunit/experimental/interactivity-api/directives/wp-context-test.php b/phpunit/experimental/interactivity-api/directives/wp-context-test.php new file mode 100644 index 00000000000000..90ffb7dd9bf296 --- /dev/null +++ b/phpunit/experimental/interactivity-api/directives/wp-context-test.php @@ -0,0 +1,77 @@ + array( 'open' => false ), + 'otherblock' => array( 'somekey' => 'somevalue' ), + ) + ); + + $markup = '
'; + $tags = new WP_HTML_Tag_Processor( $markup ); + $tags->next_tag(); + + gutenberg_interactivity_process_wp_context( $tags, $context ); + + $this->assertSame( + array( + 'myblock' => array( 'open' => true ), + 'otherblock' => array( 'somekey' => 'somevalue' ), + ), + $context->get_context() + ); + } + + public function test_directive_resets_context_correctly_upon_closing_tag() { + $context = new WP_Directive_Context( + array( 'my-key' => 'original-value' ) + ); + + $context->set_context( + array( 'my-key' => 'new-value' ) + ); + + $markup = '
'; + $tags = new WP_HTML_Tag_Processor( $markup ); + $tags->next_tag( array( 'tag_closers' => 'visit' ) ); + + gutenberg_interactivity_process_wp_context( $tags, $context ); + + $this->assertSame( + array( 'my-key' => 'original-value' ), + $context->get_context() + ); + } + + public function test_directive_doesnt_throw_on_malformed_context_objects() { + $context = new WP_Directive_Context( + array( 'my-key' => 'some-value' ) + ); + + $markup = '
'; + $tags = new WP_HTML_Tag_Processor( $markup ); + $tags->next_tag(); + + gutenberg_interactivity_process_wp_context( $tags, $context ); + + $this->assertSame( + array( 'my-key' => 'some-value' ), + $context->get_context() + ); + } + +} diff --git a/phpunit/experimental/interactivity-api/directives/wp-style-test.php b/phpunit/experimental/interactivity-api/directives/wp-style-test.php new file mode 100644 index 00000000000000..8942559b2fe89f --- /dev/null +++ b/phpunit/experimental/interactivity-api/directives/wp-style-test.php @@ -0,0 +1,46 @@ +Test
'; + $tags = new WP_HTML_Tag_Processor( $markup ); + $tags->next_tag(); + + $context_before = new WP_Directive_Context( array( 'myblock' => array( 'color' => 'green' ) ) ); + $context = $context_before; + gutenberg_interactivity_process_wp_style( $tags, $context ); + + $this->assertSame( + '
Test
', + $tags->get_updated_html() + ); + $this->assertStringContainsString( 'color: green;', $tags->get_attribute( 'style' ) ); + $this->assertSame( $context_before->get_context(), $context->get_context(), 'data-wp-style directive changed context' ); + } + + public function test_directive_ignores_empty_style() { + $markup = '
Test
'; + $tags = new WP_HTML_Tag_Processor( $markup ); + $tags->next_tag(); + + $context_before = new WP_Directive_Context( array( 'myblock' => array( 'color' => 'green' ) ) ); + $context = $context_before; + gutenberg_interactivity_process_wp_style( $tags, $context ); + + $this->assertSame( $markup, $tags->get_updated_html() ); + $this->assertStringNotContainsString( 'color: green;', $tags->get_attribute( 'style' ) ); + $this->assertSame( $context_before->get_context(), $context->get_context(), 'data-wp-style directive changed context' ); + } +} diff --git a/phpunit/experimental/interactivity-api/directives/wp-text-test.php b/phpunit/experimental/interactivity-api/directives/wp-text-test.php new file mode 100644 index 00000000000000..81d2d0f370a64b --- /dev/null +++ b/phpunit/experimental/interactivity-api/directives/wp-text-test.php @@ -0,0 +1,45 @@ +'; + + $tags = new WP_Directive_Processor( $markup ); + $tags->next_tag(); + + $context_before = new WP_Directive_Context( array( 'myblock' => array( 'someText' => 'The HTML tag
produces a line break.' ) ) ); + $context = clone $context_before; + gutenberg_interactivity_process_wp_text( $tags, $context ); + + $expected_markup = '
The HTML tag <br> produces a line break.
'; + $this->assertSame( $expected_markup, $tags->get_updated_html() ); + $this->assertSame( $context_before->get_context(), $context->get_context(), 'data-wp-text directive changed context' ); + } + + public function test_directive_overwrites_inner_html_based_on_attribute_value() { + $markup = '
Lorem ipsum dolor sit.
'; + + $tags = new WP_Directive_Processor( $markup ); + $tags->next_tag(); + + $context_before = new WP_Directive_Context( array( 'myblock' => array( 'someText' => 'Honi soit qui mal y pense.' ) ) ); + $context = clone $context_before; + gutenberg_interactivity_process_wp_text( $tags, $context ); + + $expected_markup = '
Honi soit qui mal y pense.
'; + $this->assertSame( $expected_markup, $tags->get_updated_html() ); + $this->assertSame( $context_before->get_context(), $context->get_context(), 'data-wp-text directive changed context' ); + } +} From 3ac4a7b950533a27c5edc9175ebb942663094d7e Mon Sep 17 00:00:00 2001 From: David Arenas Date: Thu, 15 Jun 2023 23:23:07 +0200 Subject: [PATCH 28/34] Remove require for missing script-loader.php --- lib/load.php | 1 - 1 file changed, 1 deletion(-) diff --git a/lib/load.php b/lib/load.php index c061c0cf855a48..71aaa39a3717e3 100644 --- a/lib/load.php +++ b/lib/load.php @@ -103,7 +103,6 @@ function gutenberg_is_experiment_enabled( $name ) { require __DIR__ . '/experimental/kses.php'; require __DIR__ . '/experimental/l10n.php'; require __DIR__ . '/experimental/navigation-fallback.php'; -require __DIR__ . '/experimental/interactivity-api/script-loader.php'; if ( gutenberg_is_experiment_enabled( 'gutenberg-interactivity-api-core-blocks' ) ) { require __DIR__ . '/experimental/interactivity-api/blocks.php'; } From 4b0b87a343d90b6cf2487e043ed987f7e4a888de Mon Sep 17 00:00:00 2001 From: Luis Herranz Date: Wed, 21 Jun 2023 15:32:35 +0200 Subject: [PATCH 29/34] Remove missing PHP file --- lib/load.php | 1 - 1 file changed, 1 deletion(-) diff --git a/lib/load.php b/lib/load.php index a819106ce15a50..0397aefd384f42 100644 --- a/lib/load.php +++ b/lib/load.php @@ -103,7 +103,6 @@ function gutenberg_is_experiment_enabled( $name ) { require __DIR__ . '/experimental/navigation-theme-opt-in.php'; require __DIR__ . '/experimental/kses.php'; require __DIR__ . '/experimental/l10n.php'; -require __DIR__ . '/experimental/interactivity-api/script-loader.php'; if ( gutenberg_is_experiment_enabled( 'gutenberg-interactivity-api-core-blocks' ) ) { require __DIR__ . '/experimental/interactivity-api/blocks.php'; } From 02a46bb8f8a43c5f2af9a60ca941aa89343faf1b Mon Sep 17 00:00:00 2001 From: Luis Herranz Date: Wed, 21 Jun 2023 16:09:14 +0200 Subject: [PATCH 30/34] Rename view file and fix block.json --- packages/block-library/src/file/block.json | 2 +- .../block-library/src/file/{view-interactivity.js => view.js} | 0 packages/block-library/src/navigation/block.json | 2 +- .../src/navigation/{view-interactivity.js => view.js} | 0 4 files changed, 2 insertions(+), 2 deletions(-) rename packages/block-library/src/file/{view-interactivity.js => view.js} (100%) rename packages/block-library/src/navigation/{view-interactivity.js => view.js} (100%) diff --git a/packages/block-library/src/file/block.json b/packages/block-library/src/file/block.json index 4789e014e1083f..12edc20630d1ed 100644 --- a/packages/block-library/src/file/block.json +++ b/packages/block-library/src/file/block.json @@ -67,7 +67,7 @@ } } }, - "viewScript": "file:./interactivity.min.js", + "viewScript": "file:./view.min.js", "editorStyle": "wp-block-file-editor", "style": "wp-block-file" } diff --git a/packages/block-library/src/file/view-interactivity.js b/packages/block-library/src/file/view.js similarity index 100% rename from packages/block-library/src/file/view-interactivity.js rename to packages/block-library/src/file/view.js diff --git a/packages/block-library/src/navigation/block.json b/packages/block-library/src/navigation/block.json index 135e7fd1f75d3d..e5880e8370dcea 100644 --- a/packages/block-library/src/navigation/block.json +++ b/packages/block-library/src/navigation/block.json @@ -133,7 +133,7 @@ } } }, - "viewScript": "file:./interactivity.min.js", + "viewScript": "file:./view.min.js", "editorStyle": "wp-block-navigation-editor", "style": "wp-block-navigation" } diff --git a/packages/block-library/src/navigation/view-interactivity.js b/packages/block-library/src/navigation/view.js similarity index 100% rename from packages/block-library/src/navigation/view-interactivity.js rename to packages/block-library/src/navigation/view.js From 294bf691de0768c402da7b2dbeef27837e0dcd3b Mon Sep 17 00:00:00 2001 From: Luis Herranz Date: Wed, 21 Jun 2023 16:53:25 +0200 Subject: [PATCH 31/34] Add "interactivity" to supports and fix renaming --- docs/reference-guides/core-blocks.md | 4 +- packages/block-library/src/file/block.json | 3 +- packages/block-library/src/file/index.php | 17 ---- .../block-library/src/navigation/block.json | 3 +- .../block-library/src/navigation/index.php | 15 ---- .../src/navigation/view-modal.js | 78 ------------------- 6 files changed, 6 insertions(+), 114 deletions(-) delete mode 100644 packages/block-library/src/navigation/view-modal.js diff --git a/docs/reference-guides/core-blocks.md b/docs/reference-guides/core-blocks.md index 24cb7cfeefded1..abf253e9fe7abe 100644 --- a/docs/reference-guides/core-blocks.md +++ b/docs/reference-guides/core-blocks.md @@ -266,7 +266,7 @@ Add a link to a downloadable file. ([Source](https://github.com/WordPress/gutenb - **Name:** core/file - **Category:** media -- **Supports:** align, anchor, color (background, gradients, link, ~~text~~) +- **Supports:** align, anchor, color (background, gradients, link, ~~text~~), interactivity - **Attributes:** displayPreview, downloadButtonText, fileId, fileName, href, id, previewHeight, showDownloadButton, textLinkHref, textLinkTarget ## Classic @@ -412,7 +412,7 @@ A collection of blocks that allow visitors to get around your site. ([Source](ht - **Name:** core/navigation - **Category:** theme -- **Supports:** align (full, wide), inserter, layout (allowSizingOnChildren, default, ~~allowInheriting~~, ~~allowSwitching~~, ~~allowVerticalAlignment~~), spacing (blockGap, units), typography (fontSize, lineHeight), ~~html~~ +- **Supports:** align (full, wide), inserter, interactivity, layout (allowSizingOnChildren, default, ~~allowInheriting~~, ~~allowSwitching~~, ~~allowVerticalAlignment~~), spacing (blockGap, units), typography (fontSize, lineHeight), ~~html~~ - **Attributes:** __unstableLocation, backgroundColor, customBackgroundColor, customOverlayBackgroundColor, customOverlayTextColor, customTextColor, hasIcon, icon, maxNestingLevel, openSubmenusOnClick, overlayBackgroundColor, overlayMenu, overlayTextColor, ref, rgbBackgroundColor, rgbTextColor, showSubmenuIcon, templateLock, textColor ## Custom Link diff --git a/packages/block-library/src/file/block.json b/packages/block-library/src/file/block.json index 12edc20630d1ed..6096ba36d2a670 100644 --- a/packages/block-library/src/file/block.json +++ b/packages/block-library/src/file/block.json @@ -65,7 +65,8 @@ "background": true, "link": true } - } + }, + "interactivity": true }, "viewScript": "file:./view.min.js", "editorStyle": "wp-block-file-editor", diff --git a/packages/block-library/src/file/index.php b/packages/block-library/src/file/index.php index 26a48ccad98c30..a7011cc9efa348 100644 --- a/packages/block-library/src/file/index.php +++ b/packages/block-library/src/file/index.php @@ -5,23 +5,6 @@ * @package WordPress */ -if ( defined( 'IS_GUTENBERG_PLUGIN' ) && IS_GUTENBERG_PLUGIN ) { - /** - * Replaces view script for the File block with version using Interactivity API. - * - * @param array $metadata Block metadata as read in via block.json. - * - * @return array Filtered block type metadata. - */ - function gutenberg_block_core_file_update_interactive_view_script( $metadata ) { - if ( 'core/file' === $metadata['name'] ) { - $metadata['viewScript'] = array( 'file:./interactivity.min.js' ); - } - return $metadata; - } - add_filter( 'block_type_metadata', 'gutenberg_block_core_file_update_interactive_view_script', 10, 1 ); -} - /** * When the `core/file` block is rendering, check if we need to enqueue the `'wp-block-file-view` script. * diff --git a/packages/block-library/src/navigation/block.json b/packages/block-library/src/navigation/block.json index e5880e8370dcea..7896ea147699f7 100644 --- a/packages/block-library/src/navigation/block.json +++ b/packages/block-library/src/navigation/block.json @@ -131,7 +131,8 @@ } } } - } + }, + "interactivity": true }, "viewScript": "file:./view.min.js", "editorStyle": "wp-block-navigation-editor", diff --git a/packages/block-library/src/navigation/index.php b/packages/block-library/src/navigation/index.php index f8f53a93e70fd9..6dd59104ef6642 100644 --- a/packages/block-library/src/navigation/index.php +++ b/packages/block-library/src/navigation/index.php @@ -128,21 +128,6 @@ function gutenberg_block_core_navigation_add_directives_to_submenu( $w, $block_a } return $w->get_updated_html(); }; - - /** - * Replaces view script for the Navigation block with version using Interactivity API. - * - * @param array $metadata Block metadata as read in via block.json. - * - * @return array Filtered block type metadata. - */ - function gutenberg_block_core_navigation_update_interactive_view_script( $metadata ) { - if ( 'core/navigation' === $metadata['name'] ) { - $metadata['viewScript'] = array( 'file:./interactivity.min.js' ); - } - return $metadata; - } - add_filter( 'block_type_metadata', 'gutenberg_block_core_navigation_update_interactive_view_script', 10, 1 ); } diff --git a/packages/block-library/src/navigation/view-modal.js b/packages/block-library/src/navigation/view-modal.js deleted file mode 100644 index 9477d262816d93..00000000000000 --- a/packages/block-library/src/navigation/view-modal.js +++ /dev/null @@ -1,78 +0,0 @@ -/** - * External dependencies - */ -import MicroModal from 'micromodal'; - -// Responsive navigation toggle. -function navigationToggleModal( modal ) { - const dialogContainer = modal.querySelector( - `.wp-block-navigation__responsive-dialog` - ); - - const isHidden = 'true' === modal.getAttribute( 'aria-hidden' ); - - modal.classList.toggle( 'has-modal-open', ! isHidden ); - dialogContainer.toggleAttribute( 'aria-modal', ! isHidden ); - - if ( isHidden ) { - dialogContainer.removeAttribute( 'role' ); - dialogContainer.removeAttribute( 'aria-modal' ); - } else { - dialogContainer.setAttribute( 'role', 'dialog' ); - dialogContainer.setAttribute( 'aria-modal', 'true' ); - } - - // Add a class to indicate the modal is open. - const htmlElement = document.documentElement; - htmlElement.classList.toggle( 'has-modal-open' ); -} - -function isLinkToAnchorOnCurrentPage( node ) { - return ( - node.hash && - node.protocol === window.location.protocol && - node.host === window.location.host && - node.pathname === window.location.pathname && - node.search === window.location.search - ); -} - -window.addEventListener( 'load', () => { - MicroModal.init( { - onShow: navigationToggleModal, - onClose: navigationToggleModal, - openClass: 'is-menu-open', - } ); - - // Close modal automatically on clicking anchor links inside modal. - const navigationLinks = document.querySelectorAll( - '.wp-block-navigation-item__content' - ); - - navigationLinks.forEach( function ( link ) { - // Ignore non-anchor links and anchor links which open on a new tab. - if ( - ! isLinkToAnchorOnCurrentPage( link ) || - link.attributes?.target === '_blank' - ) { - return; - } - - // Find the specific parent modal for this link - // since .close() won't work without an ID if there are - // multiple navigation menus in a post/page. - const modal = link.closest( - '.wp-block-navigation__responsive-container' - ); - const modalId = modal?.getAttribute( 'id' ); - - link.addEventListener( 'click', () => { - // check if modal exists and is open before trying to close it - // otherwise Micromodal will toggle the `has-modal-open` class - // on the html tag which prevents scrolling - if ( modalId && modal.classList.contains( 'has-modal-open' ) ) { - MicroModal.close( modalId ); - } - } ); - } ); -} ); From 87588656b221e2fe58a08757db94512b1813bff5 Mon Sep 17 00:00:00 2001 From: David Arenas Date: Fri, 23 Jun 2023 16:56:48 +0200 Subject: [PATCH 32/34] Code improvements for the SSR part of the Interactivity API (#51640) * Fix multi-line comments and add examples * Add parse_attribute_name static method to WP_Directive_Processor * Replace array functions with a foreach loop * Add explanatory comment for the negation operator check * Replace $array with $path_segments * Minor fix for the negation operator comment * Call only instances of Closure * Improve negation operator code style * Do not lower-case tags * Use static parse_attribute_name inside directive processors * Add basic error handling in wp-context * Fix hidden identation errors * Use the correct variable name * Fix test for evaluating functions * Remove references to "attribute" directives * Remove emtpy lines in multi-line function calls * Fix typo --------- Co-authored-by: Luis Herranz --- .../class-wp-directive-processor.php | 17 +++++ .../directive-processing.php | 70 +++++++++++-------- .../interactivity-api/directives/wp-bind.php | 2 +- .../interactivity-api/directives/wp-class.php | 2 +- .../directives/wp-context.php | 5 +- .../interactivity-api/directives/wp-style.php | 2 +- .../directive-processing-test.php | 22 ++++-- 7 files changed, 84 insertions(+), 36 deletions(-) diff --git a/lib/experimental/interactivity-api/class-wp-directive-processor.php b/lib/experimental/interactivity-api/class-wp-directive-processor.php index 2315f6e286702b..608466a9c6edae 100644 --- a/lib/experimental/interactivity-api/class-wp-directive-processor.php +++ b/lib/experimental/interactivity-api/class-wp-directive-processor.php @@ -173,4 +173,21 @@ public static function is_html_void_element( $tag_name ) { return false; } } + + /** + * Extract and return the directive type and the the part after the double + * hyphen from an attribute name (if present), in an array format. + * + * Examples: + * + * 'wp-island' => array( 'wp-island', null ) + * 'wp-bind--src' => array( 'wp-bind', 'src' ) + * 'wp-thing--and--thang' => array( 'wp-thing', 'and--thang' ) + * + * @param string $name The attribute name. + * @return array The resulting array + */ + public static function parse_attribute_name( $name ) { + return explode( '--', $name, 2 ); + } } diff --git a/lib/experimental/interactivity-api/directive-processing.php b/lib/experimental/interactivity-api/directive-processing.php index 594e592a22137f..0f2af5da0f8044 100644 --- a/lib/experimental/interactivity-api/directive-processing.php +++ b/lib/experimental/interactivity-api/directive-processing.php @@ -69,7 +69,7 @@ function gutenberg_interactivity_process_directives( $tags, $prefix, $directives $tag_stack = array(); while ( $tags->next_tag( array( 'tag_closers' => 'visit' ) ) ) { - $tag_name = strtolower( $tags->get_tag() ); + $tag_name = $tags->get_tag(); // Is this a tag that closes the latest opening tag? if ( $tags->is_tag_closer() ) { @@ -81,27 +81,31 @@ function gutenberg_interactivity_process_directives( $tags, $prefix, $directives if ( $latest_opening_tag_name === $tag_name ) { array_pop( $tag_stack ); - // If the matching opening tag didn't have any attribute directives, - // we move on. + // If the matching opening tag didn't have any directives, we move on. if ( 0 === count( $attributes ) ) { continue; } } } else { - // Helper that removes the part after the double hyphen before looking for - // the directive processor inside `$attribute_directives`. - $get_directive_type = function ( $attr ) { - return explode( '--', $attr )[0]; - }; - - $attributes = $tags->get_attribute_names_with_prefix( $prefix ); - $attributes = array_map( $get_directive_type, $attributes ); - $attributes = array_intersect( $attributes, array_keys( $directives ) ); - - // If this is an open tag, and if it either has attribute directives, or - // if we're inside a tag that does, take note of this tag and its - // attribute directives so we can call its directive processor once we - // encounter the matching closing tag. + $attributes = array(); + foreach ( $tags->get_attribute_names_with_prefix( $prefix ) as $name ) { + /* + * Removes the part after the double hyphen before looking for + * the directive processor inside `$directives`, e.g., "wp-bind" + * from "wp-bind--src" and "wp-context" from "wp-context" etc... + */ + list( $type ) = WP_Directive_Processor::parse_attribute_name( $name ); + if ( array_key_exists( $type, $directives ) ) { + $attributes[] = $type; + } + } + + /* + * If this is an open tag, and if it either has directives, or if + * we're inside a tag that does, take note of this tag and its + * directives so we can call its directive processor once we + * encounter the matching closing tag. + */ if ( ! WP_Directive_Processor::is_html_void_element( $tags->get_tag() ) && ( 0 !== count( $attributes ) || 0 !== count( $tag_stack ) ) @@ -131,14 +135,17 @@ function gutenberg_interactivity_evaluate_reference( $path, array $context = arr array( 'context' => $context ) ); - if ( strpos( $path, '!' ) === 0 ) { - $path = substr( $path, 1 ); - $has_negation_operator = true; - } - - $array = explode( '.', $path ); - $current = $store; - foreach ( $array as $p ) { + /* + * Check first if the directive path is preceded by a negator operator (!), + * indicating that the value obtained from the Interactivity Store (or the + * passed context) using the subsequent path should be negated. + */ + $should_negate_value = '!' === $path[0]; + + $path = $should_negate_value ? substr( $path, 1 ) : $path; + $path_segments = explode( '.', $path ); + $current = $store; + foreach ( $path_segments as $p ) { if ( isset( $current[ $p ] ) ) { $current = $current[ $p ]; } else { @@ -146,11 +153,18 @@ function gutenberg_interactivity_evaluate_reference( $path, array $context = arr } } - // Check if $current is a function and if so, call it passing the store. - if ( is_callable( $current ) ) { + /* + * Check if $current is an anonymous function or an arrow function, and if + * so, call it passing the store. Other types of callables are ignored on + * purpose, as arbitrary strings or arrays could be wrongly evaluated as + * "callables". + * + * E.g., "file" is an string and a "callable" (the "file" function exists). + */ + if ( $current instanceof Closure ) { $current = call_user_func( $current, $store ); } // Return the opposite if it has a negator operator (!). - return isset( $has_negation_operator ) ? ! $current : $current; + return $should_negate_value ? ! $current : $current; } diff --git a/lib/experimental/interactivity-api/directives/wp-bind.php b/lib/experimental/interactivity-api/directives/wp-bind.php index d65f1df16ac640..54be4a9faeb7d2 100644 --- a/lib/experimental/interactivity-api/directives/wp-bind.php +++ b/lib/experimental/interactivity-api/directives/wp-bind.php @@ -20,7 +20,7 @@ function gutenberg_interactivity_process_wp_bind( $tags, $context ) { $prefixed_attributes = $tags->get_attribute_names_with_prefix( 'data-wp-bind--' ); foreach ( $prefixed_attributes as $attr ) { - list( , $bound_attr ) = explode( '--', $attr ); + list( , $bound_attr ) = WP_Directive_Processor::parse_attribute_name( $attr ); if ( empty( $bound_attr ) ) { continue; } diff --git a/lib/experimental/interactivity-api/directives/wp-class.php b/lib/experimental/interactivity-api/directives/wp-class.php index 93c3b4d4a00998..741cc75b42c60e 100644 --- a/lib/experimental/interactivity-api/directives/wp-class.php +++ b/lib/experimental/interactivity-api/directives/wp-class.php @@ -20,7 +20,7 @@ function gutenberg_interactivity_process_wp_class( $tags, $context ) { $prefixed_attributes = $tags->get_attribute_names_with_prefix( 'data-wp-class--' ); foreach ( $prefixed_attributes as $attr ) { - list( , $class_name ) = explode( '--', $attr ); + list( , $class_name ) = WP_Directive_Processor::parse_attribute_name( $attr ); if ( empty( $class_name ) ) { continue; } diff --git a/lib/experimental/interactivity-api/directives/wp-context.php b/lib/experimental/interactivity-api/directives/wp-context.php index 68a436aaaca8de..5e3c5a140b2b0d 100644 --- a/lib/experimental/interactivity-api/directives/wp-context.php +++ b/lib/experimental/interactivity-api/directives/wp-context.php @@ -24,7 +24,10 @@ function gutenberg_interactivity_process_wp_context( $tags, $context ) { } $new_context = json_decode( $value, true ); - // TODO: Error handling. + if ( null === $new_context ) { + // Invalid JSON defined in the directive. + return; + } $context->set_context( $new_context ); } diff --git a/lib/experimental/interactivity-api/directives/wp-style.php b/lib/experimental/interactivity-api/directives/wp-style.php index f87a85f099a0ea..9c37f9082c2c0b 100644 --- a/lib/experimental/interactivity-api/directives/wp-style.php +++ b/lib/experimental/interactivity-api/directives/wp-style.php @@ -20,7 +20,7 @@ function gutenberg_interactivity_process_wp_style( $tags, $context ) { $prefixed_attributes = $tags->get_attribute_names_with_prefix( 'data-wp-style--' ); foreach ( $prefixed_attributes as $attr ) { - list( , $style_name ) = explode( '--', $attr ); + list( , $style_name ) = WP_Directive_Processor::parse_attribute_name( $attr ); if ( empty( $style_name ) ) { continue; } diff --git a/phpunit/experimental/interactivity-api/directive-processing-test.php b/phpunit/experimental/interactivity-api/directive-processing-test.php index 811305f1b71983..4da0a4a85ac3c0 100644 --- a/phpunit/experimental/interactivity-api/directive-processing-test.php +++ b/phpunit/experimental/interactivity-api/directive-processing-test.php @@ -127,7 +127,7 @@ public function test_evaluate_function_should_return_null_for_unresolved_paths() $this->assertNull( gutenberg_interactivity_evaluate_reference( 'this.property.doesnt.exist' ) ); } - public function test_evaluate_function_should_execute_functions() { + public function test_evaluate_function_should_execute_anonymous_functions() { $context = new WP_Directive_Context( array( 'count' => 2 ) ); $helper = new Helper_Class; @@ -140,6 +140,7 @@ public function test_evaluate_function_should_execute_functions() { 'anonymous_function' => function( $store ) { return $store['state']['count'] + $store['context']['count']; }, + // Other types of callables should not be executed. 'function_name' => 'gutenberg_test_process_directives_helper_increment', 'class_method' => array( $helper, 'increment' ), 'class_static_method' => 'Helper_Class::static_increment', @@ -149,8 +150,21 @@ public function test_evaluate_function_should_execute_functions() { ); $this->assertSame( 5, gutenberg_interactivity_evaluate_reference( 'selectors.anonymous_function', $context->get_context() ) ); - $this->assertSame( 5, gutenberg_interactivity_evaluate_reference( 'selectors.function_name', $context->get_context() ) ); - $this->assertSame( 5, gutenberg_interactivity_evaluate_reference( 'selectors.class_static_method', $context->get_context() ) ); - $this->assertSame( 5, gutenberg_interactivity_evaluate_reference( 'selectors.class_static_method_as_array', $context->get_context() ) ); + $this->assertSame( + 'gutenberg_test_process_directives_helper_increment', + gutenberg_interactivity_evaluate_reference( 'selectors.function_name', $context->get_context() ) + ); + $this->assertSame( + array( $helper, 'increment' ), + gutenberg_interactivity_evaluate_reference( 'selectors.class_method', $context->get_context() ) + ); + $this->assertSame( + 'Helper_Class::static_increment', + gutenberg_interactivity_evaluate_reference( 'selectors.class_static_method', $context->get_context() ) + ); + $this->assertSame( + array( 'Helper_Class', 'static_increment' ), + gutenberg_interactivity_evaluate_reference( 'selectors.class_static_method_as_array', $context->get_context() ) + ); } } From c7450631283b976fcefbacdcbaf8fcddf77de51c Mon Sep 17 00:00:00 2001 From: David Arenas Date: Tue, 27 Jun 2023 13:23:11 +0200 Subject: [PATCH 33/34] Add the full Interactivity API runtime (but removing the client-side navigation). (#51194) * Add show and text directives * Move directive bind tests * Move the rest of e2e tests (except csn-related) * Add interactive-blocks plugin for e2e tests * Move test plugins one folder up * Add plugin to .wp-env.json * Change directive-bind spec file to use new plugin * Move plugin to e2e-tests package * Move HTML for directive-bind to plugin * Update exposed properties from preact * Refactor directive-bind spec file * Create directive-effect block for e2e testing * Update directive-effect spec file * Remove unnecessary files * Fix e2e tests for bind and effect directives * Refactor fixtures and use them for bind and effect * Remove unnecessary editorScript * Fix e2e test for directive priorities * Remove unnecessary files * Fix negation operator * Refactor store-tag e2e tests * Refactor directive-class e2e tests * Remove extra spaces * Add util for removing all created posts * Add block for context directive * Add block for directive show testing * Remove unintentionally added artifact * Ignore artifacts generated inside /test/e2e * Remove unused html * Add block for directive text testing * Add blocks for tovdom testing * Update directives syntax in e2e tests * Add getLink to InteractivityUtils * Fix php lint errors * Add disable_directives_ssr param * Fix phpcs errors * Fix missing phpcs error and warnings * Remove `wp-interactivity` from `viewScript` --------- Co-authored-by: Luis Herranz --- .gitignore | 1 + .../src/request-utils/posts.ts | 1 + .../e2e-tests/plugins/interactive-blocks.php | 48 +++++ .../directive-bind/block.json | 14 ++ .../directive-bind/render.php | 59 +++++++ .../interactive-blocks/directive-bind/view.js | 23 +++ .../directive-class/block.json | 14 ++ .../directive-class/render.php | 75 ++++++++ .../directive-class/view.js | 21 +++ .../directive-context/block.json | 14 ++ .../directive-context/render.php | 121 +++++++++++++ .../directive-context/view.js | 22 +++ .../directive-effect/block.json | 14 ++ .../directive-effect/render.php | 27 +++ .../directive-effect/view.js | 61 +++++++ .../directive-priorities/block.json | 14 ++ .../directive-priorities/render.php | 20 +++ .../directive-priorities/view.js | 121 +++++++++++++ .../directive-show/block.json | 14 ++ .../directive-show/render.php | 53 ++++++ .../interactive-blocks/directive-show/view.js | 24 +++ .../directive-text/block.json | 14 ++ .../directive-text/render.php | 35 ++++ .../interactive-blocks/directive-text/view.js | 17 ++ .../negation-operator/block.json | 14 ++ .../negation-operator/render.php | 26 +++ .../negation-operator/view.js | 22 +++ .../interactive-blocks/store-tag/block.json | 14 ++ .../interactive-blocks/store-tag/render.php | 64 +++++++ .../interactive-blocks/store-tag/view.js | 24 +++ .../tovdom-islands/block.json | 14 ++ .../tovdom-islands/render.php | 66 +++++++ .../interactive-blocks/tovdom-islands/view.js | 9 + .../interactive-blocks/tovdom/block.json | 14 ++ .../interactive-blocks/tovdom/cdata.js | 15 ++ .../tovdom/processing-instructions.js | 16 ++ .../interactive-blocks/tovdom/render.php | 33 ++++ .../plugins/interactive-blocks/tovdom/view.js | 5 + packages/interactivity/src/directives.js | 32 +++- packages/interactivity/src/index.js | 4 + .../interactivity/directive-bind.spec.ts | 96 ++++++++++ .../interactivity/directive-effect.spec.ts | 39 +++++ .../directive-priorities.spec.ts | 84 +++++++++ .../interactivity/directives-class.spec.ts | 101 +++++++++++ .../interactivity/directives-context.spec.ts | 165 ++++++++++++++++++ .../interactivity/directives-show.spec.ts | 59 +++++++ .../interactivity/directives-text.spec.ts | 36 ++++ .../e2e/specs/interactivity/fixtures/index.ts | 25 +++ .../fixtures/interactivity-utils.ts | 60 +++++++ .../interactivity/negation-operator.spec.ts | 44 +++++ .../e2e/specs/interactivity/store-tag.spec.ts | 86 +++++++++ .../interactivity/tovdom-islands.spec.ts | 58 ++++++ test/e2e/specs/interactivity/tovdom.spec.ts | 55 ++++++ 53 files changed, 2101 insertions(+), 6 deletions(-) create mode 100644 packages/e2e-tests/plugins/interactive-blocks.php create mode 100644 packages/e2e-tests/plugins/interactive-blocks/directive-bind/block.json create mode 100644 packages/e2e-tests/plugins/interactive-blocks/directive-bind/render.php create mode 100644 packages/e2e-tests/plugins/interactive-blocks/directive-bind/view.js create mode 100644 packages/e2e-tests/plugins/interactive-blocks/directive-class/block.json create mode 100644 packages/e2e-tests/plugins/interactive-blocks/directive-class/render.php create mode 100644 packages/e2e-tests/plugins/interactive-blocks/directive-class/view.js create mode 100644 packages/e2e-tests/plugins/interactive-blocks/directive-context/block.json create mode 100644 packages/e2e-tests/plugins/interactive-blocks/directive-context/render.php create mode 100644 packages/e2e-tests/plugins/interactive-blocks/directive-context/view.js create mode 100644 packages/e2e-tests/plugins/interactive-blocks/directive-effect/block.json create mode 100644 packages/e2e-tests/plugins/interactive-blocks/directive-effect/render.php create mode 100644 packages/e2e-tests/plugins/interactive-blocks/directive-effect/view.js create mode 100644 packages/e2e-tests/plugins/interactive-blocks/directive-priorities/block.json create mode 100644 packages/e2e-tests/plugins/interactive-blocks/directive-priorities/render.php create mode 100644 packages/e2e-tests/plugins/interactive-blocks/directive-priorities/view.js create mode 100644 packages/e2e-tests/plugins/interactive-blocks/directive-show/block.json create mode 100644 packages/e2e-tests/plugins/interactive-blocks/directive-show/render.php create mode 100644 packages/e2e-tests/plugins/interactive-blocks/directive-show/view.js create mode 100644 packages/e2e-tests/plugins/interactive-blocks/directive-text/block.json create mode 100644 packages/e2e-tests/plugins/interactive-blocks/directive-text/render.php create mode 100644 packages/e2e-tests/plugins/interactive-blocks/directive-text/view.js create mode 100644 packages/e2e-tests/plugins/interactive-blocks/negation-operator/block.json create mode 100644 packages/e2e-tests/plugins/interactive-blocks/negation-operator/render.php create mode 100644 packages/e2e-tests/plugins/interactive-blocks/negation-operator/view.js create mode 100644 packages/e2e-tests/plugins/interactive-blocks/store-tag/block.json create mode 100644 packages/e2e-tests/plugins/interactive-blocks/store-tag/render.php create mode 100644 packages/e2e-tests/plugins/interactive-blocks/store-tag/view.js create mode 100644 packages/e2e-tests/plugins/interactive-blocks/tovdom-islands/block.json create mode 100644 packages/e2e-tests/plugins/interactive-blocks/tovdom-islands/render.php create mode 100644 packages/e2e-tests/plugins/interactive-blocks/tovdom-islands/view.js create mode 100644 packages/e2e-tests/plugins/interactive-blocks/tovdom/block.json create mode 100644 packages/e2e-tests/plugins/interactive-blocks/tovdom/cdata.js create mode 100644 packages/e2e-tests/plugins/interactive-blocks/tovdom/processing-instructions.js create mode 100644 packages/e2e-tests/plugins/interactive-blocks/tovdom/render.php create mode 100644 packages/e2e-tests/plugins/interactive-blocks/tovdom/view.js create mode 100644 test/e2e/specs/interactivity/directive-bind.spec.ts create mode 100644 test/e2e/specs/interactivity/directive-effect.spec.ts create mode 100644 test/e2e/specs/interactivity/directive-priorities.spec.ts create mode 100644 test/e2e/specs/interactivity/directives-class.spec.ts create mode 100644 test/e2e/specs/interactivity/directives-context.spec.ts create mode 100644 test/e2e/specs/interactivity/directives-show.spec.ts create mode 100644 test/e2e/specs/interactivity/directives-text.spec.ts create mode 100644 test/e2e/specs/interactivity/fixtures/index.ts create mode 100644 test/e2e/specs/interactivity/fixtures/interactivity-utils.ts create mode 100644 test/e2e/specs/interactivity/negation-operator.spec.ts create mode 100644 test/e2e/specs/interactivity/store-tag.spec.ts create mode 100644 test/e2e/specs/interactivity/tovdom-islands.spec.ts create mode 100644 test/e2e/specs/interactivity/tovdom.spec.ts diff --git a/.gitignore b/.gitignore index 4a7f4708ce399a..19e43aecea7b82 100644 --- a/.gitignore +++ b/.gitignore @@ -15,6 +15,7 @@ coverage *.log yarn.lock /artifacts +/test/e2e/artifacts /perf-envs /composer.lock diff --git a/packages/e2e-test-utils-playwright/src/request-utils/posts.ts b/packages/e2e-test-utils-playwright/src/request-utils/posts.ts index e02dc11d3842a0..5e32c0c877555c 100644 --- a/packages/e2e-test-utils-playwright/src/request-utils/posts.ts +++ b/packages/e2e-test-utils-playwright/src/request-utils/posts.ts @@ -7,6 +7,7 @@ export interface Post { id: number; content: string; status: 'publish' | 'future' | 'draft' | 'pending' | 'private'; + link: string; } export interface CreatePostPayload { diff --git a/packages/e2e-tests/plugins/interactive-blocks.php b/packages/e2e-tests/plugins/interactive-blocks.php new file mode 100644 index 00000000000000..8a44e5a0efd4ac --- /dev/null +++ b/packages/e2e-tests/plugins/interactive-blocks.php @@ -0,0 +1,48 @@ + +
+ + + + + + + + + + + + + + +

+ Some Text +

+
diff --git a/packages/e2e-tests/plugins/interactive-blocks/directive-bind/view.js b/packages/e2e-tests/plugins/interactive-blocks/directive-bind/view.js new file mode 100644 index 00000000000000..0cd590f184cc85 --- /dev/null +++ b/packages/e2e-tests/plugins/interactive-blocks/directive-bind/view.js @@ -0,0 +1,23 @@ +( ( { wp } ) => { + const { store } = wp.interactivity; + + store( { + state: { + url: '/some-url', + checked: true, + show: false, + width: 1, + }, + foo: { + bar: 1, + }, + actions: { + toggle: ( { state, foo } ) => { + state.url = '/some-other-url'; + state.checked = ! state.checked; + state.show = ! state.show; + state.width += foo.bar; + }, + }, + } ); +} )( window ); diff --git a/packages/e2e-tests/plugins/interactive-blocks/directive-class/block.json b/packages/e2e-tests/plugins/interactive-blocks/directive-class/block.json new file mode 100644 index 00000000000000..af2764db986919 --- /dev/null +++ b/packages/e2e-tests/plugins/interactive-blocks/directive-class/block.json @@ -0,0 +1,14 @@ +{ + "apiVersion": 2, + "name": "test/directive-class", + "title": "E2E Interactivity tests - directive class", + "category": "text", + "icon": "heart", + "description": "", + "supports": { + "interactivity": true + }, + "textdomain": "e2e-interactivity", + "viewScript": "directive-class-view", + "render": "file:./render.php" +} diff --git a/packages/e2e-tests/plugins/interactive-blocks/directive-class/render.php b/packages/e2e-tests/plugins/interactive-blocks/directive-class/render.php new file mode 100644 index 00000000000000..19da052dc1148c --- /dev/null +++ b/packages/e2e-tests/plugins/interactive-blocks/directive-class/render.php @@ -0,0 +1,75 @@ + +
+ + + + +
+ +
+ +
+ +
+ +
+ +
+ +
+
+ +
+
diff --git a/packages/e2e-tests/plugins/interactive-blocks/directive-class/view.js b/packages/e2e-tests/plugins/interactive-blocks/directive-class/view.js new file mode 100644 index 00000000000000..bb06cf38412811 --- /dev/null +++ b/packages/e2e-tests/plugins/interactive-blocks/directive-class/view.js @@ -0,0 +1,21 @@ +( ( { wp } ) => { + const { store } = wp.interactivity; + + store( { + state: { + trueValue: true, + falseValue: false, + }, + actions: { + toggleTrueValue: ( { state } ) => { + state.trueValue = ! state.trueValue; + }, + toggleFalseValue: ( { state } ) => { + state.falseValue = ! state.falseValue; + }, + toggleContextFalseValue: ( { context } ) => { + context.falseValue = ! context.falseValue; + }, + }, + } ); +} )( window ); diff --git a/packages/e2e-tests/plugins/interactive-blocks/directive-context/block.json b/packages/e2e-tests/plugins/interactive-blocks/directive-context/block.json new file mode 100644 index 00000000000000..1b3c448cc62aac --- /dev/null +++ b/packages/e2e-tests/plugins/interactive-blocks/directive-context/block.json @@ -0,0 +1,14 @@ +{ + "apiVersion": 2, + "name": "test/directive-context", + "title": "E2E Interactivity tests - directive context", + "category": "text", + "icon": "heart", + "description": "", + "supports": { + "interactivity": true + }, + "textdomain": "e2e-interactivity", + "viewScript": "directive-context-view", + "render": "file:./render.php" +} diff --git a/packages/e2e-tests/plugins/interactive-blocks/directive-context/render.php b/packages/e2e-tests/plugins/interactive-blocks/directive-context/render.php new file mode 100644 index 00000000000000..a9b0402d1b094e --- /dev/null +++ b/packages/e2e-tests/plugins/interactive-blocks/directive-context/render.php @@ -0,0 +1,121 @@ + +
+
+
+			
+		
+ + + + +
+
+				
+			
+ + + + + + +
+
+ + +
+
diff --git a/packages/e2e-tests/plugins/interactive-blocks/directive-context/view.js b/packages/e2e-tests/plugins/interactive-blocks/directive-context/view.js new file mode 100644 index 00000000000000..46483aaa2ea53d --- /dev/null +++ b/packages/e2e-tests/plugins/interactive-blocks/directive-context/view.js @@ -0,0 +1,22 @@ +( ( { wp } ) => { + const { store } = wp.interactivity; + + store( { + derived: { + renderContext: ( { context } ) => { + return JSON.stringify( context, undefined, 2 ); + }, + }, + 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; + }, + toggleContextText: ( { context } ) => { + context.text = context.text === 'Text 1' ? 'Text 2' : 'Text 1'; + }, + }, + } ); +} )( window ); diff --git a/packages/e2e-tests/plugins/interactive-blocks/directive-effect/block.json b/packages/e2e-tests/plugins/interactive-blocks/directive-effect/block.json new file mode 100644 index 00000000000000..b9cb2f782b2e6f --- /dev/null +++ b/packages/e2e-tests/plugins/interactive-blocks/directive-effect/block.json @@ -0,0 +1,14 @@ +{ + "apiVersion": 2, + "name": "test/directive-effect", + "title": "E2E Interactivity tests - directive effect", + "category": "text", + "icon": "heart", + "description": "", + "supports": { + "interactivity": true + }, + "textdomain": "e2e-interactivity", + "viewScript": "directive-effect-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-effect/render.php new file mode 100644 index 00000000000000..243826aae35046 --- /dev/null +++ b/packages/e2e-tests/plugins/interactive-blocks/directive-effect/render.php @@ -0,0 +1,27 @@ + +
+
+ +
+ +
+ +
+ + +
diff --git a/packages/e2e-tests/plugins/interactive-blocks/directive-effect/view.js b/packages/e2e-tests/plugins/interactive-blocks/directive-effect/view.js new file mode 100644 index 00000000000000..debb363a9ac0fd --- /dev/null +++ b/packages/e2e-tests/plugins/interactive-blocks/directive-effect/view.js @@ -0,0 +1,61 @@ +( ( { wp } ) => { + const { store, directive, useContext, useMemo } = wp.interactivity; + + // Fake `data-wp-fakeshow` directive to test when things are removed from the DOM. + // Replace with `data-wp-show` when it's ready. + directive( + 'fakeshow', + ( { + directives: { + fakeshow: { default: fakeshow }, + }, + element, + evaluate, + context, + } ) => { + const contextValue = useContext( context ); + const children = useMemo( + () => + element.type === 'template' + ? element.props.templateChildren + : element, + [] + ); + if ( ! evaluate( fakeshow, { context: contextValue } ) ) return null; + return children; + } + ); + + store( { + state: { + isOpen: true, + isElementInTheDOM: false, + }, + selectors: { + elementInTheDOM: ( { state } ) => + state.isElementInTheDOM + ? 'element is in the DOM' + : 'element is not in the DOM', + }, + actions: { + toggle( { state } ) { + state.isOpen = ! state.isOpen; + }, + }, + effects: { + elementAddedToTheDOM: ( { state } ) => { + state.isElementInTheDOM = true; + + return () => { + state.isElementInTheDOM = false; + }; + }, + changeFocus: ( { state } ) => { + if ( state.isOpen ) { + document.querySelector( "[data-testid='input']" ).focus(); + } + }, + }, + } ); + +} )( window ); diff --git a/packages/e2e-tests/plugins/interactive-blocks/directive-priorities/block.json b/packages/e2e-tests/plugins/interactive-blocks/directive-priorities/block.json new file mode 100644 index 00000000000000..c7361c3d5f121a --- /dev/null +++ b/packages/e2e-tests/plugins/interactive-blocks/directive-priorities/block.json @@ -0,0 +1,14 @@ +{ + "apiVersion": 2, + "name": "test/directive-priorities", + "title": "E2E Interactivity tests - directive priorities", + "category": "text", + "icon": "heart", + "description": "", + "supports": { + "interactivity": true + }, + "textdomain": "e2e-interactivity", + "viewScript": "directive-priorities-view", + "render": "file:./render.php" +} diff --git a/packages/e2e-tests/plugins/interactive-blocks/directive-priorities/render.php b/packages/e2e-tests/plugins/interactive-blocks/directive-priorities/render.php new file mode 100644 index 00000000000000..a5f0b045ab0d67 --- /dev/null +++ b/packages/e2e-tests/plugins/interactive-blocks/directive-priorities/render.php @@ -0,0 +1,20 @@ + +
+

+
+	
+	
+
diff --git a/packages/e2e-tests/plugins/interactive-blocks/directive-priorities/view.js b/packages/e2e-tests/plugins/interactive-blocks/directive-priorities/view.js new file mode 100644 index 00000000000000..cedc0c7c1d3ad3 --- /dev/null +++ b/packages/e2e-tests/plugins/interactive-blocks/directive-priorities/view.js @@ -0,0 +1,121 @@ +( ( { wp } ) => { + /** + * WordPress dependencies + */ + const { + store, + directive, + deepSignal, + useContext, + useEffect, + createElement: h + } = wp.interactivity; + + /** + * Util to check that render calls happen in order. + * + * @param {string} n Name passed from the directive being executed. + */ + const executionProof = ( n ) => { + const el = document.querySelector( '[data-testid="execution order"]' ); + if ( ! el.textContent ) el.textContent = n; + else el.textContent += `, ${ n }`; + }; + + /** + * Simple context directive, just for testing purposes. It provides a deep + * signal with these two properties: + * - attribute: 'from context' + * - text: 'from context' + */ + directive( + 'test-context', + ( { context: { Provider }, props: { children } } ) => { + executionProof( 'context' ); + const value = deepSignal( { + attribute: 'from context', + text: 'from context', + } ); + return h( Provider, { value }, children ); + }, + { priority: 8 } + ); + + /** + * Simple attribute directive, for testing purposes. It reads the value of + * `attribute` from context and populates `data-attribute` with it. + */ + directive( 'test-attribute', ( { context, evaluate, element } ) => { + executionProof( 'attribute' ); + const contextValue = useContext( context ); + const attributeValue = evaluate( 'context.attribute', { + context: contextValue, + } ); + useEffect( () => { + element.ref.current.setAttribute( + 'data-attribute', + attributeValue, + ); + }, [] ); + element.props[ 'data-attribute' ] = attributeValue; + } ); + + /** + * Simple text directive, for testing purposes. It reads the value of + * `text` from context and populates `children` with it. + */ + directive( + 'test-text', + ( { context, evaluate, element } ) => { + executionProof( 'text' ); + const contextValue = useContext( context ); + const textValue = evaluate( 'context.text', { + context: contextValue, + } ); + element.props.children = + h( 'p', { 'data-testid': 'text' }, textValue ); + }, + { priority: 12 } + ); + + /** + * Children directive, for testing purposes. It adds a wrapper around + * `children`, including two buttons to modify `text` and `attribute` values + * from the received context. + */ + directive( + 'test-children', + ( { context, evaluate, element } ) => { + executionProof( 'children' ); + const contextValue = useContext( context ); + const updateAttribute = () => { + evaluate( + 'actions.updateAttribute', + { context: contextValue } + ); + }; + const updateText = () => { + evaluate( 'actions.updateText', { context: contextValue } ); + }; + element.props.children = h( + 'div', + {}, + element.props.children, + h( 'button', { onClick: updateAttribute }, 'Update attribute' ), + h( 'button', { onClick: updateText }, 'Update text' ) + ); + }, + { priority: 14 } + ); + + store( { + actions: { + updateText( { context } ) { + context.text = 'updated'; + }, + updateAttribute( { context } ) { + context.attribute = 'updated'; + }, + }, + } ); +} )( window ); diff --git a/packages/e2e-tests/plugins/interactive-blocks/directive-show/block.json b/packages/e2e-tests/plugins/interactive-blocks/directive-show/block.json new file mode 100644 index 00000000000000..ab6801843a7cae --- /dev/null +++ b/packages/e2e-tests/plugins/interactive-blocks/directive-show/block.json @@ -0,0 +1,14 @@ +{ + "apiVersion": 2, + "name": "test/directive-show", + "title": "E2E Interactivity tests - directive show", + "category": "text", + "icon": "heart", + "description": "", + "supports": { + "interactivity": true + }, + "textdomain": "e2e-interactivity", + "viewScript": "directive-show-view", + "render": "file:./render.php" +} diff --git a/packages/e2e-tests/plugins/interactive-blocks/directive-show/render.php b/packages/e2e-tests/plugins/interactive-blocks/directive-show/render.php new file mode 100644 index 00000000000000..5818f3b7c10b63 --- /dev/null +++ b/packages/e2e-tests/plugins/interactive-blocks/directive-show/render.php @@ -0,0 +1,53 @@ + +
+ + + + +
+

trueValue children

+
+ +
+

falseValue children

+
+ +
+
+ falseValue +
+ +
+
diff --git a/packages/e2e-tests/plugins/interactive-blocks/directive-show/view.js b/packages/e2e-tests/plugins/interactive-blocks/directive-show/view.js new file mode 100644 index 00000000000000..9398321b4cfe43 --- /dev/null +++ b/packages/e2e-tests/plugins/interactive-blocks/directive-show/view.js @@ -0,0 +1,24 @@ +( ( { wp } ) => { + /** + * WordPress dependencies + */ + const { store } = wp.interactivity; + + store( { + state: { + trueValue: true, + falseValue: false, + }, + actions: { + toggleTrueValue: ( { state } ) => { + state.trueValue = ! state.trueValue; + }, + toggleFalseValue: ( { state } ) => { + state.falseValue = ! state.falseValue; + }, + toggleContextFalseValue: ( { context } ) => { + context.falseValue = ! context.falseValue; + }, + }, + } ); +} )( window ); diff --git a/packages/e2e-tests/plugins/interactive-blocks/directive-text/block.json b/packages/e2e-tests/plugins/interactive-blocks/directive-text/block.json new file mode 100644 index 00000000000000..7295849b9912d7 --- /dev/null +++ b/packages/e2e-tests/plugins/interactive-blocks/directive-text/block.json @@ -0,0 +1,14 @@ +{ + "apiVersion": 2, + "name": "test/directive-text", + "title": "E2E Interactivity tests - directive text", + "category": "text", + "icon": "heart", + "description": "", + "supports": { + "interactivity": true + }, + "textdomain": "e2e-interactivity", + "viewScript": "directive-text-view", + "render": "file:./render.php" +} diff --git a/packages/e2e-tests/plugins/interactive-blocks/directive-text/render.php b/packages/e2e-tests/plugins/interactive-blocks/directive-text/render.php new file mode 100644 index 00000000000000..54ac9c09e7d863 --- /dev/null +++ b/packages/e2e-tests/plugins/interactive-blocks/directive-text/render.php @@ -0,0 +1,35 @@ + +
+
+ + +
+ +
+ + +
+
diff --git a/packages/e2e-tests/plugins/interactive-blocks/directive-text/view.js b/packages/e2e-tests/plugins/interactive-blocks/directive-text/view.js new file mode 100644 index 00000000000000..49121213f2b04e --- /dev/null +++ b/packages/e2e-tests/plugins/interactive-blocks/directive-text/view.js @@ -0,0 +1,17 @@ +( ( { wp } ) => { + const { store } = wp.interactivity; + + store( { + state: { + text: 'Text 1', + }, + actions: { + toggleStateText: ( { state } ) => { + state.text = state.text === 'Text 1' ? 'Text 2' : 'Text 1'; + }, + toggleContextText: ( { context } ) => { + context.text = context.text === 'Text 1' ? 'Text 2' : 'Text 1'; + }, + }, + } ); +} )( window ); diff --git a/packages/e2e-tests/plugins/interactive-blocks/negation-operator/block.json b/packages/e2e-tests/plugins/interactive-blocks/negation-operator/block.json new file mode 100644 index 00000000000000..68da53367ad63a --- /dev/null +++ b/packages/e2e-tests/plugins/interactive-blocks/negation-operator/block.json @@ -0,0 +1,14 @@ +{ + "apiVersion": 2, + "name": "test/negation-operator", + "title": "E2E Interactivity tests - negation operator", + "category": "text", + "icon": "heart", + "description": "", + "supports": { + "interactivity": true + }, + "textdomain": "e2e-interactivity", + "viewScript": "negation-operator-view", + "render": "file:./render.php" +} diff --git a/packages/e2e-tests/plugins/interactive-blocks/negation-operator/render.php b/packages/e2e-tests/plugins/interactive-blocks/negation-operator/render.php new file mode 100644 index 00000000000000..e087a5eceb8369 --- /dev/null +++ b/packages/e2e-tests/plugins/interactive-blocks/negation-operator/render.php @@ -0,0 +1,26 @@ + +
+ + +
+ +
+
diff --git a/packages/e2e-tests/plugins/interactive-blocks/negation-operator/view.js b/packages/e2e-tests/plugins/interactive-blocks/negation-operator/view.js new file mode 100644 index 00000000000000..64a84269c356e3 --- /dev/null +++ b/packages/e2e-tests/plugins/interactive-blocks/negation-operator/view.js @@ -0,0 +1,22 @@ +( ( { wp } ) => { + /** + * WordPress dependencies + */ + const { store } = wp.interactivity; + + store( { + selectors: { + active: ( { state } ) => { + return state.active; + }, + }, + state: { + active: false, + }, + actions: { + toggle: ( { state } ) => { + state.active = ! state.active; + }, + }, + } ); +} )( window ); diff --git a/packages/e2e-tests/plugins/interactive-blocks/store-tag/block.json b/packages/e2e-tests/plugins/interactive-blocks/store-tag/block.json new file mode 100644 index 00000000000000..4611288de796c9 --- /dev/null +++ b/packages/e2e-tests/plugins/interactive-blocks/store-tag/block.json @@ -0,0 +1,14 @@ +{ + "apiVersion": 2, + "name": "test/store-tag", + "title": "E2E Interactivity tests - store tag", + "category": "text", + "icon": "heart", + "description": "", + "supports": { + "interactivity": true + }, + "textdomain": "e2e-interactivity", + "viewScript": "store-tag-view", + "render": "file:./render.php" +} diff --git a/packages/e2e-tests/plugins/interactive-blocks/store-tag/render.php b/packages/e2e-tests/plugins/interactive-blocks/store-tag/render.php new file mode 100644 index 00000000000000..9bc8126720b9b9 --- /dev/null +++ b/packages/e2e-tests/plugins/interactive-blocks/store-tag/render.php @@ -0,0 +1,64 @@ + +
+
+ Counter: + +
+ Double: + +
+ + 0 + clicks +
+
+ + $test_store_tag_json + +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 new file mode 100644 index 00000000000000..140cab6463137f --- /dev/null +++ b/packages/e2e-tests/plugins/interactive-blocks/store-tag/view.js @@ -0,0 +1,24 @@ +( ( { wp } ) => { + /** + * WordPress dependencies + */ + const { store } = wp.interactivity; + + store( { + state: { + counter: { + // `value` is defined in the server. + double: ( { state } ) => state.counter.value * 2, + clicks: 0, + }, + }, + actions: { + counter: { + increment: ( { state } ) => { + state.counter.value += 1; + state.counter.clicks += 1; + }, + }, + }, + } ); +} )( window ); diff --git a/packages/e2e-tests/plugins/interactive-blocks/tovdom-islands/block.json b/packages/e2e-tests/plugins/interactive-blocks/tovdom-islands/block.json new file mode 100644 index 00000000000000..fb852acefb9116 --- /dev/null +++ b/packages/e2e-tests/plugins/interactive-blocks/tovdom-islands/block.json @@ -0,0 +1,14 @@ +{ + "apiVersion": 2, + "name": "test/tovdom-islands", + "title": "E2E Interactivity tests - tovdom islands", + "category": "text", + "icon": "heart", + "description": "", + "supports": { + "interactivity": true + }, + "textdomain": "e2e-interactivity", + "viewScript": "tovdom-islands-view", + "render": "file:./render.php" +} diff --git a/packages/e2e-tests/plugins/interactive-blocks/tovdom-islands/render.php b/packages/e2e-tests/plugins/interactive-blocks/tovdom-islands/render.php new file mode 100644 index 00000000000000..aa0f2e68d3b7cf --- /dev/null +++ b/packages/e2e-tests/plugins/interactive-blocks/tovdom-islands/render.php @@ -0,0 +1,66 @@ + +
+
+ + This should be shown because it is inside an island. + +
+ +
+
+ + This should not be shown because it is inside an island. + +
+
+ +
+
+
+ + This should be shown because it is inside an inner + block of an isolated island. + +
+
+
+ +
+
+
+ + This should not have two template wrappers because + that means we hydrated twice. + +
+
+
+ +
+
+
+
+ + This should not be shown because even though it + is inside an inner block of an isolated island, + it's inside an new island. + +
+
+
+
+
diff --git a/packages/e2e-tests/plugins/interactive-blocks/tovdom-islands/view.js b/packages/e2e-tests/plugins/interactive-blocks/tovdom-islands/view.js new file mode 100644 index 00000000000000..3ccb09203e6bb6 --- /dev/null +++ b/packages/e2e-tests/plugins/interactive-blocks/tovdom-islands/view.js @@ -0,0 +1,9 @@ +( ( { wp } ) => { + const { store } = wp.interactivity; + + store( { + state: { + falseValue: false, + }, + } ); +} )( window ); diff --git a/packages/e2e-tests/plugins/interactive-blocks/tovdom/block.json b/packages/e2e-tests/plugins/interactive-blocks/tovdom/block.json new file mode 100644 index 00000000000000..b685919e164821 --- /dev/null +++ b/packages/e2e-tests/plugins/interactive-blocks/tovdom/block.json @@ -0,0 +1,14 @@ +{ + "apiVersion": 2, + "name": "test/tovdom", + "title": "E2E Interactivity tests - tovdom", + "category": "text", + "icon": "heart", + "description": "", + "supports": { + "interactivity": true + }, + "textdomain": "e2e-interactivity", + "viewScript": "tovdom-view", + "render": "file:./render.php" +} diff --git a/packages/e2e-tests/plugins/interactive-blocks/tovdom/cdata.js b/packages/e2e-tests/plugins/interactive-blocks/tovdom/cdata.js new file mode 100644 index 00000000000000..506e899e42850c --- /dev/null +++ b/packages/e2e-tests/plugins/interactive-blocks/tovdom/cdata.js @@ -0,0 +1,15 @@ +const cdata = ` +
+ +
+ +
+
+ `; + +const cdataElement = new DOMParser() + .parseFromString( cdata, 'text/xml' ) + .querySelector( 'div' ); +document + .getElementById( 'replace-with-cdata' ) + .replaceWith( cdataElement ); diff --git a/packages/e2e-tests/plugins/interactive-blocks/tovdom/processing-instructions.js b/packages/e2e-tests/plugins/interactive-blocks/tovdom/processing-instructions.js new file mode 100644 index 00000000000000..b65095ef6cde4f --- /dev/null +++ b/packages/e2e-tests/plugins/interactive-blocks/tovdom/processing-instructions.js @@ -0,0 +1,16 @@ +const processingInstructions = ` +
+ +
+ Processing instructions inner node + +
+
+ `; + +const processingInstructionsElement = new DOMParser() + .parseFromString( processingInstructions, 'text/xml' ) + .querySelector( 'div' ); +document + .getElementById( 'replace-with-processing-instructions' ) + .replaceWith( processingInstructionsElement ); diff --git a/packages/e2e-tests/plugins/interactive-blocks/tovdom/render.php b/packages/e2e-tests/plugins/interactive-blocks/tovdom/render.php new file mode 100644 index 00000000000000..952a4f6c0a455d --- /dev/null +++ b/packages/e2e-tests/plugins/interactive-blocks/tovdom/render.php @@ -0,0 +1,33 @@ + + +
+
+ +
+ Comments inner node + +
+
+ +
+
+
+ + + +
+
+
+ + +
diff --git a/packages/e2e-tests/plugins/interactive-blocks/tovdom/view.js b/packages/e2e-tests/plugins/interactive-blocks/tovdom/view.js new file mode 100644 index 00000000000000..734ccbd801bb1e --- /dev/null +++ b/packages/e2e-tests/plugins/interactive-blocks/tovdom/view.js @@ -0,0 +1,5 @@ +( ( { wp } ) => { + const { store } = wp.interactivity; + + store( {} ); +} )( window ); diff --git a/packages/interactivity/src/directives.js b/packages/interactivity/src/directives.js index 5ecdcf6c4d6b93..ca87f7655dba68 100644 --- a/packages/interactivity/src/directives.js +++ b/packages/interactivity/src/directives.js @@ -175,21 +175,23 @@ export default () => { } ); - // data-wp-text + // data-wp-show directive( - 'text', + 'show', ( { directives: { - text: { default: text }, + show: { default: show }, }, element, evaluate, context, } ) => { const contextValue = useContext( context ); - element.props.children = evaluate( text, { - context: contextValue, - } ); + + if ( ! evaluate( show, { context: contextValue } ) ) + element.props.children = ( + + ); } ); @@ -212,4 +214,22 @@ 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, + } ); + } + ); }; diff --git a/packages/interactivity/src/index.js b/packages/interactivity/src/index.js index 71b6a2b790704d..9d1b6b695b66b7 100644 --- a/packages/interactivity/src/index.js +++ b/packages/interactivity/src/index.js @@ -4,6 +4,10 @@ import registerDirectives from './directives'; import { init } from './hydration'; export { store } from './store'; +export { directive } from './hooks'; +export { h as createElement } from 'preact'; +export { useEffect, useContext, useMemo } from 'preact/hooks'; +export { deepSignal } from 'deepsignal'; /** * Initialize the Interactivity API. diff --git a/test/e2e/specs/interactivity/directive-bind.spec.ts b/test/e2e/specs/interactivity/directive-bind.spec.ts new file mode 100644 index 00000000000000..67ee1232a6798e --- /dev/null +++ b/test/e2e/specs/interactivity/directive-bind.spec.ts @@ -0,0 +1,96 @@ +/** + * Internal dependencies + */ +import { test, expect } from './fixtures'; + +test.describe( 'data-wp-bind', () => { + test.beforeAll( async ( { interactivityUtils: utils } ) => { + await utils.activatePlugins(); + await utils.addPostWithBlock( 'test/directive-bind' ); + } ); + + test.beforeEach( async ( { interactivityUtils: utils, page } ) => { + await page.goto( utils.getLink( 'test/directive-bind' ) ); + } ); + + test.afterAll( async ( { interactivityUtils: utils } ) => { + await utils.deactivatePlugins(); + await utils.deleteAllPosts(); + } ); + + test( 'add missing href at hydration', async ( { page } ) => { + const el = page.getByTestId( 'add missing href at hydration' ); + await expect( el ).toHaveAttribute( 'href', '/some-url' ); + } ); + + test( 'change href at hydration', async ( { page } ) => { + const el = page.getByTestId( 'change href at hydration' ); + await expect( el ).toHaveAttribute( 'href', '/some-url' ); + } ); + + test( 'update missing href at hydration', async ( { page } ) => { + const el = page.getByTestId( 'add missing href at hydration' ); + await expect( el ).toHaveAttribute( 'href', '/some-url' ); + await page.getByTestId( 'toggle' ).click(); + await expect( el ).toHaveAttribute( 'href', '/some-other-url' ); + } ); + + test( 'add missing checked at hydration', async ( { page } ) => { + const el = page.getByTestId( 'add missing checked at hydration' ); + await expect( el ).toHaveAttribute( 'checked', '' ); + } ); + + test( 'remove existing checked at hydration', async ( { page } ) => { + const el = page.getByTestId( 'remove existing checked at hydration' ); + await expect( el ).not.toHaveAttribute( 'checked', '' ); + } ); + + test( 'update existing checked', async ( { page } ) => { + const el = page.getByTestId( 'add missing checked at hydration' ); + const el2 = page.getByTestId( 'remove existing checked at hydration' ); + let checked = await el.evaluate( + ( element: HTMLInputElement ) => element.checked + ); + let checked2 = await el2.evaluate( + ( element: HTMLInputElement ) => element.checked + ); + expect( checked ).toBe( true ); + expect( checked2 ).toBe( false ); + await page.getByTestId( 'toggle' ).click(); + checked = await el.evaluate( + ( element: HTMLInputElement ) => element.checked + ); + checked2 = await el2.evaluate( + ( element: HTMLInputElement ) => element.checked + ); + expect( checked ).toBe( false ); + expect( checked2 ).toBe( true ); + } ); + + test( 'nested binds', async ( { page } ) => { + const el = page.getByTestId( 'nested binds - 1' ); + await expect( el ).toHaveAttribute( 'href', '/some-url' ); + const el2 = page.getByTestId( 'nested binds - 2' ); + await expect( el2 ).toHaveAttribute( 'width', '1' ); + await page.getByTestId( 'toggle' ).click(); + await expect( el ).toHaveAttribute( 'href', '/some-other-url' ); + await expect( el2 ).toHaveAttribute( 'width', '2' ); + } ); + + test( 'check enumerated attributes with true/false values', async ( { + page, + } ) => { + const el = page.getByTestId( + 'check enumerated attributes with true/false exist and have a string value' + ); + await expect( el ).toHaveAttribute( 'hidden', '' ); + await expect( el ).toHaveAttribute( 'aria-hidden', 'true' ); + await expect( el ).toHaveAttribute( 'aria-expanded', 'false' ); + await expect( el ).toHaveAttribute( 'data-some-value', 'false' ); + await page.getByTestId( 'toggle' ).click(); + await expect( el ).not.toHaveAttribute( 'hidden', '' ); + await expect( el ).toHaveAttribute( 'aria-hidden', 'false' ); + await expect( el ).toHaveAttribute( 'aria-expanded', 'true' ); + await expect( el ).toHaveAttribute( 'data-some-value', 'true' ); + } ); +} ); diff --git a/test/e2e/specs/interactivity/directive-effect.spec.ts b/test/e2e/specs/interactivity/directive-effect.spec.ts new file mode 100644 index 00000000000000..2ec52444cfd9b8 --- /dev/null +++ b/test/e2e/specs/interactivity/directive-effect.spec.ts @@ -0,0 +1,39 @@ +/** + * Internal dependencies + */ +import { test, expect } from './fixtures'; + +test.describe( 'data-wp-effect', () => { + test.beforeAll( async ( { interactivityUtils: utils } ) => { + await utils.activatePlugins(); + await utils.addPostWithBlock( 'test/directive-effect' ); + } ); + + test.beforeEach( async ( { interactivityUtils: utils, page } ) => { + await page.goto( utils.getLink( 'test/directive-effect' ) ); + } ); + + test.afterAll( async ( { interactivityUtils: utils } ) => { + await utils.deactivatePlugins(); + await utils.deleteAllPosts(); + } ); + + test( 'check that effect 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 } ) => { + await page.getByTestId( 'toggle' ).click(); + const el = page.getByTestId( 'element in the DOM' ); + await expect( el ).toContainText( 'element is not in the DOM' ); + } ); + + test( 'change focus after DOM changes', async ( { page } ) => { + const el = page.getByTestId( 'input' ); + await expect( el ).toBeFocused(); + await page.getByTestId( 'toggle' ).click(); + await page.getByTestId( 'toggle' ).click(); + await expect( el ).toBeFocused(); + } ); +} ); diff --git a/test/e2e/specs/interactivity/directive-priorities.spec.ts b/test/e2e/specs/interactivity/directive-priorities.spec.ts new file mode 100644 index 00000000000000..1734d6f5aecc36 --- /dev/null +++ b/test/e2e/specs/interactivity/directive-priorities.spec.ts @@ -0,0 +1,84 @@ +/** + * Internal dependencies + */ +import { test, expect } from './fixtures'; + +test.describe( 'Directives (w/ priority)', () => { + test.beforeAll( async ( { interactivityUtils: utils } ) => { + await utils.activatePlugins(); + await utils.addPostWithBlock( 'test/directive-priorities' ); + } ); + + test.beforeEach( async ( { interactivityUtils: utils, page } ) => { + await page.goto( utils.getLink( 'test/directive-priorities' ) ); + } ); + + test.afterAll( async ( { interactivityUtils: utils } ) => { + await utils.deactivatePlugins(); + await utils.deleteAllPosts(); + } ); + + test( 'should run in priority order', async ( { page } ) => { + const executionOrder = page.getByTestId( 'execution order' ); + await expect( executionOrder ).toHaveText( + 'context, attribute, text, children' + ); + } ); + + test( 'should wrap those with less priority', async ( { page } ) => { + // Check that attribute value is correctly received from Provider. + const element = page.getByTestId( 'test directives' ); + await expect( element ).toHaveAttribute( + 'data-attribute', + 'from context' + ); + + // Check that text value is correctly received from Provider, and text + // wrapped with an element with `data-testid=text`. + const text = element.getByTestId( 'text' ); + await expect( text ).toHaveText( 'from context' ); + } ); + + test( 'should propagate element modifications top-down', async ( { + page, + } ) => { + const executionOrder = page.getByTestId( 'execution order' ); + const element = page.getByTestId( 'test directives' ); + const text = element.getByTestId( 'text' ); + + // Get buttons. + const updateAttribute = element.getByRole( 'button', { + name: 'Update attribute', + } ); + const updateText = element.getByRole( 'button', { + name: 'Update text', + } ); + + // Modify `attribute` inside context. This triggers a re-render for the + // component that wraps the `attribute` directive, evaluating it again. + // Nested components are re-rendered as well, so their directives are + // also re-evaluated (note how `text` and `children` have run). + await updateAttribute.click(); + await expect( element ).toHaveAttribute( 'data-attribute', 'updated' ); + await expect( executionOrder ).toHaveText( + [ + 'context, attribute, text, children', + 'attribute, text, children', + ].join( ', ' ) + ); + + // Modify `text` inside context. This triggers a re-render of the + // component that wraps the `text` directive. In this case, only + // `children` run as well, right after `text`. + await updateText.click(); + await expect( element ).toHaveAttribute( 'data-attribute', 'updated' ); + await expect( text ).toHaveText( 'updated' ); + await expect( executionOrder ).toHaveText( + [ + 'context, attribute, text, children', + 'attribute, text, children', + 'text, children', + ].join( ', ' ) + ); + } ); +} ); diff --git a/test/e2e/specs/interactivity/directives-class.spec.ts b/test/e2e/specs/interactivity/directives-class.spec.ts new file mode 100644 index 00000000000000..c0ec77e14d0567 --- /dev/null +++ b/test/e2e/specs/interactivity/directives-class.spec.ts @@ -0,0 +1,101 @@ +/** + * Internal dependencies + */ +import { test, expect } from './fixtures'; + +test.describe( 'data-wp-class', () => { + test.beforeAll( async ( { interactivityUtils: utils } ) => { + await utils.activatePlugins(); + await utils.addPostWithBlock( 'test/directive-class' ); + } ); + + test.beforeEach( async ( { interactivityUtils: utils, page } ) => { + await page.goto( utils.getLink( 'test/directive-class' ) ); + } ); + + test.afterAll( async ( { interactivityUtils: utils } ) => { + await utils.deactivatePlugins(); + await utils.deleteAllPosts(); + } ); + + test( 'remove class if callback returns falsy value', async ( { + page, + } ) => { + const el = page.getByTestId( + 'remove class if callback returns falsy value' + ); + await expect( el ).toHaveClass( 'bar' ); + await page.getByTestId( 'toggle falseValue' ).click(); + await expect( el ).toHaveClass( 'foo bar' ); + await page.getByTestId( 'toggle falseValue' ).click(); + await expect( el ).toHaveClass( 'bar' ); + } ); + + test( 'add class if callback returns truthy value', async ( { page } ) => { + const el = page.getByTestId( + 'add class if callback returns truthy value' + ); + await expect( el ).toHaveClass( 'foo bar' ); + await page.getByTestId( 'toggle trueValue' ).click(); + await expect( el ).toHaveClass( 'foo' ); + await page.getByTestId( 'toggle trueValue' ).click(); + await expect( el ).toHaveClass( 'foo bar' ); + } ); + + test( 'handles multiple classes and callbacks', async ( { page } ) => { + const el = page.getByTestId( 'handles multiple classes and callbacks' ); + await expect( el ).toHaveClass( 'bar baz' ); + await page.getByTestId( 'toggle trueValue' ).click(); + await expect( el ).toHaveClass( '' ); + await page.getByTestId( 'toggle trueValue' ).click(); + await expect( el ).toHaveClass( 'bar baz' ); + await page.getByTestId( 'toggle falseValue' ).click(); + await expect( el ).toHaveClass( 'foo bar baz' ); + await page.getByTestId( 'toggle trueValue' ).click(); + await expect( el ).toHaveClass( 'foo' ); + } ); + + test( 'handles class names that are contained inside other class names', async ( { + page, + } ) => { + const el = page.getByTestId( + 'handles class names that are contained inside other class names' + ); + await expect( el ).toHaveClass( 'foo-bar' ); + await page.getByTestId( 'toggle falseValue' ).click(); + await expect( el ).toHaveClass( 'foo foo-bar' ); + await page.getByTestId( 'toggle trueValue' ).click(); + await expect( el ).toHaveClass( 'foo' ); + } ); + + test( 'can toggle class in the middle', async ( { page } ) => { + const el = page.getByTestId( 'can toggle class in the middle' ); + await expect( el ).toHaveClass( 'foo bar baz' ); + await page.getByTestId( 'toggle trueValue' ).click(); + await expect( el ).toHaveClass( 'foo baz' ); + await page.getByTestId( 'toggle trueValue' ).click(); + await expect( el ).toHaveClass( 'foo bar baz' ); + } ); + + test( 'can toggle class when class attribute is missing', async ( { + page, + } ) => { + const el = page.getByTestId( + 'can toggle class when class attribute is missing' + ); + await expect( el ).toHaveClass( '' ); + await page.getByTestId( 'toggle falseValue' ).click(); + await expect( el ).toHaveClass( 'foo' ); + await page.getByTestId( 'toggle falseValue' ).click(); + await expect( el ).toHaveClass( '' ); + } ); + + test( 'can use context values', async ( { page } ) => { + const el = page.getByTestId( 'can use context values' ); + await expect( el ).toHaveClass( '' ); + await page.getByTestId( 'toggle context false value' ).click(); + await expect( el ).toHaveClass( 'foo' ); + await page.getByTestId( 'toggle context false value' ).click(); + await expect( el ).toHaveClass( '' ); + } ); +} ); diff --git a/test/e2e/specs/interactivity/directives-context.spec.ts b/test/e2e/specs/interactivity/directives-context.spec.ts new file mode 100644 index 00000000000000..5c74e8054bf19d --- /dev/null +++ b/test/e2e/specs/interactivity/directives-context.spec.ts @@ -0,0 +1,165 @@ +/** + * External dependencies + */ +import type { Locator } from '@playwright/test'; +/** + * Internal dependencies + */ +import { test, expect } from './fixtures'; + +const parseContent = async ( loc: Locator ) => + JSON.parse( ( await loc.textContent() ) || '' ); + +test.describe( 'data-wp-context', () => { + test.beforeAll( async ( { interactivityUtils: utils } ) => { + await utils.activatePlugins(); + await utils.addPostWithBlock( 'test/directive-context' ); + } ); + + test.beforeEach( async ( { interactivityUtils: utils, page } ) => { + await page.goto( utils.getLink( 'test/directive-context' ) ); + } ); + + test.afterAll( async ( { interactivityUtils: utils } ) => { + await utils.deactivatePlugins(); + await utils.deleteAllPosts(); + } ); + + test( 'is correctly initialized', async ( { page } ) => { + const parentContext = await parseContent( + page.getByTestId( 'parent context' ) + ); + + expect( parentContext ).toMatchObject( { + prop1: 'parent', + prop2: 'parent', + obj: { prop4: 'parent', prop5: 'parent' }, + array: [ 1, 2, 3 ], + } ); + } ); + + test( 'is correctly extended', async ( { page } ) => { + const childContext = await parseContent( + page.getByTestId( 'child context' ) + ); + + expect( childContext ).toMatchObject( { + prop1: 'parent', + prop2: 'child', + prop3: 'child', + obj: { prop4: 'parent', prop5: 'child', prop6: 'child' }, + array: [ 4, 5, 6 ], + } ); + } ); + + test( 'changes in inherited properties are reflected (child)', async ( { + page, + } ) => { + await page.getByTestId( 'child prop1' ).click(); + await page.getByTestId( 'child obj.prop4' ).click(); + + const childContext = await parseContent( + page.getByTestId( 'child context' ) + ); + + expect( childContext.prop1 ).toBe( 'modifiedFromChild' ); + expect( childContext.obj.prop4 ).toBe( 'modifiedFromChild' ); + + const parentContext = await parseContent( + page.getByTestId( 'parent context' ) + ); + + expect( parentContext.prop1 ).toBe( 'modifiedFromChild' ); + expect( parentContext.obj.prop4 ).toBe( 'modifiedFromChild' ); + } ); + + test( 'changes in inherited properties are reflected (parent)', async ( { + page, + } ) => { + await page.getByTestId( 'parent prop1' ).click(); + await page.getByTestId( 'parent obj.prop4' ).click(); + + const childContext = await parseContent( + page.getByTestId( 'child context' ) + ); + + expect( childContext.prop1 ).toBe( 'modifiedFromParent' ); + expect( childContext.obj.prop4 ).toBe( 'modifiedFromParent' ); + + const parentContext = await parseContent( + page.getByTestId( 'parent context' ) + ); + + expect( parentContext.prop1 ).toBe( 'modifiedFromParent' ); + expect( parentContext.obj.prop4 ).toBe( 'modifiedFromParent' ); + } ); + + test( 'changes in shadowed properties do not leak (child)', async ( { + page, + } ) => { + await page.getByTestId( 'child prop2' ).click(); + await page.getByTestId( 'child obj.prop5' ).click(); + + const childContext = await parseContent( + page.getByTestId( 'child context' ) + ); + + expect( childContext.prop2 ).toBe( 'modifiedFromChild' ); + expect( childContext.obj.prop5 ).toBe( 'modifiedFromChild' ); + + const parentContext = await parseContent( + page.getByTestId( 'parent context' ) + ); + + expect( parentContext.prop2 ).toBe( 'parent' ); + expect( parentContext.obj.prop5 ).toBe( 'parent' ); + } ); + + test( 'changes in shadowed properties do not leak (parent)', async ( { + page, + } ) => { + await page.getByTestId( 'parent prop2' ).click(); + await page.getByTestId( 'parent obj.prop5' ).click(); + + const childContext = await parseContent( + page.getByTestId( 'child context' ) + ); + + expect( childContext.prop2 ).toBe( 'child' ); + expect( childContext.obj.prop5 ).toBe( 'child' ); + + const parentContext = await parseContent( + page.getByTestId( 'parent context' ) + ); + + expect( parentContext.prop2 ).toBe( 'modifiedFromParent' ); + expect( parentContext.obj.prop5 ).toBe( 'modifiedFromParent' ); + } ); + + test( 'Array properties are shadowed', async ( { page } ) => { + const parentContext = await parseContent( + page.getByTestId( 'parent context' ) + ); + + const childContext = await parseContent( + page.getByTestId( 'child context' ) + ); + + expect( parentContext.array ).toMatchObject( [ 1, 2, 3 ] ); + expect( childContext.array ).toMatchObject( [ 4, 5, 6 ] ); + } ); + + test( 'can be accessed in other directives on the same element', async ( { + page, + } ) => { + const element = page.getByTestId( 'context & other directives' ); + await expect( element ).toHaveText( 'Text 1' ); + await expect( element ).toHaveAttribute( 'value', 'Text 1' ); + await element.click(); + await expect( element ).toHaveText( 'Text 2' ); + await expect( element ).toHaveAttribute( 'value', 'Text 2' ); + await element.click(); + await expect( element ).toHaveText( 'Text 1' ); + await expect( element ).toHaveAttribute( 'value', 'Text 1' ); + } ); +} ); diff --git a/test/e2e/specs/interactivity/directives-show.spec.ts b/test/e2e/specs/interactivity/directives-show.spec.ts new file mode 100644 index 00000000000000..7e25332ae143c5 --- /dev/null +++ b/test/e2e/specs/interactivity/directives-show.spec.ts @@ -0,0 +1,59 @@ +/** + * Internal dependencies + */ +import { test, expect } from './fixtures'; + +test.describe( 'data-wp-show', () => { + test.beforeAll( async ( { interactivityUtils: utils } ) => { + await utils.activatePlugins(); + await utils.addPostWithBlock( 'test/directive-show' ); + } ); + test.beforeEach( async ( { interactivityUtils: utils, page } ) => { + await page.goto( utils.getLink( 'test/directive-show' ) ); + } ); + test.afterAll( async ( { interactivityUtils: utils } ) => { + await utils.deactivatePlugins(); + await utils.deleteAllPosts(); + } ); + + test( 'show if callback returns truthy value', async ( { page } ) => { + const el = page.getByTestId( 'show if callback returns truthy value' ); + await expect( el ).toBeVisible(); + } ); + + test( 'do not show if callback returns falsy value', async ( { page } ) => { + const el = page.getByTestId( + 'do not show if callback returns false value' + ); + await expect( el ).toBeHidden(); + } ); + + test( 'hide when toggling truthy value to falsy', async ( { page } ) => { + const el = page.getByTestId( 'show if callback returns truthy value' ); + await expect( el ).toBeVisible(); + await page.getByTestId( 'toggle trueValue' ).click(); + await expect( el ).toBeHidden(); + await page.getByTestId( 'toggle trueValue' ).click(); + await expect( el ).toBeVisible(); + } ); + + test( 'show when toggling false value to truthy', async ( { page } ) => { + const el = page.getByTestId( + 'do not show if callback returns false value' + ); + await expect( el ).toBeHidden(); + await page.getByTestId( 'toggle falseValue' ).click(); + await expect( el ).toBeVisible(); + await page.getByTestId( 'toggle falseValue' ).click(); + await expect( el ).toBeHidden(); + } ); + + test( 'can use context values', async ( { page } ) => { + const el = page.getByTestId( 'can use context values' ); + await expect( el ).toBeHidden(); + await page.getByTestId( 'toggle context false value' ).click(); + await expect( el ).toBeVisible(); + await page.getByTestId( 'toggle context false value' ).click(); + await expect( el ).toBeHidden(); + } ); +} ); diff --git a/test/e2e/specs/interactivity/directives-text.spec.ts b/test/e2e/specs/interactivity/directives-text.spec.ts new file mode 100644 index 00000000000000..8e83be26de15ca --- /dev/null +++ b/test/e2e/specs/interactivity/directives-text.spec.ts @@ -0,0 +1,36 @@ +/** + * Internal dependencies + */ +import { test, expect } from './fixtures'; + +test.describe( 'data-wp-text', () => { + test.beforeAll( async ( { interactivityUtils: utils } ) => { + await utils.activatePlugins(); + await utils.addPostWithBlock( 'test/directive-text' ); + } ); + test.beforeEach( async ( { interactivityUtils: utils, page } ) => { + await page.goto( utils.getLink( 'test/directive-text' ) ); + } ); + test.afterAll( async ( { interactivityUtils: utils } ) => { + await utils.deactivatePlugins(); + await utils.deleteAllPosts(); + } ); + + test( 'show proper text reading from state', async ( { page } ) => { + const el = page.getByTestId( 'show state text' ); + await expect( el ).toHaveText( 'Text 1' ); + await page.getByTestId( 'toggle state text' ).click(); + await expect( el ).toHaveText( 'Text 2' ); + await page.getByTestId( 'toggle state text' ).click(); + await expect( el ).toHaveText( 'Text 1' ); + } ); + + test( 'show proper text reading from context', async ( { page } ) => { + const el = page.getByTestId( 'show context text' ); + await expect( el ).toHaveText( 'Text 1' ); + await page.getByTestId( 'toggle context text' ).click(); + await expect( el ).toHaveText( 'Text 2' ); + await page.getByTestId( 'toggle context text' ).click(); + await expect( el ).toHaveText( 'Text 1' ); + } ); +} ); diff --git a/test/e2e/specs/interactivity/fixtures/index.ts b/test/e2e/specs/interactivity/fixtures/index.ts new file mode 100644 index 00000000000000..607221ffb1ec43 --- /dev/null +++ b/test/e2e/specs/interactivity/fixtures/index.ts @@ -0,0 +1,25 @@ +/** + * WordPress dependencies + */ +import { test as base } from '@wordpress/e2e-test-utils-playwright'; +export { expect } from '@wordpress/e2e-test-utils-playwright'; + +/** + * Internal dependencies + */ +import InteractivityUtils from './interactivity-utils'; + +type Fixtures = { + interactivityUtils: InteractivityUtils; +}; + +export const test = base.extend< Fixtures >( { + interactivityUtils: [ + async ( { requestUtils }, use ) => { + await use( new InteractivityUtils( { requestUtils } ) ); + }, + // @ts-ignore: The required type is 'test', but can be 'worker' too. See + // https://playwright.dev/docs/test-fixtures#worker-scoped-fixtures + { scope: 'worker' }, + ], +} ); diff --git a/test/e2e/specs/interactivity/fixtures/interactivity-utils.ts b/test/e2e/specs/interactivity/fixtures/interactivity-utils.ts new file mode 100644 index 00000000000000..9d83c93650d403 --- /dev/null +++ b/test/e2e/specs/interactivity/fixtures/interactivity-utils.ts @@ -0,0 +1,60 @@ +/** + * WordPress dependencies + */ +import type { RequestUtils } from '@wordpress/e2e-test-utils-playwright'; + +export default class InteractivityUtils { + links: Map< string, string >; + requestUtils: RequestUtils; + + constructor( { requestUtils }: { requestUtils: RequestUtils } ) { + this.links = new Map(); + this.requestUtils = requestUtils; + } + + getLink( blockName: string ) { + const link = this.links.get( blockName ); + if ( ! link ) { + throw new Error( + `No link found for post with block '${ blockName }'` + ); + } + + // Add an extra param to disable directives SSR. This is required at + // this moment, as SSR for directives is not stabilized yet and we need + // to ensure hydration works, even when the SSR'ed HTML is not correct. + const url = new URL( link ); + url.searchParams.append( 'disable_directives_ssr', 'true' ); + return url.href; + } + + async addPostWithBlock( blockName: string ) { + const payload = { + content: ``, + status: 'publish' as 'publish', + date_gmt: '2023-01-01T00:00:00', + }; + + const { link } = await this.requestUtils.createPost( payload ); + this.links.set( blockName, link ); + } + + async deleteAllPosts() { + await this.requestUtils.deleteAllPosts(); + this.links.clear(); + } + + async activatePlugins() { + await this.requestUtils.activateTheme( 'emptytheme' ); + await this.requestUtils.activatePlugin( + 'gutenberg-test-interactive-blocks' + ); + } + + async deactivatePlugins() { + await this.requestUtils.activateTheme( 'twentytwentyone' ); + await this.requestUtils.deactivatePlugin( + 'gutenberg-test-interactive-blocks' + ); + } +} diff --git a/test/e2e/specs/interactivity/negation-operator.spec.ts b/test/e2e/specs/interactivity/negation-operator.spec.ts new file mode 100644 index 00000000000000..da5670a0e57824 --- /dev/null +++ b/test/e2e/specs/interactivity/negation-operator.spec.ts @@ -0,0 +1,44 @@ +/** + * Internal dependencies + */ +import { test, expect } from './fixtures'; + +test.describe( 'negation-operator', () => { + test.beforeAll( async ( { interactivityUtils: utils } ) => { + await utils.activatePlugins(); + await utils.addPostWithBlock( 'test/negation-operator' ); + } ); + + test.beforeEach( async ( { interactivityUtils: utils, page } ) => { + await page.goto( utils.getLink( 'test/negation-operator' ) ); + } ); + + test.afterAll( async ( { interactivityUtils: utils } ) => { + await utils.deactivatePlugins(); + await utils.deleteAllPosts(); + } ); + + test( 'add hidden attribute when !state.active', async ( { page } ) => { + const el = page.getByTestId( + 'add hidden attribute if state is not active' + ); + + await expect( el ).toHaveAttribute( 'hidden', '' ); + await page.getByTestId( 'toggle active value' ).click(); + await expect( el ).not.toHaveAttribute( 'hidden', '' ); + await page.getByTestId( 'toggle active value' ).click(); + await expect( el ).toHaveAttribute( 'hidden', '' ); + } ); + + test( 'add hidden attribute when !selectors.active', async ( { page } ) => { + const el = page.getByTestId( + 'add hidden attribute if selector is not active' + ); + + await expect( el ).toHaveAttribute( 'hidden', '' ); + await page.getByTestId( 'toggle active value' ).click(); + await expect( el ).not.toHaveAttribute( 'hidden', '' ); + await page.getByTestId( 'toggle active value' ).click(); + await expect( el ).toHaveAttribute( 'hidden', '' ); + } ); +} ); diff --git a/test/e2e/specs/interactivity/store-tag.spec.ts b/test/e2e/specs/interactivity/store-tag.spec.ts new file mode 100644 index 00000000000000..80f9acfad93358 --- /dev/null +++ b/test/e2e/specs/interactivity/store-tag.spec.ts @@ -0,0 +1,86 @@ +/** + * Internal dependencies + */ +import { test, expect } from './fixtures'; + +test.describe( 'store tag', () => { + test.beforeAll( async ( { interactivityUtils: utils } ) => { + await utils.activatePlugins(); + await utils.addPostWithBlock( 'test/store-tag {"condition":"ok"}' ); + await utils.addPostWithBlock( + 'test/store-tag {"condition":"missing"}' + ); + await utils.addPostWithBlock( + 'test/store-tag {"condition":"corrupted-json"}' + ); + await utils.addPostWithBlock( + 'test/store-tag {"condition":"invalid-state"}' + ); + } ); + + test.afterAll( async ( { interactivityUtils: utils } ) => { + await utils.deactivatePlugins(); + await utils.deleteAllPosts(); + } ); + + test( 'hydrates when it is well defined', async ( { + interactivityUtils: utils, + page, + } ) => { + const block = 'test/store-tag {"condition":"ok"}'; + await page.goto( utils.getLink( block ) ); + + const value = page.getByTestId( 'counter value' ); + const double = page.getByTestId( 'counter double' ); + const clicks = page.getByTestId( 'counter clicks' ); + + await expect( value ).toHaveText( '3' ); + await expect( double ).toHaveText( '6' ); + await expect( clicks ).toHaveText( '0' ); + + await page.getByTestId( 'counter button' ).click(); + + await expect( value ).toHaveText( '4' ); + await expect( double ).toHaveText( '8' ); + await expect( clicks ).toHaveText( '1' ); + } ); + + test( 'does not break the page when missing', async ( { + interactivityUtils: utils, + page, + } ) => { + const block = 'test/store-tag {"condition":"missing"}'; + await page.goto( utils.getLink( block ) ); + + const clicks = page.getByTestId( 'counter clicks' ); + await expect( clicks ).toHaveText( '0' ); + await page.getByTestId( 'counter button' ).click(); + await expect( clicks ).toHaveText( '1' ); + } ); + + test( 'does not break the page when corrupted', async ( { + interactivityUtils: utils, + page, + } ) => { + const block = 'test/store-tag {"condition":"corrupted-json"}'; + await page.goto( utils.getLink( block ) ); + + const clicks = page.getByTestId( 'counter clicks' ); + await expect( clicks ).toHaveText( '0' ); + await page.getByTestId( 'counter button' ).click(); + await expect( clicks ).toHaveText( '1' ); + } ); + + test( 'does not break the page when it contains an invalid state', async ( { + interactivityUtils: utils, + page, + } ) => { + const block = 'test/store-tag {"condition":"invalid-state"}'; + await page.goto( utils.getLink( block ) ); + + const clicks = page.getByTestId( 'counter clicks' ); + await expect( clicks ).toHaveText( '0' ); + await page.getByTestId( 'counter button' ).click(); + await expect( clicks ).toHaveText( '1' ); + } ); +} ); diff --git a/test/e2e/specs/interactivity/tovdom-islands.spec.ts b/test/e2e/specs/interactivity/tovdom-islands.spec.ts new file mode 100644 index 00000000000000..fcc7c6081077a6 --- /dev/null +++ b/test/e2e/specs/interactivity/tovdom-islands.spec.ts @@ -0,0 +1,58 @@ +/** + * Internal dependencies + */ +import { test, expect } from './fixtures'; + +test.describe( 'toVdom - islands', () => { + test.beforeAll( async ( { interactivityUtils: utils } ) => { + await utils.activatePlugins(); + await utils.addPostWithBlock( 'test/tovdom-islands' ); + } ); + test.beforeEach( async ( { interactivityUtils: utils, page } ) => { + await page.goto( utils.getLink( 'test/tovdom-islands' ) ); + } ); + test.afterAll( async ( { interactivityUtils: utils } ) => { + await utils.deactivatePlugins(); + await utils.deleteAllPosts(); + } ); + + test( 'directives that are not inside islands should not be hydrated', async ( { + page, + } ) => { + const el = page.getByTestId( 'not inside an island' ); + await expect( el ).toBeVisible(); + } ); + + test( 'directives that are inside islands should be hydrated', async ( { + page, + } ) => { + const el = page.getByTestId( 'inside an island' ); + await expect( el ).toBeHidden(); + } ); + + test( 'directives that are inside inner blocks of isolated islands should not be hydrated', async ( { + page, + } ) => { + const el = page.getByTestId( + 'inside an inner block of an isolated island' + ); + await expect( el ).toBeVisible(); + } ); + + test( 'directives inside islands should not be hydrated twice', async ( { + page, + } ) => { + const el = page.getByTestId( 'island inside another island' ); + const templates = el.locator( 'template' ); + expect( await templates.count() ).toEqual( 1 ); + } ); + + test( 'islands inside inner blocks of isolated islands should be hydrated', async ( { + page, + } ) => { + const el = page.getByTestId( + 'island inside inner block of isolated island' + ); + await expect( el ).toBeHidden(); + } ); +} ); diff --git a/test/e2e/specs/interactivity/tovdom.spec.ts b/test/e2e/specs/interactivity/tovdom.spec.ts new file mode 100644 index 00000000000000..cf08fb115db4fd --- /dev/null +++ b/test/e2e/specs/interactivity/tovdom.spec.ts @@ -0,0 +1,55 @@ +/** + * Internal dependencies + */ +import { test, expect } from './fixtures'; + +test.describe( 'toVdom', () => { + test.beforeAll( async ( { interactivityUtils: utils } ) => { + await utils.activatePlugins(); + await utils.addPostWithBlock( 'test/tovdom' ); + } ); + test.beforeEach( async ( { interactivityUtils: utils, page } ) => { + await page.goto( utils.getLink( 'test/tovdom' ) ); + } ); + test.afterAll( async ( { interactivityUtils: utils } ) => { + await utils.deactivatePlugins(); + await utils.deleteAllPosts(); + } ); + + test( 'it should delete comments', async ( { page } ) => { + const el = page.getByTestId( 'it should delete comments' ); + const c = await el.innerHTML(); + expect( c ).not.toContain( '##1##' ); + expect( c ).not.toContain( '##2##' ); + const el2 = page.getByTestId( + 'it should keep this node between comments' + ); + await expect( el2 ).toBeVisible(); + } ); + + test( 'it should delete processing instructions', async ( { page } ) => { + const el = page.getByTestId( + 'it should delete processing instructions' + ); + const c = await el.innerHTML(); + expect( c ).not.toContain( '##1##' ); + expect( c ).not.toContain( '##2##' ); + const el2 = page.getByTestId( + 'it should keep this node between processing instructions' + ); + await expect( el2 ).toBeVisible(); + } ); + + test( 'it should replace CDATA with text nodes', async ( { page } ) => { + const el = page.getByTestId( + 'it should replace CDATA with text nodes' + ); + const c = await el.innerHTML(); + expect( c ).toContain( '##1##' ); + expect( c ).toContain( '##2##' ); + const el2 = page.getByTestId( + 'it should keep this node between CDATA' + ); + await expect( el2 ).toBeVisible(); + } ); +} ); From 3f29f8b0343259ea3dc245bcdd2142956009dcca Mon Sep 17 00:00:00 2001 From: Luis Herranz Date: Wed, 28 Jun 2023 09:09:22 +0200 Subject: [PATCH 34/34] Check that modal exists before using `contains` --- 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 8fcc5527c6042c..c9e0a78d6a2a3e 100644 --- a/packages/block-library/src/navigation/view.js +++ b/packages/block-library/src/navigation/view.js @@ -31,7 +31,7 @@ const closeMenu = ( { context, selectors }, menuClosedOn ) => { // Check if the menu is still open or not. if ( ! selectors.core.navigation.isMenuOpen( { context } ) ) { if ( - context.core.navigation.modal.contains( + context.core.navigation.modal?.contains( window.document.activeElement ) ) { @@ -155,7 +155,7 @@ store( { // `window.document.activeElement` doesn't change if ( context.core.navigation.isMenuOpen.click && - ! context.core.navigation.modal.contains( + ! context.core.navigation.modal?.contains( event.relatedTarget ) && event.target !== window.document.activeElement