diff --git a/package-lock.json b/package-lock.json index 876887ea..d70f9c5a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2102,6 +2102,19 @@ "integrity": "sha512-a5Sab1C4/icpTZVzZc5Ghpz88yQtGOyNqYXcZgOssB2uuAr+wF/MvN6bgtW32q7HHrvBki+BsZ0OuNv6EV3K9g==", "dev": true }, + "@preact/signals": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@preact/signals/-/signals-1.1.2.tgz", + "integrity": "sha512-MLNNrICSllHBhpXBvXbl7K5L1HmIjuTzgBw+zdODqjM/cLGPXdYiAWt4lqXlrxNavYdoU4eljb+TLE+DRL+6yw==", + "requires": { + "@preact/signals-core": "^1.2.2" + } + }, + "@preact/signals-core": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@preact/signals-core/-/signals-core-1.2.2.tgz", + "integrity": "sha512-z3/bCj7rRA21RJb4FeJ4guCrD1CQbaURHkCTunUWQpxUMAFOPXCD8tSFqERyGrrcSb4T3Hrmdc1OAl0LXBHwiw==" + }, "@prettier/plugin-php": { "version": "0.18.9", "resolved": "https://registry.npmjs.org/@prettier/plugin-php/-/plugin-php-0.18.9.tgz", diff --git a/package.json b/package.json index bafeb09c..d0c60e63 100644 --- a/package.json +++ b/package.json @@ -37,6 +37,7 @@ "prettier": "^2.7.1" }, "dependencies": { + "@preact/signals": "^1.1.2", "hpq": "^1.3.0", "preact": "^10.10.6" } diff --git a/src/runtime/components.js b/src/runtime/components.js new file mode 100644 index 00000000..8edcf5f4 --- /dev/null +++ b/src/runtime/components.js @@ -0,0 +1,11 @@ +import { useMemo } from 'preact/hooks'; +import { deepSignal } from './deepsignal'; +import { component } from './hooks'; + +export default () => { + const WpContext = ({ children, data, context: { Provider } }) => { + const signals = useMemo(() => deepSignal(JSON.parse(data)), []); + return {children}; + }; + component('wp-context', WpContext); +}; diff --git a/src/runtime/deepsignal.js b/src/runtime/deepsignal.js new file mode 100644 index 00000000..84901785 --- /dev/null +++ b/src/runtime/deepsignal.js @@ -0,0 +1,42 @@ +import { signal } from '@preact/signals'; +import { knownSymbols, shouldWrap } from './utils'; + +const proxyToSignals = new WeakMap(); +const objToProxy = new WeakMap(); +const returnSignal = /^\$/; + +export const deepSignal = (obj) => new Proxy(obj, handlers); + +const handlers = { + get(target, prop, receiver) { + if (typeof prop === 'symbol' && knownSymbols.has(prop)) + return Reflect.get(target, prop, receiver); + const shouldReturnSignal = returnSignal.test(prop); + const key = shouldReturnSignal ? prop.replace(returnSignal, '') : prop; + if (!proxyToSignals.has(receiver)) + proxyToSignals.set(receiver, new Map()); + const signals = proxyToSignals.get(receiver); + if (!signals.has(key)) { + let val = Reflect.get(target, key, receiver); + if (typeof val === 'object' && val !== null && shouldWrap(val)) + val = new Proxy(val, handlers); + signals.set(key, signal(val)); + } + return returnSignal ? signals.get(key) : signals.get(key).value; + }, + + set(target, prop, val, receiver) { + let internal = val; + if (typeof val === 'object' && val !== null && shouldWrap(val)) { + if (!objToProxy.has(val)) + objToProxy.set(val, new Proxy(val, handlers)); + internal = objToProxy.get(val); + } + if (!proxyToSignals.has(receiver)) + proxyToSignals.set(receiver, new Map()); + const signals = proxyToSignals.get(receiver); + if (!signals.has(prop)) signals.set(prop, signal(internal)); + else signals.get(prop).value = internal; + return Reflect.set(target, prop, val, receiver); + }, +}; diff --git a/src/runtime/directives.js b/src/runtime/directives.js new file mode 100644 index 00000000..3e7a42cd --- /dev/null +++ b/src/runtime/directives.js @@ -0,0 +1,84 @@ +import { useContext, useMemo } from 'preact/hooks'; +import { useSignalEffect } from '@preact/signals'; +import { directive } from './hooks'; +import { deepSignal } from './deepsignal'; +import { getCallback } from './utils'; + +const raf = window.requestAnimationFrame; +// Until useSignalEffects is fixed: https://github.com/preactjs/signals/issues/228 +const tick = () => new Promise((r) => raf(() => raf(r))); + +export default () => { + // wp-context + directive( + 'context', + ({ + directives: { context }, + props: { children }, + context: { Provider }, + }) => { + const signals = useMemo(() => deepSignal(context.default), []); + return {children}; + } + ); + + // wp-effect + directive( + 'effect', + ({ directives: { effect }, element, context: mainContext }) => { + const context = useContext(mainContext); + Object.values(effect).forEach((callback) => { + useSignalEffect(() => { + const cb = getCallback(callback); + cb({ context, tick, ref: element.ref.current }); + }); + }); + } + ); + + // wp-on:[event] + directive('on', ({ directives: { on }, element, context: mainContext }) => { + const context = useContext(mainContext); + Object.entries(on).forEach(([name, callback]) => { + element.props[`on${name}`] = (event) => { + const cb = getCallback(callback); + cb({ context, event }); + }; + }); + }); + + // wp-class:[classname] + directive( + 'class', + ({ + directives: { class: className }, + element, + context: mainContext, + }) => { + const context = useContext(mainContext); + Object.keys(className) + .filter((n) => n !== 'default') + .forEach((name) => { + const cb = getCallback(className[name]); + const result = cb({ context }); + if (!result) element.props.class.replace(name, ''); + else if (!element.props.class.includes(name)) + element.props.class += ` ${name}`; + }); + } + ); + + // wp-bind:[attribute] + directive( + 'bind', + ({ directives: { bind }, element, context: mainContext }) => { + const context = useContext(mainContext); + Object.entries(bind) + .filter((n) => n !== 'default') + .forEach(([attribute, callback]) => { + const cb = getCallback(callback); + element.props[attribute] = cb({ context }); + }); + } + ); +}; diff --git a/src/runtime/hooks.js b/src/runtime/hooks.js new file mode 100644 index 00000000..46e4012d --- /dev/null +++ b/src/runtime/hooks.js @@ -0,0 +1,57 @@ +import { h, options, createContext } from 'preact'; +import { useRef } from 'preact/hooks'; + +// Main context +const context = createContext({}); + +// WordPress Directives. +const directives = {}; +export const directive = (name, cb) => { + directives[name] = cb; +}; + +// WordPress Components. +const components = {}; +export const component = (name, Comp) => { + components[name] = Comp; +}; + +// Directive wrapper. +const WpDirective = ({ type, wp, props: originalProps }) => { + const ref = useRef(null); + const element = h(type, { ...originalProps, ref, _wrapped: true }); + const props = { ...originalProps, children: element }; + const directiveArgs = { directives: wp, props, element, context }; + + for (const d in wp) { + const wrapper = directives[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) => { + const type = vnode.type; + const wp = vnode.props.wp; + + if (typeof type === 'string' && type.startsWith('wp-')) { + vnode.type = components[type]; + vnode.props.context = context; + } + + if (wp) { + const props = vnode.props; + delete props.wp; + if (!props._wrapped) { + vnode.props = { type: vnode.type, wp, props }; + vnode.type = WpDirective; + } else { + delete props._wrapped; + } + } + + if (old) old(vnode); +}; diff --git a/src/runtime/index.js b/src/runtime/index.js index 2bbdee8d..b72f82db 100644 --- a/src/runtime/index.js +++ b/src/runtime/index.js @@ -1 +1,24 @@ -console.log('Runtime loaded correctly.'); +import { hydrate } from 'preact'; +import registerDirectives from './directives'; +import registerComponents from './components'; +import toVdom from './vdom'; +import { createRootFragment } from './utils'; + +/** + * Initialize the initial vDOM. + */ +document.addEventListener('DOMContentLoaded', async () => { + registerDirectives(); + registerComponents(); + + // Create the root fragment to hydrate everything. + const rootFragment = createRootFragment( + document.documentElement, + document.body + ); + + const vdom = toVdom(document.body); + hydrate(vdom, rootFragment); + + console.log('hydrated!'); +}); diff --git a/src/runtime/utils.js b/src/runtime/utils.js new file mode 100644 index 00000000..87b940cb --- /dev/null +++ b/src/runtime/utils.js @@ -0,0 +1,74 @@ +// For wrapperless hydration of document.body. +// 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); + }, + }); +}; + +// Helper function to await until the CPU is idle. +export const idle = () => + new Promise((resolve) => window.requestIdleCallback(resolve)); + +export const knownSymbols = new Set( + Object.getOwnPropertyNames(Symbol) + .map((key) => Symbol[key]) + .filter((value) => typeof value === 'symbol') +); +const supported = new Set([ + Object, + Array, + Int8Array, + Uint8Array, + Uint8ClampedArray, + Int16Array, + Uint16Array, + Int32Array, + Uint32Array, + Float32Array, + Float64Array, +]); +export const shouldWrap = ({ constructor }) => { + const isBuiltIn = + typeof constructor === 'function' && + constructor.name in globalThis && + globalThis[constructor.name] === constructor; + return !isBuiltIn || supported.has(constructor); +}; + +// Deep Merge +const isObject = (item) => + item && typeof item === 'object' && !Array.isArray(item); + +export 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] }); + } + } + } +}; + +// Get callback. +export const getCallback = (path) => { + let current = window.wpx; + path.split('.').forEach((p) => (current = current[p])); + return current; +}; diff --git a/src/runtime/vdom.js b/src/runtime/vdom.js new file mode 100644 index 00000000..6c3b0228 --- /dev/null +++ b/src/runtime/vdom.js @@ -0,0 +1,42 @@ +/** + * External dependencies + */ +import { h } from 'preact'; + +// Recursive function that transfoms a DOM tree into vDOM. +export default function toVdom(node) { + const props = {}; + const attributes = node.attributes; + const wpDirectives = {}; + let hasWpDirectives = false; + + if (node.nodeType === 3) return node.data; + if (node.nodeType === 8) return null; + if (node.localName === 'script') return h('script'); + + for (let i = 0; i < attributes.length; i++) { + const name = attributes[i].name; + if (name.startsWith('wp-')) { + hasWpDirectives = true; + let val = attributes[i].value; + try { + val = JSON.parse(val); + } catch (e) {} + const [, prefix, suffix] = /wp-([^:]+):?(.*)$/.exec(name); + wpDirectives[prefix] = wpDirectives[prefix] || {}; + wpDirectives[prefix][suffix || 'default'] = val; + } else { + props[name] = attributes[i].value; + } + } + + if (hasWpDirectives) props.wp = wpDirectives; + + // Walk child nodes and return vDOM children. + const children = [].map.call(node.childNodes, toVdom).filter(exists); + + return h(node.localName, props, children); +} + +// Filter existing items. +const exists = (x) => x; diff --git a/webpack.config.js b/webpack.config.js index f0839132..c2070f52 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -14,9 +14,10 @@ module.exports = { }, splitChunks: { cacheGroups: { - vendor: { + vendors: { test: /[\\/]node_modules[\\/]/, name: 'vendors', + minSize: 0, chunks: 'all', }, },