From 910571882f71bfdc000be234a7e64ebd34aad85a Mon Sep 17 00:00:00 2001 From: David Arenas Date: Fri, 5 May 2023 11:39:05 +0200 Subject: [PATCH] Add Interactivity API runtime (#49994) * Add interactivity runtime Co-authored-by: David Arenas * Add it to the image block This is still pretty basic, just to check that it works. Co-authored-by: David Arenas * Add a separate webpack config Co-authored-by: David Arenas * Make sure the runtime is imported only once Co-authored-by: David Arenas * Use sideEffects instead of init Co-authored-by: David Arenas * Move script registration to a general file Co-authored-by: David Arenas * Add `defer` to the interactivity scripts Co-authored-by: David Arenas * Revert changes of the image block Co-authored-by: David Arenas * Fix init import name Co-authored-by: David Arenas * Move and refactor the interactive scritps registration * Fix code style violations * Use `wp-interactivity-` prefix for script handles * Improve the matcher for side effects in `package.json` * Add custom useSignalEffect * Call `init` after `store` has been initialized * Update lib/experimental/interactivity-api/script-loader.php Co-authored-by: Weston Ruter * Plugin: Ensure that translations are set correctly when overriding scripts This replicates the same logic in WordPress core: https://github.com/WordPress/wordpress-develop/blob/a5f3087f6b5d9c52dbe67ed247165dc32427c57d/src/wp-includes/script-loader.php#L306-L308 --------- Co-authored-by: Luis Herranz Co-authored-by: Grzegorz Ziolkowski Co-authored-by: Weston Ruter --- lib/client-assets.php | 12 +- .../interactivity-api/script-loader.php | 50 +++ lib/load.php | 1 + package-lock.json | 64 +++- packages/block-library/package.json | 6 +- .../src/utils/interactivity/constants.js | 1 + .../src/utils/interactivity/directives.js | 179 ++++++++++ .../src/utils/interactivity/hooks.js | 76 +++++ .../src/utils/interactivity/hydration.js | 22 ++ .../src/utils/interactivity/index.js | 17 + .../src/utils/interactivity/store.js | 45 +++ .../src/utils/interactivity/utils.js | 66 ++++ .../src/utils/interactivity/vdom.js | 94 ++++++ tools/webpack/blocks.js | 305 +++++++++++------- webpack.config.js | 2 +- 15 files changed, 801 insertions(+), 139 deletions(-) create mode 100644 lib/experimental/interactivity-api/script-loader.php create mode 100644 packages/block-library/src/utils/interactivity/constants.js create mode 100644 packages/block-library/src/utils/interactivity/directives.js create mode 100644 packages/block-library/src/utils/interactivity/hooks.js create mode 100644 packages/block-library/src/utils/interactivity/hydration.js create mode 100644 packages/block-library/src/utils/interactivity/index.js create mode 100644 packages/block-library/src/utils/interactivity/store.js create mode 100644 packages/block-library/src/utils/interactivity/utils.js create mode 100644 packages/block-library/src/utils/interactivity/vdom.js diff --git a/lib/client-assets.php b/lib/client-assets.php index 9c0483ea539b93..9757e4b7ff24a8 100644 --- a/lib/client-assets.php +++ b/lib/client-assets.php @@ -77,16 +77,8 @@ function gutenberg_override_script( $scripts, $handle, $src, $deps = array(), $v $scripts->add( $handle, $src, $deps, $ver, ( $in_footer ? 1 : null ) ); } - /* - * `WP_Dependencies::set_translations` will fall over on itself if setting - * translations on the `wp-i18n` handle, since it internally adds `wp-i18n` - * as a dependency of itself, exhausting memory. The same applies for the - * polyfill and hooks scripts, which are dependencies _of_ `wp-i18n`. - * - * See: https://core.trac.wordpress.org/ticket/46089 - */ - if ( ! in_array( $handle, array( 'wp-i18n', 'wp-polyfill', 'wp-hooks' ), true ) ) { - $scripts->set_translations( $handle, 'default' ); + if ( in_array( 'wp-i18n', $deps, true ) ) { + $scripts->set_translations( $handle ); } /* diff --git a/lib/experimental/interactivity-api/script-loader.php b/lib/experimental/interactivity-api/script-loader.php new file mode 100644 index 00000000000000..924dc196f10d26 --- /dev/null +++ b/lib/experimental/interactivity-api/script-loader.php @@ -0,0 +1,50 @@ +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 ); diff --git a/lib/load.php b/lib/load.php index 65b731de4b9ac5..ac4941cb0e96a5 100644 --- a/lib/load.php +++ b/lib/load.php @@ -98,6 +98,7 @@ function gutenberg_is_experiment_enabled( $name ) { require __DIR__ . '/experimental/block-editor-settings-mobile.php'; require __DIR__ . '/experimental/block-editor-settings.php'; require __DIR__ . '/experimental/blocks.php'; +require __DIR__ . '/experimental/interactivity-api/script-loader.php'; require __DIR__ . '/experimental/navigation-theme-opt-in.php'; require __DIR__ . '/experimental/kses.php'; require __DIR__ . '/experimental/l10n.php'; diff --git a/package-lock.json b/package-lock.json index 0b2f6bca0197ae..1553d82a365d5b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7095,6 +7095,28 @@ "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.7.tgz", "integrity": "sha512-Cr4OjIkipTtcXKjAsm8agyleBuDHvxzeBoa1v543lbv1YaIwQjESsVcmjiWiPEbC1FIeHOG/Op9kdCmAmiS3Kw==" }, + "@preact/signals": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@preact/signals/-/signals-1.1.3.tgz", + "integrity": "sha512-N09DuAVvc90bBZVRwD+aFhtGyHAmJLhS3IFoawO/bYJRcil4k83nBOchpCEoS0s5+BXBpahgp0Mjf+IOqP57Og==", + "requires": { + "@preact/signals-core": "^1.2.3" + } + }, + "@preact/signals-core": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@preact/signals-core/-/signals-core-1.3.0.tgz", + "integrity": "sha512-M+M3ZOtd1dtV/uasyk4SZu1vbfEJ4NeENv0F7F12nijZYedB5wSgbtZcuACyssnTznhF4ctUyrR0dZHuHfyWKA==" + }, + "@preact/signals-react": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@preact/signals-react/-/signals-react-1.3.1.tgz", + "integrity": "sha512-YHWoGAT2Mmv2OGlGx7CCCbaLjAH/InV9ytGAR+esX8Y0HJmMAw51QlqGYOD5GPA5LwimV7Ht1x7KEIegDZIoxg==", + "requires": { + "@preact/signals-core": "^1.3.0", + "use-sync-external-store": "^1.2.0" + } + }, "@radix-ui/primitive": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.0.0.tgz", @@ -16862,6 +16884,7 @@ "version": "file:packages/block-library", "requires": { "@babel/runtime": "^7.16.0", + "@preact/signals": "^1.1.3", "@wordpress/a11y": "file:packages/a11y", "@wordpress/api-fetch": "file:packages/api-fetch", "@wordpress/autop": "file:packages/autop", @@ -16894,12 +16917,14 @@ "change-case": "^4.1.2", "classnames": "^2.3.1", "colord": "^2.7.0", + "deepsignal": "^1.3.0", "escape-html": "^1.0.3", "fast-average-color": "^9.1.1", "fast-deep-equal": "^3.1.3", "lodash": "^4.17.21", "memize": "^1.1.0", "micromodal": "^0.4.10", + "preact": "^10.13.2", "remove-accents": "^0.4.2" } }, @@ -25510,7 +25535,7 @@ "array-ify": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/array-ify/-/array-ify-1.0.0.tgz", - "integrity": "sha1-nlKHYrSpBmrRY6aWKjZEGOlibs4=", + "integrity": "sha512-c5AMf34bKdvPhQ7tBGhqkgKNUzMr4WUs+WDtC2ZUGOUncbxKMTvqxYctiseW3+L4bA8ec+GcZ6/A/FW4m8ukng==", "dev": true }, "array-includes": { @@ -30679,7 +30704,7 @@ "debuglog": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/debuglog/-/debuglog-1.0.1.tgz", - "integrity": "sha1-qiT/uaw9+aI1GDfPstJ5NgzXhJI=", + "integrity": "sha512-syBZ+rnAK3EgMsH2aYEOLUW7mZSY9Gb+0wUMCFsZvcmiz+HigA0LOcq/HoQqVuGG+EKykunc7QG2bzrponfaSw==", "dev": true }, "decache": { @@ -30794,6 +30819,16 @@ "integrity": "sha512-FJ3UgI4gIl+PHZm53knsuSFpE+nESMr7M4v9QcgB7S63Kj/6WqMiFQJpBBYz1Pt+66bZpP3Q7Lye0Oo9MPKEdg==", "dev": true }, + "deepsignal": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/deepsignal/-/deepsignal-1.3.0.tgz", + "integrity": "sha512-tMh3g3F7Ka6gKALqu7uOzi/3Xm00mGWgWR3hp1GUzGGnTz2J926ime5aOe1haz233v4encyjTkZESr5R6hr8oQ==", + "requires": { + "@preact/signals": "^1.0.0", + "@preact/signals-core": "^1.0.0", + "@preact/signals-react": "^1.0.0" + } + }, "default-browser-id": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/default-browser-id/-/default-browser-id-1.0.4.tgz", @@ -35511,7 +35546,7 @@ "git-remote-origin-url": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/git-remote-origin-url/-/git-remote-origin-url-2.0.0.tgz", - "integrity": "sha1-UoJlna4hBxRaERJhEq0yFuxfpl8=", + "integrity": "sha512-eU+GGrZgccNJcsDH5LkXR3PB9M958hxc7sbA8DFJjrv9j4L2P/eZfKhM+QD6wyzpiv+b1BpK0XrYCxkovtjSLw==", "dev": true, "requires": { "gitconfiglocal": "^1.0.0", @@ -35558,7 +35593,7 @@ "gitconfiglocal": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/gitconfiglocal/-/gitconfiglocal-1.0.0.tgz", - "integrity": "sha1-QdBF84UaXqiPA/JMocYXgRRGS5s=", + "integrity": "sha512-spLUXeTAVHxDtKsJc8FkFVgFtMdEN9qPGpL23VfSHx4fP4+Ds097IXLvymbnDH8FnmxX5Nr9bPw3A+AQ6mWEaQ==", "dev": true, "requires": { "ini": "^1.3.2" @@ -36832,7 +36867,7 @@ "humanize-ms": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/humanize-ms/-/humanize-ms-1.2.1.tgz", - "integrity": "sha1-xG4xWaKT9riW2ikxbYtv6Lt5u+0=", + "integrity": "sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==", "dev": true, "requires": { "ms": "^2.0.0" @@ -37848,7 +37883,7 @@ "is-text-path": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/is-text-path/-/is-text-path-1.0.1.tgz", - "integrity": "sha1-Thqg+1G/vLPpJogAE5cgLBd1tm4=", + "integrity": "sha512-xFuJpne9oFz5qDaodwmmG08e3CawH/2ZV8Qqza1Ko7Sk8POWbkRdwIoAWVhqvq0XeUzANEhKo2n0IXUGBm7A/w==", "dev": true, "requires": { "text-extensions": "^1.0.0" @@ -39621,7 +39656,7 @@ "jsonparse": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/jsonparse/-/jsonparse-1.3.1.tgz", - "integrity": "sha1-P02uSpH6wxX3EGL4UhzCOfE2YoA=", + "integrity": "sha512-POQXvpdL69+CluYsillJ7SUhKvytYjW9vG/GKpnf+xP8UWgYEM/RaMzHHofbALDiKbbP1W8UEYmgGl39WkPZsg==", "dev": true }, "jsprim": { @@ -40721,7 +40756,7 @@ "lodash.ismatch": { "version": "4.4.0", "resolved": "https://registry.npmjs.org/lodash.ismatch/-/lodash.ismatch-4.4.0.tgz", - "integrity": "sha1-dWy1FQyjum8RCFp4hJZF8Yj4Xzc=", + "integrity": "sha512-fPMfXjGQEV9Xsq/8MTSgUf255gawYRbjwMyDbcvDhXgV7enSZA0hynz6vMPnpAb5iONEzBHBPsT+0zes5Z301g==", "dev": true }, "lodash.isplainobject": { @@ -47601,6 +47636,11 @@ "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-3.3.1.tgz", "integrity": "sha512-pISE66AbVkp4fDQ7VHBwRNXzAAKJjw4Vw7nWI/+Q3vuly7SNfgYXvm6i5IgFylHGK5sP/xHAbB7N49OS4gWNyQ==" }, + "preact": { + "version": "10.13.2", + "resolved": "https://registry.npmjs.org/preact/-/preact-10.13.2.tgz", + "integrity": "sha512-q44QFLhOhty2Bd0Y46fnYW0gD/cbVM9dUVtNTDKPcdXSMA7jfY+Jpd6rk3GB0lcQss0z5s/6CmVP0Z/hV+g6pw==" + }, "prelude-ls": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.1.2.tgz", @@ -48219,7 +48259,7 @@ "promzard": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/promzard/-/promzard-0.3.0.tgz", - "integrity": "sha1-JqXW7ox97kyxIggwWs+5O6OCqe4=", + "integrity": "sha512-JZeYqd7UAcHCwI+sTOeUDYkvEU+1bQ7iE0UT1MgB/tERkAPkesW46MrpIySzODi+owTjZtiF8Ay5j9m60KmMBw==", "dev": true, "requires": { "read": "1" @@ -48253,7 +48293,7 @@ "proto-list": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/proto-list/-/proto-list-1.2.4.tgz", - "integrity": "sha1-IS1b/hMYMGpCD2QCuOJv85ZHqEk=", + "integrity": "sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA==", "dev": true }, "protocols": { @@ -49810,7 +49850,7 @@ "read": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/read/-/read-1.0.7.tgz", - "integrity": "sha1-s9oZvQUkMal2cdRKQmNK33ELQMQ=", + "integrity": "sha512-rSOKNYUmaxy0om1BNjMN4ezNT6VKK+2xF4GBhc81mkH7L60i6dp8qPYrkndNLT3QPphoII3maL9PVC9XmhHwVQ==", "dev": true, "requires": { "mute-stream": "~0.0.4" @@ -55167,7 +55207,7 @@ "temp-dir": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/temp-dir/-/temp-dir-1.0.0.tgz", - "integrity": "sha1-CnwOom06Oa+n4OvqnB/AvE2qAR0=", + "integrity": "sha512-xZFXEGbG7SNC3itwBzI3RYjq/cEhBkx2hJuKGIUOcEULmkQExXiHat2z/qkISYsuR+IKumhEfKKbV5qXmhICFQ==", "dev": true }, "terminal-link": { diff --git a/packages/block-library/package.json b/packages/block-library/package.json index 9a34e8ae084fa7..3b6ab1f0140804 100644 --- a/packages/block-library/package.json +++ b/packages/block-library/package.json @@ -27,10 +27,12 @@ "sideEffects": [ "build-style/**", "src/**/*.scss", - "{src,build,build-module}/*/init.js" + "{src,build,build-module}/*/init.js", + "{src,build,build-module}/utils/interactivity/index.js" ], "dependencies": { "@babel/runtime": "^7.16.0", + "@preact/signals": "^1.1.3", "@wordpress/a11y": "file:../a11y", "@wordpress/api-fetch": "file:../api-fetch", "@wordpress/autop": "file:../autop", @@ -63,12 +65,14 @@ "change-case": "^4.1.2", "classnames": "^2.3.1", "colord": "^2.7.0", + "deepsignal": "^1.3.0", "escape-html": "^1.0.3", "fast-average-color": "^9.1.1", "fast-deep-equal": "^3.1.3", "lodash": "^4.17.21", "memize": "^1.1.0", "micromodal": "^0.4.10", + "preact": "^10.13.2", "remove-accents": "^0.4.2" }, "peerDependencies": { diff --git a/packages/block-library/src/utils/interactivity/constants.js b/packages/block-library/src/utils/interactivity/constants.js new file mode 100644 index 00000000000000..f462753c9f8179 --- /dev/null +++ b/packages/block-library/src/utils/interactivity/constants.js @@ -0,0 +1 @@ +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 new file mode 100644 index 00000000000000..d7583fa8eaa33e --- /dev/null +++ b/packages/block-library/src/utils/interactivity/directives.js @@ -0,0 +1,179 @@ +/** + * 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 }; + } + ); + + // 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; + + useEffect( () => { + // 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. + if ( result === false ) { + element.ref.current.removeAttribute( attribute ); + } else { + element.ref.current.setAttribute( + attribute, + result === true ? '' : 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 new file mode 100644 index 00000000000000..ca3bd20964d511 --- /dev/null +++ b/packages/block-library/src/utils/interactivity/hooks.js @@ -0,0 +1,76 @@ +/** + * External dependencies + */ +import { h, options, createContext } 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 = {}; +export const directive = ( name, cb ) => { + directiveMap[ name ] = cb; +}; + +// Resolve the path to some property of the store object. +const resolve = ( path, ctx ) => { + // If path starts with !, remove it and save a flag. + const hasNegationOperator = + path[ 0 ] === '!' && !! ( path = path.slice( 1 ) ); + let current = { ...store, context: ctx }; + path.split( '.' ).forEach( ( p ) => ( current = current[ p ] ) ); + return hasNegationOperator ? ! current : current; +}; + +// Generate the evaluate function. +const getEvaluate = + ( { ref } = {} ) => + ( path, extraArgs = {} ) => { + const value = resolve( path, extraArgs.context ); + return typeof value === 'function' + ? value( { + ref: ref.current, + ...store, + ...extraArgs, + } ) + : value; + }; + +// Directive wrapper. +const Directive = ( { type, directives, props: originalProps } ) => { + const ref = useRef( null ); + const element = h( type, { ...originalProps, ref } ); + const props = { ...originalProps, children: element }; + const evaluate = useMemo( () => getEvaluate( { ref } ), [] ); + 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 new file mode 100644 index 00000000000000..2fc34eeb64b9b5 --- /dev/null +++ b/packages/block-library/src/utils/interactivity/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/block-library/src/utils/interactivity/index.js b/packages/block-library/src/utils/interactivity/index.js new file mode 100644 index 00000000000000..6dbac1a45e88ca --- /dev/null +++ b/packages/block-library/src/utils/interactivity/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/block-library/src/utils/interactivity/store.js b/packages/block-library/src/utils/interactivity/store.js new file mode 100644 index 00000000000000..d11af901352017 --- /dev/null +++ b/packages/block-library/src/utils/interactivity/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/block-library/src/utils/interactivity/utils.js b/packages/block-library/src/utils/interactivity/utils.js new file mode 100644 index 00000000000000..21d15da2f94ff9 --- /dev/null +++ b/packages/block-library/src/utils/interactivity/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/block-library/src/utils/interactivity/vdom.js b/packages/block-library/src/utils/interactivity/vdom.js new file mode 100644 index 00000000000000..07640319b88a8a --- /dev/null +++ b/packages/block-library/src/utils/interactivity/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 ); +} diff --git a/tools/webpack/blocks.js b/tools/webpack/blocks.js index f2c30984141fb2..35081eb3b12dda 100644 --- a/tools/webpack/blocks.js +++ b/tools/webpack/blocks.js @@ -74,134 +74,209 @@ const createEntrypoints = () => { }, {} ); }; -module.exports = { - ...baseConfig, - name: 'blocks', - entry: createEntrypoints(), - output: { - devtoolNamespace: 'wp', - filename: './build/block-library/blocks/[name].min.js', - path: join( __dirname, '..', '..' ), - }, - plugins: [ - ...plugins, - new DependencyExtractionWebpackPlugin( { injectPolyfill: false } ), - new CopyWebpackPlugin( { - patterns: [].concat( - [ - 'style', - 'style-rtl', - 'editor', - 'editor-rtl', - 'theme', - 'theme-rtl', - ].map( ( filename ) => ( { - from: `./packages/block-library/build-style/*/${ filename }.css`, - to( { absoluteFilename } ) { - const [ , dirname ] = absoluteFilename.match( - new RegExp( - `([\\w-]+)${ escapeRegExp( - sep - ) }${ filename }\\.css$` - ) - ); - - return join( - 'build/block-library/blocks', - dirname, - filename + '.css' - ); - }, - transform: stylesTransform, - } ) ), - Object.entries( { - './packages/block-library/src/': - 'build/block-library/blocks/', - './packages/edit-widgets/src/blocks/': - 'build/edit-widgets/blocks/', - './packages/widgets/src/blocks/': 'build/widgets/blocks/', - } ).flatMap( ( [ from, to ] ) => [ - { - from: `${ from }/**/index.php`, +module.exports = [ + { + ...baseConfig, + name: 'blocks', + entry: createEntrypoints(), + output: { + devtoolNamespace: 'wp', + filename: './build/block-library/blocks/[name].min.js', + path: join( __dirname, '..', '..' ), + }, + plugins: [ + ...plugins, + new DependencyExtractionWebpackPlugin( { injectPolyfill: false } ), + new CopyWebpackPlugin( { + patterns: [].concat( + [ + 'style', + 'style-rtl', + 'editor', + 'editor-rtl', + 'theme', + 'theme-rtl', + ].map( ( filename ) => ( { + from: `./packages/block-library/build-style/*/${ filename }.css`, to( { absoluteFilename } ) { const [ , dirname ] = absoluteFilename.match( new RegExp( `([\\w-]+)${ escapeRegExp( sep - ) }index\\.php$` + ) }${ filename }\\.css$` ) ); - return join( to, `${ dirname }.php` ); + return join( + 'build/block-library/blocks', + dirname, + filename + '.css' + ); }, - transform: ( content ) => { - const prefix = 'gutenberg_'; - content = content.toString(); + transform: stylesTransform, + } ) ), + Object.entries( { + './packages/block-library/src/': + 'build/block-library/blocks/', + './packages/edit-widgets/src/blocks/': + 'build/edit-widgets/blocks/', + './packages/widgets/src/blocks/': + 'build/widgets/blocks/', + } ).flatMap( ( [ from, to ] ) => [ + { + from: `${ from }/**/index.php`, + to( { absoluteFilename } ) { + const [ , dirname ] = absoluteFilename.match( + new RegExp( + `([\\w-]+)${ escapeRegExp( + sep + ) }index\\.php$` + ) + ); - // Within content, search and prefix any function calls from - // `prefixFunctions` list. This is needed because some functions - // are called inside block files, but have been declared elsewhere. - // So with the rename we can call Gutenberg override functions, but the - // block will still call the core function when updates are back ported. - content = content.replace( - new RegExp( prefixFunctions.join( '|' ), 'g' ), - ( match ) => - `${ prefix }${ match.replace( - /^wp_/, - '' - ) }` - ); + return join( to, `${ dirname }.php` ); + }, + transform: ( content ) => { + const prefix = 'gutenberg_'; + content = content.toString(); - // Within content, search for any function definitions. For - // each, replace every other reference to it in the file. - return ( - Array.from( - content.matchAll( - /^\s*function ([^\(]+)/gm - ) - ) - .reduce( ( result, [ , functionName ] ) => { - // Prepend the Gutenberg prefix, substituting any - // other core prefix (e.g. "wp_"). - return result.replace( - new RegExp( functionName, 'g' ), - ( match ) => - prefix + - match.replace( /^wp_/, '' ) - ); - }, content ) - // The core blocks override procedure takes place in - // the init action default priority to ensure that core - // blocks would have been registered already. Since the - // blocks implementations occur at the default priority - // and due to WordPress hooks behavior not considering - // mutations to the same priority during another's - // callback, the Gutenberg build blocks are modified - // to occur at a later priority. - .replace( - /(add_action\(\s*'init',\s*'gutenberg_register_block_[^']+'(?!,))/, - '$1, 20' + // Within content, search and prefix any function calls from + // `prefixFunctions` list. This is needed because some functions + // are called inside block files, but have been declared elsewhere. + // So with the rename we can call Gutenberg override functions, but the + // block will still call the core function when updates are back ported. + content = content.replace( + new RegExp( + prefixFunctions.join( '|' ), + 'g' + ), + ( match ) => + `${ prefix }${ match.replace( + /^wp_/, + '' + ) }` + ); + + // Within content, search for any function definitions. For + // each, replace every other reference to it in the file. + return ( + Array.from( + content.matchAll( + /^\s*function ([^\(]+)/gm + ) ) - ); + .reduce( + ( result, [ , functionName ] ) => { + // Prepend the Gutenberg prefix, substituting any + // other core prefix (e.g. "wp_"). + return result.replace( + new RegExp( + functionName, + 'g' + ), + ( match ) => + prefix + + match.replace( + /^wp_/, + '' + ) + ); + }, + content + ) + // The core blocks override procedure takes place in + // the init action default priority to ensure that core + // blocks would have been registered already. Since the + // blocks implementations occur at the default priority + // and due to WordPress hooks behavior not considering + // mutations to the same priority during another's + // callback, the Gutenberg build blocks are modified + // to occur at a later priority. + .replace( + /(add_action\(\s*'init',\s*'gutenberg_register_block_[^']+'(?!,))/, + '$1, 20' + ) + ); + }, + noErrorOnMissing: true, }, - noErrorOnMissing: true, - }, - { - from: `${ from }/*/block.json`, - to( { absoluteFilename } ) { - const [ , dirname ] = absoluteFilename.match( - new RegExp( - `([\\w-]+)${ escapeRegExp( - sep - ) }block\\.json$` - ) - ); + { + from: `${ from }/*/block.json`, + to( { absoluteFilename } ) { + const [ , dirname ] = absoluteFilename.match( + new RegExp( + `([\\w-]+)${ escapeRegExp( + sep + ) }block\\.json$` + ) + ); - return join( to, dirname, 'block.json' ); + return join( to, dirname, 'block.json' ); + }, }, + ] ) + ), + } ), + ].filter( Boolean ), + }, + { + entry: { + // blockname: './packages/block-library/src/blockname/interactivity.js', + }, + output: { + devtoolNamespace: 'wp', + filename: './build/block-library/interactive-blocks/[name].min.js', + path: join( __dirname, '..', '..' ), + }, + optimization: { + runtimeChunk: { + name: 'vendors', + }, + splitChunks: { + cacheGroups: { + vendors: { + name: 'vendors', + test: /[\\/]node_modules[\\/]/, + minSize: 0, + chunks: 'all', }, - ] ) - ), - } ), - ].filter( Boolean ), -}; + interactivity: { + name: 'interactivity', + test: /[\\/]utils\/interactivity[\\/]/, + 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', + }, + ], + ], + }, + }, + ], + }, + ], + }, + }, +]; diff --git a/webpack.config.js b/webpack.config.js index 9a29ed7782268d..f1c5ce803adc1b 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -5,4 +5,4 @@ const blocksConfig = require( './tools/webpack/blocks' ); const developmentConfigs = require( './tools/webpack/development' ); const packagesConfig = require( './tools/webpack/packages' ); -module.exports = [ blocksConfig, packagesConfig, ...developmentConfigs ]; +module.exports = [ ...blocksConfig, packagesConfig, ...developmentConfigs ];