diff --git a/block-hydration-experiments.php b/block-hydration-experiments.php index 616e8120..58401408 100644 --- a/block-hydration-experiments.php +++ b/block-hydration-experiments.php @@ -11,6 +11,8 @@ */ function block_hydration_experiments_init() { + wp_enqueue_script('vendors', plugin_dir_url(__FILE__) . 'build/vendors.js'); + wp_register_script( 'hydration', plugin_dir_url(__FILE__) . 'build/gutenberg-packages/hydration.js', @@ -116,7 +118,12 @@ function bhe_block_wrapper($block_content, $block, $instance) $template_wrapper, sprintf($block_wrapper, $block_content . $empty_template) ); - return sprintf($block_wrapper, $block_content); + + // The block content comes between two line breaks that seem to be included during block + // serialization, corresponding to those between the block markup and the block content. + // + // They need to be removed here; otherwise, the preact hydration fails. + return sprintf($block_wrapper, substr($block_content, 1, -1)); } add_filter('render_block', 'bhe_block_wrapper', 10, 3); diff --git a/src/blocks/interactive-child/view.js b/src/blocks/interactive-child/view.js index 06ff5bcb..cedc867b 100644 --- a/src/blocks/interactive-child/view.js +++ b/src/blocks/interactive-child/view.js @@ -1,6 +1,10 @@ +import CounterContext from '../../context/counter'; +import ThemeContext from '../../context/theme'; +import { useContext } from '../../gutenberg-packages/wordpress-element'; + const View = ({ blockProps, context }) => { - const theme = 'cool theme'; - const counter = 0; + const theme = useContext(ThemeContext); + const counter = useContext(CounterContext); return (
diff --git a/src/blocks/interactive-parent/edit.js b/src/blocks/interactive-parent/edit.js index 49a07e4b..9460b7d4 100644 --- a/src/blocks/interactive-parent/edit.js +++ b/src/blocks/interactive-parent/edit.js @@ -4,20 +4,22 @@ // the site. import '@wordpress/block-editor'; -import { InnerBlocks, useBlockProps } from '@wordpress/block-editor'; -import Button from './shared/button'; -import Title from './shared/title'; +import { InnerBlocks, useBlockProps, RichText } from '@wordpress/block-editor'; -const Edit = ({ attributes: { counter, title, secret }, setAttributes }) => ( +const Edit = ({ + attributes: { counter = 0, title, secret }, + setAttributes, +}) => ( <>
- setAttributes({ title: val })} placeholder="This will be passed through context to child blocks" - > - {title} - - + /> + diff --git a/src/blocks/interactive-parent/shared/button.js b/src/blocks/interactive-parent/shared/button.js deleted file mode 100644 index 2384a54a..00000000 --- a/src/blocks/interactive-parent/shared/button.js +++ /dev/null @@ -1,5 +0,0 @@ -const Button = ({ handler, children }) => { - return ; -}; - -export default Button; diff --git a/src/blocks/interactive-parent/shared/title.js b/src/blocks/interactive-parent/shared/title.js deleted file mode 100644 index 2c554e4b..00000000 --- a/src/blocks/interactive-parent/shared/title.js +++ /dev/null @@ -1,7 +0,0 @@ -const Title = ({ children, ...props }) => ( -

- {children} -

-); - -export default Title; diff --git a/src/blocks/interactive-parent/view.js b/src/blocks/interactive-parent/view.js index 00e525dc..68b4d7f9 100644 --- a/src/blocks/interactive-parent/view.js +++ b/src/blocks/interactive-parent/view.js @@ -1,9 +1,6 @@ -import { createContext, useState } from 'preact/compat'; -import Button from './shared/button'; -import Title from './shared/title'; - -const Counter = createContext(null); -const Theme = createContext(null); +import Counter from '../../context/counter'; +import Theme from '../../context/theme'; +import { useState } from '../../gutenberg-packages/wordpress-element'; const View = ({ blockProps: { @@ -27,9 +24,9 @@ const View = ({ fontWeight: bold ? 900 : fontWeight, }} > - {title} - - +

{title}

+ + diff --git a/src/blocks/non-interactive-parent/edit.js b/src/blocks/non-interactive-parent/edit.js index 6871ad27..566ae8f4 100644 --- a/src/blocks/non-interactive-parent/edit.js +++ b/src/blocks/non-interactive-parent/edit.js @@ -1,5 +1,4 @@ -import { InnerBlocks, useBlockProps } from '@wordpress/block-editor'; -import { RichText } from '../../gutenberg-packages/wordpress-blockeditor'; +import { InnerBlocks, useBlockProps, RichText } from '@wordpress/block-editor'; const Edit = ({ attributes, setAttributes }) => (
@@ -9,9 +8,7 @@ const Edit = ({ attributes, setAttributes }) => ( onChange={(val) => setAttributes({ title: val })} placeholder="This will be passed through context to child blocks" value={attributes.title} - > - {attributes.title} - + />
); diff --git a/src/blocks/non-interactive-parent/view.js b/src/blocks/non-interactive-parent/view.js index c381f3cb..2df201b5 100644 --- a/src/blocks/non-interactive-parent/view.js +++ b/src/blocks/non-interactive-parent/view.js @@ -1,6 +1,6 @@ const View = ({ attributes, blockProps, children }) => (
-

{attributes.title}

+

{attributes.title}

{children}
); diff --git a/src/context/counter.js b/src/context/counter.js index 6061f470..388f8aca 100644 --- a/src/context/counter.js +++ b/src/context/counter.js @@ -1,7 +1,11 @@ -import { createContext } from '@wordpress/element'; +import { createContext } from 'preact/compat'; -if (typeof window.reactContext === 'undefined') { - window.reactContext = createContext(null); +if (typeof window.counterContext === 'undefined') { + window.counterContext = window.wp.element + ? window.wp.element.createContext(null) + : createContext(null); + + window.counterContext.displayName = 'CounterContext'; } -window.reactContext.displayName = 'CounterContext'; -export default window.reactContext; + +export default window.counterContext; diff --git a/src/context/theme.js b/src/context/theme.js index 8aefc8ec..331c1942 100644 --- a/src/context/theme.js +++ b/src/context/theme.js @@ -1,7 +1,11 @@ -import { createContext } from '@wordpress/element'; +import { createContext } from 'preact/compat'; -if (typeof window.themeReactContext === 'undefined') { - window.themeReactContext = createContext(null); +if (typeof window.themeContext === 'undefined') { + window.themeContext = window.wp.element + ? window.wp.element.createContext('initial') + : createContext('initial'); + + window.themeContext.displayName = 'ThemeContext'; } -window.themeReactContext.displayName = 'ThemeContext'; -export default window.themeReactContext; + +export default window.themeContext; diff --git a/src/gutenberg-packages/hydration.js b/src/gutenberg-packages/hydration.js index 0402b02f..b0f26a1d 100644 --- a/src/gutenberg-packages/hydration.js +++ b/src/gutenberg-packages/hydration.js @@ -1,3 +1,17 @@ +import { hydrate, createElement } from 'preact/compat'; import { createGlobal } from './utils'; +import toVdom from './to-vdom'; +import visitor from './visitor'; const blockViews = createGlobal('blockViews', new Map()); + +const components = Object.fromEntries( + [...blockViews.entries()].map(([k, v]) => [k, v.Component]) +); + +visitor.map = components; + +const dom = document.querySelector('.wp-site-blocks'); +const vdom = toVdom(dom, visitor, createElement).props.children; + +setTimeout(() => console.log('hydrated', hydrate(vdom, dom)), 3000); diff --git a/src/gutenberg-packages/to-vdom.js b/src/gutenberg-packages/to-vdom.js new file mode 100644 index 00000000..abe86eb6 --- /dev/null +++ b/src/gutenberg-packages/to-vdom.js @@ -0,0 +1,41 @@ +export default function toVdom(node, visitor, h) { + walk.visitor = visitor; + walk.h = h; + return walk(node); +} + +function walk(n) { + if (n.nodeType === 3) return n.data; + if (n.nodeType !== 1) return null; + let nodeName = String(n.nodeName).toLowerCase(); + + // Do not allow script tags (for now). + if (nodeName === 'script') return null; + + let out = walk.h( + nodeName, + getProps(n.attributes), + walkChildren(n.childNodes) + ); + if (walk.visitor) walk.visitor(out, n); + + return out; +} + +function getProps(attrs) { + let len = attrs && attrs.length; + if (!len) return null; + let props = {}; + for (let i = 0; i < len; i++) { + let { name, value } = attrs[i]; + props[name] = value; + } + return props; +} + +function walkChildren(children) { + let c = children && Array.prototype.map.call(children, walk).filter(exists); + return c && c.length ? c : null; +} + +let exists = (x) => x; diff --git a/src/gutenberg-packages/visitor.js b/src/gutenberg-packages/visitor.js new file mode 100644 index 00000000..ebe737d9 --- /dev/null +++ b/src/gutenberg-packages/visitor.js @@ -0,0 +1,100 @@ +import { h } from "preact"; +import { matcherFromSource } from './utils'; + +export default function visitor(vNode, domNode) { + const name = (vNode.type || '').toLowerCase(); + const map = visitor.map; + + if (name === 'wp-block' && map) { + processWpBlock({ vNode, domNode, map }); + } else { + vNode.type = name.replace(/[^a-z0-9-]/i, ''); + } +} + +function processWpBlock({ vNode, domNode, map }) { + const blockType = vNode.props['data-wp-block-type']; + const Component = map[blockType]; + + if (!Component) return vNode; + + const block = h(Component, { + attributes: getAttributes(vNode, domNode), + context: {}, + blockProps: getBlockProps(vNode), + children: getChildren(vNode), + }); + + vNode.props = { + ...vNode.props, + children: [block] + }; +} + +function getBlockProps(vNode) { + const { class: className, style } = JSON.parse( + vNode.props['data-wp-block-props'] + ); + return { className, style: getStyleProp(style) }; +} + +function getAttributes(vNode, domNode) { + // Get the block attributes. + const attributes = JSON.parse( + vNode.props['data-wp-block-attributes'] + ); + + // Add the sourced attributes to the attributes object. + const sourcedAttributes = JSON.parse( + vNode.props['data-wp-block-sourced-attributes'] + ); + for (const attr in sourcedAttributes) { + attributes[attr] = matcherFromSource(sourcedAttributes[attr])( + domNode + ); + } + + return attributes; +} + +function getChildren(vNode) { + return getChildrenFromWrapper(vNode.props.children) || vNode.props.children; +} + +function getChildrenFromWrapper(children) { + if (!children?.length) return null; + + for (const child of children) { + if (isChildrenWrapper(child)) return [child] || []; + } + + // Try with the next nesting level. + return getChildrenFromWrapper( + [].concat(...children.map((child) => child?.props?.children || [])) + ); +} + +function isChildrenWrapper(vNode) { + return vNode.type === 'wp-inner-blocks'; +} + +function toCamelCase(name) { + return name.replace(/-(.)/g, (match, letter) => letter.toUpperCase()); +} + +export function getStyleProp(cssText) { + if (!cssText) return {}; + + const el = document.createElement('div'); + const { style } = el; + style.cssText = cssText; + + const output = {}; + for (let i = 0; i < style.length; i += 1) { + const key = style.item(0); + output[toCamelCase(key)] = style.getPropertyValue(key); + } + + el.remove(); + return output; +} \ No newline at end of file diff --git a/src/gutenberg-packages/wordpress-element.js b/src/gutenberg-packages/wordpress-element.js index 17135d9a..0201d3a2 100644 --- a/src/gutenberg-packages/wordpress-element.js +++ b/src/gutenberg-packages/wordpress-element.js @@ -1,28 +1,26 @@ import { createContext, - useContext as useReactContext, - useEffect as useReactEffect, - useState as useReactState, -} from '@wordpress/element'; + useContext as usePreactContext, + useEffect as usePreactEffect, + useState as usePreactState, +} from 'preact/compat'; -export const EnvContext = createContext(null); +export const EnvContext = createContext('view'); /** * A React hook that returns the name of the environment. * - * This is still a bit hacky. Ideally, Save components should support React - * hooks and all the environments (Edit, Save and View) should populate a - * normal context. Also, more environments could be added in the future. + * Based on the workaround used for the Island Hydration approach, but only to differentiate between + * Save and View, so this function and related hooks cannot be used inside Edit. + * + * Note that the other approach was a bit hacky; this is a bit more hacky. * - * @returns {"edit" | "save" | "view"} + * @returns {"save" | "view"} */ export const useBlockEnvironment = () => { try { - const env = useReactContext(EnvContext); - if (env === 'view') { - return 'view'; - } - return 'edit'; + // This will fail if the hook runs inside something that's not a Preact component. + return usePreactContext(EnvContext); } catch (e) { return 'save'; } @@ -30,13 +28,13 @@ export const useBlockEnvironment = () => { const noop = () => {}; -export const useState = (init) => - useBlockEnvironment() !== 'save' ? useReactState(init) : [init, noop]; +export const useState = (init) => + useBlockEnvironment() !== 'save' ? usePreactState(init) : [init, noop]; export const useEffect = (...args) => - useBlockEnvironment() !== 'save' ? useReactEffect(...args) : noop; + useBlockEnvironment() !== 'save' ? usePreactEffect(...args) : noop; export const useContext = (Context) => useBlockEnvironment() !== 'save' - ? useReactContext(Context) - : Context._currentValue; + ? usePreactContext(Context) + : Context._currentValue; \ No newline at end of file diff --git a/webpack.config.js b/webpack.config.js index f70789a4..2674dcc3 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -4,15 +4,6 @@ module.exports = [ defaultConfig, { ...defaultConfig, - resolve: { - alias: { - '@wordpress/element': 'preact/compat', - react: 'preact/compat', - 'react-dom/test-utils': 'preact/test-utils', - 'react-dom': 'preact/compat', // Must be below test-utils - 'react/jsx-runtime': 'preact/jsx-runtime', - }, - }, entry: { 'gutenberg-packages/hydration': './src/gutenberg-packages/hydration.js', @@ -21,6 +12,20 @@ module.exports = [ 'blocks/interactive-parent/register-view': './src/blocks/interactive-parent/register-view.js', }, + optimization: { + runtimeChunk: { + name: 'vendors', + }, + splitChunks: { + cacheGroups: { + vendor: { + test: /[\\/]node_modules[\\/]/, + name: 'vendors', + chunks: 'all', + }, + }, + }, + }, module: { rules: [ ...defaultConfig.module.rules,