diff --git a/assets/js/interactivity/components.js b/assets/js/interactivity/components.js deleted file mode 100644 index 7b234c818d9..00000000000 --- a/assets/js/interactivity/components.js +++ /dev/null @@ -1,26 +0,0 @@ -import { useMemo, useContext } from 'preact/hooks'; -import { deepSignal } from 'deepsignal'; -import { component } from './hooks'; - -export default () => { - // - const Context = ( { children, data, context: { Provider } } ) => { - const signals = useMemo( - () => deepSignal( JSON.parse( data ) ), - [ data ] - ); - return { children }; - }; - component( 'context', Context ); - - // - const Show = ( { children, when, evaluate, context } ) => { - const contextValue = useContext( context ); - if ( evaluate( when, { context: contextValue } ) ) { - return children; - } else { - return ; - } - }; - component( 'show', Show ); -}; diff --git a/assets/js/interactivity/constants.js b/assets/js/interactivity/constants.js index e2de6acee8b..2802f9103fe 100644 --- a/assets/js/interactivity/constants.js +++ b/assets/js/interactivity/constants.js @@ -1,3 +1,2 @@ -export const csnMetaTagItemprop = 'woo-client-side-navigation'; -export const componentPrefix = 'woo-'; -export const directivePrefix = 'data-woo-'; +export const csnMetaTagItemprop = 'wc-client-side-navigation'; +export const directivePrefix = 'wc'; diff --git a/assets/js/interactivity/directives.js b/assets/js/interactivity/directives.js index 4f6775db73e..a306b7df9fe 100644 --- a/assets/js/interactivity/directives.js +++ b/assets/js/interactivity/directives.js @@ -1,14 +1,9 @@ import { useContext, useMemo, useEffect } from 'preact/hooks'; -import { useSignalEffect } from '@preact/signals'; import { deepSignal, peek } from 'deepsignal'; +import { useSignalEffect } from './utils'; import { directive } from './hooks'; import { prefetch, navigate, canDoClientSideNavigation } from './router'; -// Until useSignalEffects is fixed: -// https://github.com/preactjs/signals/issues/228 -const raf = window.requestAnimationFrame; -const tick = () => new Promise( ( r ) => raf( () => raf( r ) ) ); - // Check if current page can do client-side navigation. const clientSideNavigation = canDoClientSideNavigation( document.head ); @@ -32,7 +27,7 @@ const mergeDeepSignals = ( target, source ) => { }; export default () => { - // wp-context + // data-wc-context directive( 'context', ( { @@ -51,20 +46,21 @@ export default () => { }, [ context, inheritedValue ] ); return { children }; - } + }, + { priority: 5 } ); - // wp-effect:[name] + // data-wc-effect--[name] directive( 'effect', ( { directives: { effect }, context, evaluate } ) => { const contextValue = useContext( context ); Object.values( effect ).forEach( ( path ) => { useSignalEffect( () => { - evaluate( path, { context: contextValue } ); + return evaluate( path, { context: contextValue } ); } ); } ); } ); - // wp-on:[event] + // data-wc-on--[event] directive( 'on', ( { directives: { on }, element, evaluate, context } ) => { const contextValue = useContext( context ); Object.entries( on ).forEach( ( [ name, path ] ) => { @@ -74,7 +70,7 @@ export default () => { } ); } ); - // wp-class:[classname] + // data-wc-class--[classname] directive( 'class', ( { @@ -119,7 +115,7 @@ export default () => { } ); - // wp-bind:[attribute] + // data-wc-bind--[attribute] directive( 'bind', ( { directives: { bind }, element, context, evaluate } ) => { @@ -127,14 +123,35 @@ export default () => { Object.entries( bind ) .filter( ( n ) => n !== 'default' ) .forEach( ( [ attribute, path ] ) => { - element.props[ attribute ] = evaluate( 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 + ); + } + }, [] ); } ); } ); - // wp-link + // data-wc-link directive( 'link', ( { @@ -173,4 +190,62 @@ export default () => { } } ); + + // data-wc-show + directive( + 'show', + ( { + directives: { + show: { default: show }, + }, + element, + evaluate, + context, + } ) => { + const contextValue = useContext( context ); + + if ( ! evaluate( show, { context: contextValue } ) ) + element.props.children = ( + + ); + } + ); + + // data-wc-ignore + directive( + 'ignore', + ( { + element: { + type: Type, + props: { innerHTML, ...rest }, + }, + } ) => { + // Preserve the initial inner HTML. + const cached = useMemo( () => innerHTML, [] ); + return ( + + ); + } + ); + + // data-wc-text + directive( + 'text', + ( { + directives: { + text: { default: text }, + }, + element, + evaluate, + context, + } ) => { + const contextValue = useContext( context ); + element.props.children = evaluate( text, { + context: contextValue, + } ); + } + ); }; diff --git a/assets/js/interactivity/hooks.js b/assets/js/interactivity/hooks.js index 51fd9059dc3..a055c111bf7 100644 --- a/assets/js/interactivity/hooks.js +++ b/assets/js/interactivity/hooks.js @@ -1,26 +1,21 @@ -import { h, options, createContext } from 'preact'; -import { useRef } from 'preact/hooks'; +import { h, options, createContext, cloneElement } from 'preact'; +import { useRef, useMemo } from 'preact/hooks'; import { rawStore as store } from './store'; -import { componentPrefix } from './constants'; // Main context. const context = createContext( {} ); // WordPress Directives. const directiveMap = {}; -export const directive = ( name, cb ) => { +const directivePriorities = {}; +export const directive = ( name, cb, { priority = 10 } = {} ) => { directiveMap[ name ] = cb; -}; - -// WordPress Components. -const componentMap = {}; -export const component = ( name, Comp ) => { - componentMap[ name ] = Comp; + directivePriorities[ name ] = priority; }; // Resolve the path to some property of the store object. -const resolve = ( path, context ) => { - let current = { ...store, context }; +const resolve = ( path, ctx ) => { + let current = { ...store, context: ctx }; path.split( '.' ).forEach( ( p ) => ( current = current[ p ] ) ); return current; }; @@ -29,22 +24,92 @@ const resolve = ( path, context ) => { 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 ); - return typeof value === 'function' - ? value( { - state: store.state, - ...( ref !== undefined ? { ref } : {} ), - ...extraArgs, - } ) - : value; + 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, _wrapped: true } ); - const props = { ...originalProps, children: element }; - const evaluate = getEvaluate( { ref: ref.current } ); + 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 ) { @@ -58,27 +123,16 @@ const Directive = ( { type, directives, props: originalProps } ) => { // Preact Options Hook called each time a vnode is created. const old = options.vnode; options.vnode = ( vnode ) => { - const type = vnode.type; - const { directives } = vnode.props; - - if ( - typeof type === 'string' && - type.slice( 0, componentPrefix.length ) === componentPrefix - ) { - vnode.props.children = h( - componentMap[ type.slice( componentPrefix.length ) ], - { ...vnode.props, context, evaluate: getEvaluate() }, - vnode.props.children - ); - } else if ( directives ) { + if ( vnode.props.__directives ) { const props = vnode.props; - delete props.directives; - if ( ! props._wrapped ) { - vnode.props = { type: vnode.type, directives, props }; - vnode.type = Directive; - } else { - delete props._wrapped; - } + const directives = props.__directives; + delete props.__directives; + vnode.props = { + type: vnode.type, + directives, + props, + }; + vnode.type = Directive; } if ( old ) old( vnode ); diff --git a/assets/js/interactivity/index.js b/assets/js/interactivity/index.js index cc1357d15e7..67d56e8e9cb 100644 --- a/assets/js/interactivity/index.js +++ b/assets/js/interactivity/index.js @@ -1,14 +1,14 @@ import registerDirectives from './directives'; -import registerComponents from './components'; import { init } from './router'; export { store } from './store'; +export { navigate } from './router'; /** - * Initialize the initial vDOM. + * Initialize the Interactivity API. */ document.addEventListener( 'DOMContentLoaded', async () => { registerDirectives(); - registerComponents(); await init(); - console.log( 'hydrated!' ); + // eslint-disable-next-line no-console + console.log( 'Interactivity API started' ); } ); diff --git a/assets/js/interactivity/router.js b/assets/js/interactivity/router.js index 8b441a95363..780371dd764 100644 --- a/assets/js/interactivity/router.js +++ b/assets/js/interactivity/router.js @@ -6,9 +6,10 @@ import { csnMetaTagItemprop, directivePrefix } from './constants'; // The root to render the vdom (document.body). let rootFragment; -// The cache of visited and prefetched pages and stylesheets. +// The cache of visited and prefetched pages, stylesheets and scripts. const pages = new Map(); const stylesheets = new Map(); +const scripts = new Map(); // Helper to remove domain and hash from the URL. We are only interesting in // caching the path and the query. @@ -23,30 +24,77 @@ export const canDoClientSideNavigation = ( dom ) => .querySelector( `meta[itemprop='${ csnMetaTagItemprop }']` ) ?.getAttribute( 'content' ) === 'active'; -// Fetch styles of a new page. -const fetchHead = async ( head ) => { - const sheets = await Promise.all( - [].map.call( - head.querySelectorAll( "link[rel='stylesheet']" ), - ( link ) => { - const href = link.getAttribute( 'href' ); - if ( ! stylesheets.has( href ) ) - stylesheets.set( - href, - fetch( href ).then( ( r ) => r.text() ) - ); - return stylesheets.get( href ); - } - ) +/** + * Finds the elements in the document that match the selector and fetch them. + * For each element found, fetch the content and store it in the cache. + * Returns an array of elements to add to the document. + * + * @param {Document} document + * @param {string} selector - CSS selector used to find the elements. + * @param {'href'|'src'} attribute - Attribute that determines where to fetch + * the styles or scripts from. Also used as the key for the cache. + * @param {Map} cache - Cache to use for the elements. Can be `stylesheets` or `scripts`. + * @param {'style'|'script'} elementToCreate - Element to create for each fetched + * item. Can be 'style' or 'script'. + * @return {Promise>} - Array of elements to add to the document. + */ +const fetchScriptOrStyle = async ( + document, + selector, + attribute, + cache, + elementToCreate +) => { + const fetchedItems = await Promise.all( + [].map.call( document.querySelectorAll( selector ), ( el ) => { + const attributeValue = el.getAttribute( attribute ); + if ( ! cache.has( attributeValue ) ) + cache.set( + attributeValue, + fetch( attributeValue ).then( ( r ) => r.text() ) + ); + return cache.get( attributeValue ); + } ) ); - const stylesFromSheets = sheets.map( ( sheet ) => { - const style = document.createElement( 'style' ); - style.textContent = sheet; - return style; + + return fetchedItems.map( ( item ) => { + const element = document.createElement( elementToCreate ); + element.textContent = item; + return element; } ); +}; + +// Fetch styles of a new page. +const fetchAssets = async ( document ) => { + const stylesFromSheets = await fetchScriptOrStyle( + document, + 'link[rel=stylesheet]', + 'href', + stylesheets, + 'style' + ); + const scriptTags = await fetchScriptOrStyle( + document, + 'script[src]', + 'src', + scripts, + 'script' + ); + const moduleScripts = await fetchScriptOrStyle( + document, + 'script[type=module]', + 'src', + scripts, + 'script' + ); + moduleScripts.forEach( ( script ) => + script.setAttribute( 'type', 'module' ) + ); + return [ - head.querySelector( 'title' ), - ...head.querySelectorAll( 'style' ), + ...scriptTags, + document.querySelector( 'title' ), + ...document.querySelectorAll( 'style' ), ...stylesFromSheets, ]; }; @@ -56,7 +104,7 @@ const fetchPage = async ( url ) => { const html = await window.fetch( url ).then( ( r ) => r.text() ); const dom = new window.DOMParser().parseFromString( html, 'text/html' ); if ( ! canDoClientSideNavigation( dom.head ) ) return false; - const head = await fetchHead( dom.head ); + const head = await fetchAssets( dom ); return { head, body: toVdom( dom.body ) }; }; @@ -70,14 +118,18 @@ export const prefetch = ( url ) => { }; // Navigate to a new page. -export const navigate = async ( href ) => { +export const navigate = async ( href, { replace = false } = {} ) => { const url = cleanUrl( href ); prefetch( url ); const page = await pages.get( url ); if ( page ) { document.head.replaceChildren( ...page.head ); render( page.body, rootFragment ); - window.history.pushState( {}, '', href ); + window.history[ replace ? 'replaceState' : 'pushState' ]( + {}, + '', + href + ); } else { window.location.assign( href ); } @@ -104,18 +156,22 @@ export const init = async () => { document.documentElement, document.body ); - const body = toVdom( document.body ); hydrate( body, rootFragment ); - const head = await fetchHead( document.head ); + // Cache the scripts. Has to be called before fetching the assets. + [].map.call( document.querySelectorAll( 'script[src]' ), ( script ) => { + scripts.set( script.getAttribute( 'src' ), script.textContent ); + } ); + + const head = await fetchAssets( document ); pages.set( cleanUrl( window.location ), Promise.resolve( { body, head } ) ); } else { document - .querySelectorAll( `[${ directivePrefix }island]` ) + .querySelectorAll( `[data-${ directivePrefix }-interactive]` ) .forEach( ( node ) => { if ( ! hydratedIslands.has( node ) ) { const fragment = createRootFragment( diff --git a/assets/js/interactivity/store.js b/assets/js/interactivity/store.js index 14df2a369d6..d00308f8012 100644 --- a/assets/js/interactivity/store.js +++ b/assets/js/interactivity/store.js @@ -18,7 +18,7 @@ export const deepMerge = ( target, source ) => { const getSerializedState = () => { const storeTag = document.querySelector( - `script[type="application/json"]#store` + `script[type="application/json"]#wc-interactivity-store-data` ); if ( ! storeTag ) return {}; try { @@ -26,6 +26,7 @@ const getSerializedState = () => { 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 {}; diff --git a/assets/js/interactivity/utils.js b/assets/js/interactivity/utils.js index 04ebe313630..e064397a334 100644 --- a/assets/js/interactivity/utils.js +++ b/assets/js/interactivity/utils.js @@ -1,4 +1,47 @@ -// For wrapperless hydration of document.body. +import { useRef, useEffect } from 'preact/hooks'; +import { effect } from '@preact/signals'; + +function afterNextFrame( callback ) { + const done = () => { + cancelAnimationFrame( raf ); + setTimeout( callback ); + }; + const raf = 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 ); diff --git a/assets/js/interactivity/vdom.js b/assets/js/interactivity/vdom.js index 48c335ca342..3d15d24fc07 100644 --- a/assets/js/interactivity/vdom.js +++ b/assets/js/interactivity/vdom.js @@ -1,69 +1,105 @@ 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(); -// Recursive function that transfoms a DOM tree into vDOM. -export function toVdom( node ) { - const props = {}; - const { attributes, childNodes } = node; - const directives = {}; - let hasDirectives = false; - let ignore = false; - let island = false; +// 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 + ); - if ( node.nodeType === 3 ) return node.data; - if ( node.nodeType === 4 ) { - node.replaceWith( new Text( node.nodeValue ) ); - return node.nodeValue; - } + function walk( node ) { + const { attributes, nodeType } = node; + + if ( nodeType === 3 ) return [ node.data ]; + if ( nodeType === 4 ) { + const next = treeWalker.nextSibling(); + node.replaceWith( new Text( node.nodeValue ) ); + return [ node.nodeValue, next ]; + } + if ( nodeType === 8 || nodeType === 7 ) { + const next = treeWalker.nextSibling(); + node.remove(); + return [ null, next ]; + } - 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; + 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[ fullPrefix.length ] && + n.slice( 0, fullPrefix.length ) === fullPrefix + ) { + 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; } - } else if ( n === 'ref' ) { - continue; - } else { props[ n ] = attributes[ i ].value; } - } - if ( ignore && ! island ) - return h( node.localName, { - dangerouslySetInnerHTML: { __html: node.innerHTML }, - } ); - if ( island ) hydratedIslands.add( node ); + if ( ignore && ! island ) + return [ + h( node.localName, { + ...props, + innerHTML: node.innerHTML, + __directives: { ignore: true }, + } ), + ]; + if ( island ) hydratedIslands.add( node ); - if ( hasDirectives ) props.directives = directives; + if ( hasDirectives ) props.__directives = directives; - const children = []; - for ( let i = 0; i < childNodes.length; i++ ) { - const child = childNodes[ i ]; - if ( child.nodeType === 8 || child.nodeType === 7 ) { - child.remove(); - i--; - } else { - children.push( toVdom( child ) ); + 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 h( node.localName, props, children ); + return walk( treeWalker.currentNode ); } diff --git a/bin/webpack-configs.js b/bin/webpack-configs.js index 41c3bcfb6fa..b5fb6f3f82d 100644 --- a/bin/webpack-configs.js +++ b/bin/webpack-configs.js @@ -785,11 +785,17 @@ const getInteractivityAPIConfig = ( options = {} ) => { const { alias, resolvePlugins = [] } = options; return { entry: { - runtime: './assets/js/interactivity', + 'wc-interactivity': './assets/js/interactivity', }, output: { - filename: 'woo-directives-[name].js', + filename: '[name].js', path: path.resolve( __dirname, '../build/' ), + library: [ 'wc', '__experimentalInteractivity' ], + libraryTarget: 'this', + // This fixes an issue with multiple webpack projects using chunking + // overwriting each other's chunk loader function. + // See https://webpack.js.org/configuration/output/#outputjsonpfunction + jsonpFunction: 'webpackWcBlocksJsonp', }, resolve: { alias, @@ -804,21 +810,6 @@ const getInteractivityAPIConfig = ( options = {} ) => { getProgressBarPluginConfig( 'WP directives' ) ), ], - optimization: { - runtimeChunk: { - name: 'vendors', - }, - splitChunks: { - cacheGroups: { - vendors: { - test: /[\\/]node_modules[\\/]/, - name: 'vendors', - minSize: 0, - chunks: 'all', - }, - }, - }, - }, module: { rules: [ { @@ -841,6 +832,7 @@ const getInteractivityAPIConfig = ( options = {} ) => { }, ], ], + // Required until Webpack is updated to ^5.0.0 plugins: [ '@babel/plugin-proposal-optional-chaining', ], diff --git a/bin/webpack-helpers.js b/bin/webpack-helpers.js index 0eaa4926eb0..deb1b16f905 100644 --- a/bin/webpack-helpers.js +++ b/bin/webpack-helpers.js @@ -17,6 +17,7 @@ const wcDepMap = { '@woocommerce/shared-hocs': [ 'wc', 'wcBlocksSharedHocs' ], '@woocommerce/price-format': [ 'wc', 'priceFormat' ], '@woocommerce/blocks-checkout': [ 'wc', 'blocksCheckout' ], + '@woocommerce/interactivity': [ 'wc', '__experimentalInteractivity' ], }; const wcHandleMap = { @@ -28,6 +29,7 @@ const wcHandleMap = { '@woocommerce/shared-hocs': 'wc-blocks-shared-hocs', '@woocommerce/price-format': 'wc-price-format', '@woocommerce/blocks-checkout': 'wc-blocks-checkout', + '@woocommerce/interactivity': 'wc-interactivity', }; const getAlias = ( options = {} ) => { diff --git a/src/Interactivity/class-wc-interactivity-store.php b/src/Interactivity/class-wc-interactivity-store.php new file mode 100644 index 00000000000..07eac593f33 --- /dev/null +++ b/src/Interactivity/class-wc-interactivity-store.php @@ -0,0 +1,56 @@ +%s', + wp_json_encode( self::$store ) + ); + } +} diff --git a/src/Interactivity/client-side-navigation.php b/src/Interactivity/client-side-navigation.php new file mode 100644 index 00000000000..ab550070f61 --- /dev/null +++ b/src/Interactivity/client-side-navigation.php @@ -0,0 +1,9 @@ +'; +} +add_action( 'wp_head', 'woocommerce_interactivity_add_client_side_navigation_meta_tag' ); diff --git a/src/Interactivity/directives/attributes/woo-bind.php b/src/Interactivity/directives/attributes/woo-bind.php deleted file mode 100644 index 1990be52987..00000000000 --- a/src/Interactivity/directives/attributes/woo-bind.php +++ /dev/null @@ -1,22 +0,0 @@ -is_tag_closer() ) { - return; - } - - $prefixed_attributes = $tags->get_attribute_names_with_prefix( 'data-woo-bind:' ); - - foreach ( $prefixed_attributes as $attr ) { - list( , $bound_attr ) = explode( ':', $attr ); - if ( empty( $bound_attr ) ) { - continue; - } - - $expr = $tags->get_attribute( $attr ); - $value = woo_directives_evaluate( $expr, $context->get_context() ); - $tags->set_attribute( $bound_attr, $value ); - } -} diff --git a/src/Interactivity/directives/attributes/woo-class.php b/src/Interactivity/directives/attributes/woo-class.php deleted file mode 100644 index be0858bb60d..00000000000 --- a/src/Interactivity/directives/attributes/woo-class.php +++ /dev/null @@ -1,26 +0,0 @@ -is_tag_closer() ) { - return; - } - - $prefixed_attributes = $tags->get_attribute_names_with_prefix( 'data-woo-class:' ); - - foreach ( $prefixed_attributes as $attr ) { - list( , $class_name ) = explode( ':', $attr ); - if ( empty( $class_name ) ) { - continue; - } - - $expr = $tags->get_attribute( $attr ); - $add_class = woo_directives_evaluate( $expr, $context->get_context() ); - if ( $add_class ) { - $tags->add_class( $class_name ); - } else { - $tags->remove_class( $class_name ); - } - } -} diff --git a/src/Interactivity/directives/attributes/woo-context.php b/src/Interactivity/directives/attributes/woo-context.php deleted file mode 100644 index 823ab9683b0..00000000000 --- a/src/Interactivity/directives/attributes/woo-context.php +++ /dev/null @@ -1,18 +0,0 @@ -is_tag_closer() ) { - $context->rewind_context(); - return; - } - - $value = $tags->get_attribute( 'data-woo-context' ); - if ( null === $value ) { - // No woo-context directive. - return; - } - - $new_context = json_decode( $value, true ); - - $context->set_context( $new_context ); -} diff --git a/src/Interactivity/directives/attributes/woo-style.php b/src/Interactivity/directives/attributes/woo-style.php deleted file mode 100644 index 4cd38ced4dc..00000000000 --- a/src/Interactivity/directives/attributes/woo-style.php +++ /dev/null @@ -1,29 +0,0 @@ -is_tag_closer() ) { - return; - } - - $prefixed_attributes = $tags->get_attribute_names_with_prefix( 'data-woo-style:' ); - - foreach ( $prefixed_attributes as $attr ) { - list( , $style_name ) = explode( ':', $attr ); - if ( empty( $style_name ) ) { - continue; - } - - $expr = $tags->get_attribute( $attr ); - $style_value = woo_directives_evaluate( $expr, $context->get_context() ); - if ( $style_value ) { - $style_attr = $tags->get_attribute( 'style' ); - $style_attr = woo_directives_set_style( $style_attr, $style_name, $style_value ); - $tags->set_attribute( 'style', $style_attr ); - } else { - // $tags->remove_class( $style_name ); - } - } -} - diff --git a/src/Interactivity/directives/class-woo-directive-context.php b/src/Interactivity/directives/class-woo-directive-context.php deleted file mode 100644 index a4074ca40f1..00000000000 --- a/src/Interactivity/directives/class-woo-directive-context.php +++ /dev/null @@ -1,73 +0,0 @@ - - * - * - * - * - * - * - */ -class Woo_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 ) { - 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/src/Interactivity/directives/class-woo-directive-store.php b/src/Interactivity/directives/class-woo-directive-store.php deleted file mode 100644 index 3b213d281d3..00000000000 --- a/src/Interactivity/directives/class-woo-directive-store.php +++ /dev/null @@ -1,31 +0,0 @@ -$store"; - } -} diff --git a/src/Interactivity/directives/tags/woo-context.php b/src/Interactivity/directives/tags/woo-context.php deleted file mode 100644 index 13a4a74cc3f..00000000000 --- a/src/Interactivity/directives/tags/woo-context.php +++ /dev/null @@ -1,18 +0,0 @@ -is_tag_closer() ) { - $context->rewind_context(); - return; - } - - $value = $tags->get_attribute( 'data' ); - if ( null === $value ) { - // No woo-context directive. - return; - } - - $new_context = json_decode( $value, true ); - - $context->set_context( $new_context ); -} diff --git a/src/Interactivity/directives/utils.php b/src/Interactivity/directives/utils.php deleted file mode 100644 index 5d072e69da9..00000000000 --- a/src/Interactivity/directives/utils.php +++ /dev/null @@ -1,49 +0,0 @@ - $context ) - ); - - $array = explode( '.', $path ); - foreach ( $array as $p ) { - if ( isset( $current[ $p ] ) ) { - $current = $current[ $p ]; - } else { - return null; - } - } - return $current; -} - -function woo_directives_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 ) { - $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/src/Interactivity/directives/woo-process-directives.php b/src/Interactivity/directives/woo-process-directives.php deleted file mode 100644 index dcfe317d235..00000000000 --- a/src/Interactivity/directives/woo-process-directives.php +++ /dev/null @@ -1,85 +0,0 @@ -next_tag( array( 'tag_closers' => 'visit' ) ) ) { - $tag_name = strtolower( $tags->get_tag() ); - if ( array_key_exists( $tag_name, $tag_directives ) ) { - call_user_func( $tag_directives[ $tag_name ], $tags, $context ); - } else { - // Components can't have directives (unless we change our mind about this). - - // 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 colon before looking - // for the directive processor inside `$attribute_directives`. - $get_directive_type = function ( $attr ) { - return strtok( $attr, ':' ); - }; - - $attributes = $tags->get_attribute_names_with_prefix( $prefix ); - $attributes = array_map( $get_directive_type, $attributes ); - $attributes = array_intersect( $attributes, array_keys( $attribute_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 ( - ! woo_directives_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( $attribute_directives[ $attribute ], $tags, $context ); - } - } - } - - return $tags; -} - -// See e.g. https://github.com/WordPress/gutenberg/pull/47573. -function woo_directives_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/src/Interactivity/load.php b/src/Interactivity/load.php new file mode 100644 index 00000000000..8838971f099 --- /dev/null +++ b/src/Interactivity/load.php @@ -0,0 +1,5 @@ +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', 'woocommerce_interactivity_move_interactive_scripts_to_the_footer', 11 ); + +/** + * Register the Interactivity API runtime and make it available to be enqueued + * as a dependency in interactive blocks. + */ +function woocommerce_interactivity_register_runtime() { + $plugin_path = \Automattic\WooCommerce\Blocks\Package::get_path(); + $plugin_url = plugin_dir_url( $plugin_path . '/index.php' ); + + $file = 'build/wc-interactivity.js'; + + $file_path = $plugin_path . $file; + $file_url = $plugin_url . $file; + + if ( defined( 'SCRIPT_DEBUG' ) && SCRIPT_DEBUG && file_exists( $file_path ) ) { + $version = filemtime( $file_path ); + } else { + $version = \Automattic\WooCommerce\Blocks\Package::get_version(); + } + + wp_register_script( + 'wc-interactivity', + $file_url, + array(), + $version, + true + ); +} +add_action( 'wp_enqueue_scripts', 'woocommerce_interactivity_register_runtime' ); diff --git a/src/Interactivity/store.php b/src/Interactivity/store.php new file mode 100644 index 00000000000..0b141dd7408 --- /dev/null +++ b/src/Interactivity/store.php @@ -0,0 +1,19 @@ +'; - } -} -add_action( 'wp_head', 'woo_directives_add_client_side_navigation_meta_tag' ); - - -function woo_directives_mark_interactive_blocks( $block_content, $block, $instance ) { - if ( woo_directives_get_client_side_navigation() ) { - return $block_content; - } - - // Append the `data-woo-ignore` attribute for inner blocks of interactive blocks. - if ( isset( $instance->parsed_block['isolated'] ) ) { - $w = new WP_HTML_Tag_Processor( $block_content ); - $w->next_tag(); - $w->set_attribute( 'data-woo-ignore', true ); - $block_content = (string) $w; - } - - // Return if it's not interactive. - if ( ! block_has_support( $instance->block_type, array( 'interactivity' ) ) ) { - return $block_content; - } - - // Add the `data-woo-island` attribute if it's interactive. - $w = new WP_HTML_Tag_Processor( $block_content ); - $w->next_tag(); - $w->set_attribute( 'data-woo-island', true ); - - return (string) $w; -} -add_filter( 'render_block', 'woo_directives_mark_interactive_blocks', 10, 3 ); - -/** - * Add a flag to mark inner blocks of isolated interactive blocks. - */ -function woo_directives_inner_blocks( $parsed_block, $source_block, $parent_block ) { - if ( - isset( $parent_block ) && - block_has_support( - $parent_block->block_type, - array( - 'interactivity', - 'isolated', - ) - ) - ) { - $parsed_block['isolated'] = true; - } - return $parsed_block; -} -add_filter( 'render_block_data', 'woo_directives_inner_blocks', 10, 3 ); - -function woo_process_directives_in_block( $block_content ) { - $tag_directives = array( - 'woo-context' => 'process_woo_context_tag', - ); - - $attribute_directives = array( - 'data-woo-context' => 'process_woo_context_attribute', - 'data-woo-bind' => 'process_woo_bind', - 'data-woo-class' => 'process_woo_class', - 'data-woo-style' => 'process_woo_style', - ); - - $tags = new WP_HTML_Tag_Processor( $block_content ); - $tags = woo_process_directives( $tags, 'data-woo-', $tag_directives, $attribute_directives ); - return $tags->get_updated_html(); -} -add_filter( - 'render_block', - 'woo_process_directives_in_block', - 10, - 1 -); - -add_action( 'wp_footer', array( 'Woo_Directive_Store', 'render' ), 9 ); diff --git a/woocommerce-gutenberg-products-block.php b/woocommerce-gutenberg-products-block.php index 72bc2d691c1..0638184fd4b 100644 --- a/woocommerce-gutenberg-products-block.php +++ b/woocommerce-gutenberg-products-block.php @@ -314,7 +314,7 @@ function woocommerce_blocks_interactivity_setup() { ); if ( $is_enabled ) { - require_once __DIR__ . '/src/Interactivity/woo-directives.php'; + require_once __DIR__ . '/src/Interactivity/load.php'; } } add_action( 'plugins_loaded', 'woocommerce_blocks_interactivity_setup' );