Skip to content
This repository has been archived by the owner on Jul 28, 2023. It is now read-only.

⚛️ Hydrate the blocks with Directives Hydration (using wp-block and wp-inner-blocks) #66

Merged
merged 20 commits into from
Sep 13, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion block-hydration-experiments.php
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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));
Comment on lines +122 to +126
Copy link
Collaborator Author

@DAreRodz DAreRodz Sep 13, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've left a note here explaining the fix for the Preact hydration.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should be fixed in Gutenberg itself.

}

add_filter('render_block', 'bhe_block_wrapper', 10, 3);
8 changes: 6 additions & 2 deletions src/blocks/interactive-child/view.js
Original file line number Diff line number Diff line change
@@ -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 (
<div {...blockProps}>
Expand Down
20 changes: 11 additions & 9 deletions src/blocks/interactive-parent/edit.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}) => (
<>
<div {...useBlockProps()}>
<Title
<RichText
tagName="h2"
className="title"
value={title}
onChange={(val) => setAttributes({ title: val })}
placeholder="This will be passed through context to child blocks"
>
{title}
</Title>
<Button>Show</Button>
/>
<button>Show</button>
<button onClick={() => setAttributes({ counter: counter + 1 })}>
{counter}
</button>
Expand Down
5 changes: 0 additions & 5 deletions src/blocks/interactive-parent/shared/button.js

This file was deleted.

7 changes: 0 additions & 7 deletions src/blocks/interactive-parent/shared/title.js

This file was deleted.

15 changes: 6 additions & 9 deletions src/blocks/interactive-parent/view.js
Original file line number Diff line number Diff line change
@@ -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: {
Expand All @@ -27,9 +24,9 @@ const View = ({
fontWeight: bold ? 900 : fontWeight,
}}
>
<Title>{title}</Title>
<Button handler={() => setShow(!show)}>Show</Button>
<Button handler={() => setBold(!bold)}>Bold</Button>
<h2 className="title">{title}</h2>
<button onClick={() => setShow(!show)}>Show</button>
<button onClick={() => setBold(!bold)}>Bold</button>
<button onClick={() => setCounter(counter + 1)}>
{counter}
</button>
Expand Down
7 changes: 2 additions & 5 deletions src/blocks/non-interactive-parent/edit.js
Original file line number Diff line number Diff line change
@@ -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 }) => (
<div {...useBlockProps()}>
Expand All @@ -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}
</RichText>
/>
<InnerBlocks />
</div>
);
Expand Down
2 changes: 1 addition & 1 deletion src/blocks/non-interactive-parent/view.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
const View = ({ attributes, blockProps, children }) => (
<div {...blockProps}>
<p className="title">{attributes.title}</p>
<h4 className="title">{attributes.title}</h4>
{children}
</div>
);
Expand Down
14 changes: 9 additions & 5 deletions src/context/counter.js
Original file line number Diff line number Diff line change
@@ -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;
14 changes: 9 additions & 5 deletions src/context/theme.js
Original file line number Diff line number Diff line change
@@ -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;
14 changes: 14 additions & 0 deletions src/gutenberg-packages/hydration.js
Original file line number Diff line number Diff line change
@@ -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);
41 changes: 41 additions & 0 deletions src/gutenberg-packages/to-vdom.js
Original file line number Diff line number Diff line change
@@ -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();
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why is there a possibility that a node name is not in lowercase?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If the node is an Element, it always return the uppercase name (e.g. DIV for <div>). 😅

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok. I was using node.localName 🙂


// 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;
100 changes: 100 additions & 0 deletions src/gutenberg-packages/visitor.js
Original file line number Diff line number Diff line change
@@ -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;
}
36 changes: 17 additions & 19 deletions src/gutenberg-packages/wordpress-element.js
Original file line number Diff line number Diff line change
@@ -1,42 +1,40 @@
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';
}
};

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;
Loading