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 13 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
2 changes: 2 additions & 0 deletions block-hydration-experiments.php
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
5 changes: 5 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion package.json
Expand Up @@ -42,6 +42,7 @@
},
"dependencies": {
"hpq": "^1.3.0",
"preact": "^10.10.6"
"preact": "^10.10.6",
"preact-markup": "^2.1.1"
DAreRodz marked this conversation as resolved.
Show resolved Hide resolved
}
}
2 changes: 2 additions & 0 deletions src/blocks/interactive-child/register-view.js
@@ -1,3 +1,5 @@
import 'preact/debug';
DAreRodz marked this conversation as resolved.
Show resolved Hide resolved

import registerBlockView from '../../gutenberg-packages/register-block-view';
import View from './view';

Expand Down
8 changes: 6 additions & 2 deletions 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 (
<div {...blockProps}>
Expand Down
20 changes: 11 additions & 9 deletions src/blocks/interactive-parent/edit.js
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
2 changes: 2 additions & 0 deletions src/blocks/interactive-parent/register-view.js
@@ -1,3 +1,5 @@
import 'preact/debug';
DAreRodz marked this conversation as resolved.
Show resolved Hide resolved

import registerBlockView from '../../gutenberg-packages/register-block-view';
import View from './view';

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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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;
13 changes: 13 additions & 0 deletions src/gutenberg-packages/hydration.js
@@ -1,3 +1,16 @@
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;
Copy link
Member

Choose a reason for hiding this comment

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

Why do you need .props.children here?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Because you run hydrate on the parent element (wp-site-blocks), but you pass the vdom of the content (wp-site-blocks works as a wrapper).

We can change this with a better approach in the future. 🙂

Copy link
Member

Choose a reason for hiding this comment

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

Oh, I see. I'm using createRootFragment for that 🙂

hydrate(vdom, dom);
68 changes: 68 additions & 0 deletions src/gutenberg-packages/to-vdom.js
@@ -0,0 +1,68 @@
const EMPTY_OBJ = {};

// deeply convert an XML DOM to VDOM
export default function toVdom(node, visitor, h, options) {
walk.visitor = visitor;
walk.h = h;
walk.options = options || EMPTY_OBJ;
return walk(node);
}

function walk(n, index, arr) {
if (n.nodeType === 3) {
let text = 'textContent' in n ? n.textContent : n.nodeValue || '';
DAreRodz marked this conversation as resolved.
Show resolved Hide resolved

if (walk.options.trim !== false) {
let isFirstOrLast = index === 0 || index === arr.length - 1;

// trim strings but don't entirely collapse whitespace
if (text.match(/^[\s\n]+$/g) && walk.options.trim !== 'all') {
text = ' ';
} else {
text = text.replace(
/(^[\s\n]+|[\s\n]+$)/g,
walk.options.trim === 'all' || isFirstOrLast ? '' : ' '
);
}
// skip leading/trailing whitespace
if ((!text || text === ' ') && arr.length > 1 && isFirstOrLast)
return null;
}
DAreRodz marked this conversation as resolved.
Show resolved Hide resolved
return text;
}
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 unless explicitly specified
if (nodeName === 'script' && !walk.options.allowScripts) return null;
DAreRodz marked this conversation as resolved.
Show resolved Hide resolved

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];
if (name.substring(0, 2) === 'on' && walk.options.allowEvents) {
DAreRodz marked this conversation as resolved.
Show resolved Hide resolved
value = new Function(value); // eslint-disable-line no-new-func
}
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
@@ -0,0 +1,100 @@
import { createElement as h } from "preact/compat";
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.props?.children || [];
}

// 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;
}