rebase on oct-10-2023

This commit is contained in:
Rachit Bhargava
2023-10-10 17:23:21 -04:00
parent d37566ffb6
commit d096058d7d
4789 changed files with 254611 additions and 307223 deletions

View File

@@ -1,26 +0,0 @@
import { useMemo, useContext } from 'preact/hooks';
import { deepSignal } from 'deepsignal';
import { component } from './hooks';
export default () => {
// <wp-context>
const Context = ( { children, data, context: { Provider } } ) => {
const signals = useMemo(
() => deepSignal( JSON.parse( data ) ),
[ data ]
);
return <Provider value={ signals }>{ children }</Provider>;
};
component( 'context', Context );
// <wp-show>
const Show = ( { children, when, evaluate, context } ) => {
const contextValue = useContext( context );
if ( evaluate( when, { context: contextValue } ) ) {
return children;
} else {
return <template>{ children }</template>;
}
};
component( 'show', Show );
};

View File

@@ -1,3 +1,2 @@
export const csnMetaTagItemprop = 'woo-client-side-navigation';
export const componentPrefix = 'woo-';
export const directivePrefix = 'data-woo-';
export const csnMetaTagItemprop = 'wc-client-side-navigation';
export const directivePrefix = 'wc';

View File

@@ -1,16 +1,8 @@
import { useContext, useMemo, useEffect } from 'preact/hooks';
import { useSignalEffect } from '@preact/signals';
import { deepSignal, peek } from 'deepsignal';
import { useSignalEffect } from './utils';
import { directive } from './hooks';
import { prefetch, navigate, canDoClientSideNavigation } from './router';
// Until useSignalEffects is fixed:
// https://github.com/preactjs/signals/issues/228
const raf = window.requestAnimationFrame;
const tick = () => new Promise( ( r ) => raf( () => raf( r ) ) );
// Check if current page can do client-side navigation.
const clientSideNavigation = canDoClientSideNavigation( document.head );
import { prefetch, navigate } from './router';
const isObject = ( item ) =>
item && typeof item === 'object' && ! Array.isArray( item );
@@ -32,7 +24,7 @@ const mergeDeepSignals = ( target, source ) => {
};
export default () => {
// wp-context
// data-wc-context
directive(
'context',
( {
@@ -51,20 +43,21 @@ export default () => {
}, [ context, inheritedValue ] );
return <Provider value={ value }>{ children }</Provider>;
}
},
{ priority: 5 }
);
// wp-effect:[name]
// data-wc-effect--[name]
directive( 'effect', ( { directives: { effect }, context, evaluate } ) => {
const contextValue = useContext( context );
Object.values( effect ).forEach( ( path ) => {
useSignalEffect( () => {
evaluate( path, { context: contextValue } );
return evaluate( path, { context: contextValue } );
} );
} );
} );
// wp-on:[event]
// data-wc-on--[event]
directive( 'on', ( { directives: { on }, element, evaluate, context } ) => {
const contextValue = useContext( context );
Object.entries( on ).forEach( ( [ name, path ] ) => {
@@ -74,7 +67,7 @@ export default () => {
} );
} );
// wp-class:[classname]
// data-wc-class--[classname]
directive(
'class',
( {
@@ -119,7 +112,7 @@ export default () => {
}
);
// wp-bind:[attribute]
// data-wc-bind--[attribute]
directive(
'bind',
( { directives: { bind }, element, context, evaluate } ) => {
@@ -127,32 +120,53 @@ export default () => {
Object.entries( bind )
.filter( ( n ) => n !== 'default' )
.forEach( ( [ attribute, path ] ) => {
element.props[ attribute ] = evaluate( path, {
const result = evaluate( path, {
context: contextValue,
} );
element.props[ attribute ] = result;
// 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.
useEffect( () => {
// aria- and data- attributes have no boolean representation.
// A `false` value is different from the attribute not being
// present, so we can't remove it.
// We follow Preact's logic: https://github.com/preactjs/preact/blob/ea49f7a0f9d1ff2c98c0bdd66aa0cbc583055246/src/diff/props.js#L131C24-L136
if ( result === false && attribute[ 4 ] !== '-' ) {
element.ref.current.removeAttribute( attribute );
} else {
element.ref.current.setAttribute(
attribute,
result === true && attribute[ 4 ] !== '-'
? ''
: result
);
}
}, [] );
} );
}
);
// wp-link
// data-wc-navigation-link
directive(
'link',
'navigation-link',
( {
directives: {
link: { default: link },
'navigation-link': { default: link },
},
props: { href },
element,
} ) => {
useEffect( () => {
// Prefetch the page if it is in the directive options.
if ( clientSideNavigation && link?.prefetch ) {
if ( link?.prefetch ) {
prefetch( href );
}
} );
// Don't do anything if it's falsy.
if ( clientSideNavigation && link !== false ) {
if ( link !== false ) {
element.props.onclick = async ( event ) => {
event.preventDefault();
@@ -173,4 +187,62 @@ export default () => {
}
}
);
// data-wc-show
directive(
'show',
( {
directives: {
show: { default: show },
},
element,
evaluate,
context,
} ) => {
const contextValue = useContext( context );
if ( ! evaluate( show, { context: contextValue } ) )
element.props.children = (
<template>{ element.props.children }</template>
);
}
);
// data-wc-ignore
directive(
'ignore',
( {
element: {
type: Type,
props: { innerHTML, ...rest },
},
} ) => {
// Preserve the initial inner HTML.
const cached = useMemo( () => innerHTML, [] );
return (
<Type
dangerouslySetInnerHTML={ { __html: cached } }
{ ...rest }
/>
);
}
);
// data-wc-text
directive(
'text',
( {
directives: {
text: { default: text },
},
element,
evaluate,
context,
} ) => {
const contextValue = useContext( context );
element.props.children = evaluate( text, {
context: contextValue,
} );
}
);
};

View File

@@ -1,26 +1,21 @@
import { h, options, createContext } from 'preact';
import { useRef } from 'preact/hooks';
import { h, options, createContext, cloneElement } from 'preact';
import { useRef, useMemo } from 'preact/hooks';
import { rawStore as store } from './store';
import { componentPrefix } from './constants';
// Main context.
const context = createContext( {} );
// WordPress Directives.
const directiveMap = {};
export const directive = ( name, cb ) => {
const directivePriorities = {};
export const directive = ( name, cb, { priority = 10 } = {} ) => {
directiveMap[ name ] = cb;
};
// WordPress Components.
const componentMap = {};
export const component = ( name, Comp ) => {
componentMap[ name ] = Comp;
directivePriorities[ name ] = priority;
};
// Resolve the path to some property of the store object.
const resolve = ( path, context ) => {
let current = { ...store, context };
const resolve = ( path, ctx ) => {
let current = { ...store, context: ctx };
path.split( '.' ).forEach( ( p ) => ( current = current[ p ] ) );
return current;
};
@@ -29,22 +24,92 @@ const resolve = ( path, context ) => {
const getEvaluate =
( { ref } = {} ) =>
( path, extraArgs = {} ) => {
// If path starts with !, remove it and save a flag.
const hasNegationOperator =
path[ 0 ] === '!' && !! ( path = path.slice( 1 ) );
const value = resolve( path, extraArgs.context );
return typeof value === 'function'
? value( {
state: store.state,
...( ref !== undefined ? { ref } : {} ),
...extraArgs,
} )
: value;
const returnValue =
typeof value === 'function'
? value( {
ref: ref.current,
...store,
...extraArgs,
} )
: value;
return hasNegationOperator ? ! returnValue : returnValue;
};
// Separate directives by priority. The resulting array contains objects
// of directives grouped by same priority, and sorted in ascending order.
const usePriorityLevels = ( directives ) =>
useMemo( () => {
const byPriority = Object.entries( directives ).reduce(
( acc, [ name, values ] ) => {
const priority = directivePriorities[ name ];
if ( ! acc[ priority ] ) acc[ priority ] = {};
acc[ priority ][ name ] = values;
return acc;
},
{}
);
return Object.entries( byPriority )
.sort( ( [ p1 ], [ p2 ] ) => p1 - p2 )
.map( ( [ , obj ] ) => obj );
}, [ directives ] );
// Directive wrapper.
const Directive = ( { type, directives, props: originalProps } ) => {
const ref = useRef( null );
const element = h( type, { ...originalProps, ref, _wrapped: true } );
const props = { ...originalProps, children: element };
const evaluate = getEvaluate( { ref: ref.current } );
const element = h( type, { ...originalProps, ref } );
const evaluate = useMemo( () => getEvaluate( { ref } ), [] );
// Add wrappers recursively for each priority level.
const byPriorityLevel = usePriorityLevels( directives );
return (
<RecursivePriorityLevel
directives={ byPriorityLevel }
element={ element }
evaluate={ evaluate }
originalProps={ originalProps }
/>
);
};
// Priority level wrapper.
const RecursivePriorityLevel = ( {
directives: [ directives, ...rest ],
element,
evaluate,
originalProps,
} ) => {
// This element needs to be a fresh copy so we are not modifying an already
// rendered element with Preact's internal properties initialized. This
// prevents an error with changes in `element.props.children` not being
// reflected in `element.__k`.
element = cloneElement( element );
// Recursively render the wrapper for the next priority level.
//
// Note that, even though we're instantiating a vnode with a
// `RecursivePriorityLevel` here, its render function will not be executed
// just yet. Actually, it will be delayed until the current render function
// has finished. That ensures directives in the current priorty level have
// run (and thus modified the passed `element`) before the next level.
const children =
rest.length > 0 ? (
<RecursivePriorityLevel
directives={ rest }
element={ element }
evaluate={ evaluate }
originalProps={ originalProps }
/>
) : (
element
);
const props = { ...originalProps, children };
const directiveArgs = { directives, props, element, context, evaluate };
for ( const d in directives ) {
@@ -58,27 +123,16 @@ const Directive = ( { type, directives, props: originalProps } ) => {
// Preact Options Hook called each time a vnode is created.
const old = options.vnode;
options.vnode = ( vnode ) => {
const type = vnode.type;
const { directives } = vnode.props;
if (
typeof type === 'string' &&
type.slice( 0, componentPrefix.length ) === componentPrefix
) {
vnode.props.children = h(
componentMap[ type.slice( componentPrefix.length ) ],
{ ...vnode.props, context, evaluate: getEvaluate() },
vnode.props.children
);
} else if ( directives ) {
if ( vnode.props.__directives ) {
const props = vnode.props;
delete props.directives;
if ( ! props._wrapped ) {
vnode.props = { type: vnode.type, directives, props };
vnode.type = Directive;
} else {
delete props._wrapped;
}
const directives = props.__directives;
delete props.__directives;
vnode.props = {
type: vnode.type,
directives,
props,
};
vnode.type = Directive;
}
if ( old ) old( vnode );

View File

@@ -1,14 +1,17 @@
import registerDirectives from './directives';
import registerComponents from './components';
import { init } from './router';
import { rawStore, afterLoads } from './store';
export { navigate } from './router';
export { store } from './store';
/**
* Initialize the initial vDOM.
* Initialize the Interactivity API.
*/
document.addEventListener( 'DOMContentLoaded', async () => {
registerDirectives();
registerComponents();
await init();
console.log( 'hydrated!' );
afterLoads.forEach( ( afterLoad ) => afterLoad( rawStore ) );
// eslint-disable-next-line no-console
console.log( 'Interactivity API started' );
} );

View File

@@ -1,14 +1,22 @@
import { hydrate, render } from 'preact';
import { toVdom, hydratedIslands } from './vdom';
import { createRootFragment } from './utils';
import { csnMetaTagItemprop, directivePrefix } from './constants';
import { directivePrefix } from './constants';
// The root to render the vdom (document.body).
let rootFragment;
// The cache of visited and prefetched pages and stylesheets.
// The cache of visited and prefetched pages.
const pages = new Map();
const stylesheets = new Map();
// Keep the same root fragment for each interactive region node.
const regionRootFragments = new WeakMap();
const getRegionRootFragment = ( region ) => {
if ( ! regionRootFragments.has( region ) ) {
regionRootFragments.set(
region,
createRootFragment( region.parentElement, region )
);
}
return regionRootFragments.get( region );
};
// Helper to remove domain and hash from the URL. We are only interesting in
// caching the path and the query.
@@ -17,47 +25,32 @@ const cleanUrl = ( url ) => {
return u.pathname + u.search;
};
// Helper to check if a page can do client-side navigation.
export const canDoClientSideNavigation = ( dom ) =>
dom
.querySelector( `meta[itemprop='${ csnMetaTagItemprop }']` )
?.getAttribute( 'content' ) === 'active';
// Fetch styles of a new page.
const fetchHead = async ( head ) => {
const sheets = await Promise.all(
[].map.call(
head.querySelectorAll( "link[rel='stylesheet']" ),
( link ) => {
const href = link.getAttribute( 'href' );
if ( ! stylesheets.has( href ) )
stylesheets.set(
href,
fetch( href ).then( ( r ) => r.text() )
);
return stylesheets.get( href );
}
)
);
const stylesFromSheets = sheets.map( ( sheet ) => {
const style = document.createElement( 'style' );
style.textContent = sheet;
return style;
} );
return [
head.querySelector( 'title' ),
...head.querySelectorAll( 'style' ),
...stylesFromSheets,
];
};
// Fetch a new page and convert it to a static virtual DOM.
const fetchPage = async ( url ) => {
const html = await window.fetch( url ).then( ( r ) => r.text() );
const dom = new window.DOMParser().parseFromString( html, 'text/html' );
if ( ! canDoClientSideNavigation( dom.head ) ) return false;
const head = await fetchHead( dom.head );
return { head, body: toVdom( dom.body ) };
let dom;
try {
const res = await window.fetch( url );
if ( res.status !== 200 ) return false;
const html = await res.text();
dom = new window.DOMParser().parseFromString( html, 'text/html' );
} catch ( e ) {
return false;
}
return regionsToVdom( dom );
};
// Return an object with VDOM trees of those HTML regions marked with a
// `navigation-id` directive.
const regionsToVdom = ( dom ) => {
const regions = {};
const attrName = `data-${ directivePrefix }-navigation-id`;
dom.querySelectorAll( `[${ attrName }]` ).forEach( ( region ) => {
const id = region.getAttribute( attrName );
regions[ id ] = toVdom( region );
} );
return { regions };
};
// Prefetch a page. We store the promise to avoid triggering a second fetch for
@@ -69,15 +62,28 @@ export const prefetch = ( url ) => {
}
};
// Render all interactive regions contained in the given page.
const renderRegions = ( page ) => {
const attrName = `data-${ directivePrefix }-navigation-id`;
document.querySelectorAll( `[${ attrName }]` ).forEach( ( region ) => {
const id = region.getAttribute( attrName );
const fragment = getRegionRootFragment( region );
render( page.regions[ id ], fragment );
} );
};
// Navigate to a new page.
export const navigate = async ( href ) => {
export const navigate = async ( href, { replace = false } = {} ) => {
const url = cleanUrl( href );
prefetch( url );
const page = await pages.get( url );
if ( page ) {
document.head.replaceChildren( ...page.head );
render( page.body, rootFragment );
window.history.pushState( {}, '', href );
renderRegions( page );
window.history[ replace ? 'replaceState' : 'pushState' ](
{},
'',
href
);
} else {
window.location.assign( href );
}
@@ -89,8 +95,7 @@ window.addEventListener( 'popstate', async () => {
const url = cleanUrl( window.location ); // Remove hash.
const page = pages.has( url ) && ( await pages.get( url ) );
if ( page ) {
document.head.replaceChildren( ...page.head );
render( page.body, rootFragment );
renderRegions( page );
} else {
window.location.reload();
}
@@ -98,33 +103,19 @@ window.addEventListener( 'popstate', async () => {
// Initialize the router with the initial DOM.
export const init = async () => {
if ( canDoClientSideNavigation( document.head ) ) {
// Create the root fragment to hydrate everything.
rootFragment = createRootFragment(
document.documentElement,
document.body
);
document
.querySelectorAll( `[data-${ directivePrefix }-interactive]` )
.forEach( ( node ) => {
if ( ! hydratedIslands.has( node ) ) {
const fragment = getRegionRootFragment( node );
const vdom = toVdom( node );
hydrate( vdom, fragment );
}
} );
const body = toVdom( document.body );
hydrate( body, rootFragment );
const head = await fetchHead( document.head );
pages.set(
cleanUrl( window.location ),
Promise.resolve( { body, head } )
);
} else {
document
.querySelectorAll( `[${ directivePrefix }island]` )
.forEach( ( node ) => {
if ( ! hydratedIslands.has( node ) ) {
const fragment = createRootFragment(
node.parentNode,
node
);
const vdom = toVdom( node );
hydrate( vdom, fragment );
}
} );
}
// Cache the current regions.
pages.set(
cleanUrl( window.location ),
Promise.resolve( regionsToVdom( document ) )
);
};

View File

@@ -18,7 +18,7 @@ export const deepMerge = ( target, source ) => {
const getSerializedState = () => {
const storeTag = document.querySelector(
`script[type="application/json"]#store`
`script[type="application/json"]#wc-interactivity-store-data`
);
if ( ! storeTag ) return {};
try {
@@ -26,17 +26,21 @@ const getSerializedState = () => {
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 {};
};
export const afterLoads = new Set();
const rawState = getSerializedState();
export const rawStore = { state: deepSignal( rawState ) };
if ( typeof window !== 'undefined' ) window.store = rawStore;
export const store = ( { state, ...block } ) => {
export const store = ( { state, ...block }, { afterLoad } = {} ) => {
deepMerge( rawStore, block );
deepMerge( rawState, state );
if ( afterLoad ) afterLoads.add( afterLoad );
};

View File

@@ -1,4 +1,47 @@
// For wrapperless hydration of document.body.
import { useRef, useEffect } from 'preact/hooks';
import { effect } from '@preact/signals';
function afterNextFrame( callback ) {
const done = () => {
cancelAnimationFrame( raf );
setTimeout( callback );
};
const raf = 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 );

View File

@@ -1,69 +1,105 @@
import { h } from 'preact';
import { directivePrefix as p } from './constants';
const ignoreAttr = `${ p }ignore`;
const islandAttr = `${ p }island`;
const directiveParser = new RegExp( `${ p }([^:]+):?(.*)$` );
const ignoreAttr = `data-${ p }-ignore`;
const islandAttr = `data-${ p }-interactive`;
const fullPrefix = `data-${ p }-`;
// Regular expression for directive parsing.
const directiveParser = new RegExp(
`^data-${ p }-` + // ${p} must be a prefix string, like 'wp'.
// Match alphanumeric characters including hyphen-separated
// segments. It excludes underscore intentionally to prevent confusion.
// E.g., "custom-directive".
'([a-z0-9]+(?:-[a-z0-9]+)*)' +
// (Optional) Match '--' followed by any alphanumeric charachters. It
// excludes underscore intentionally to prevent confusion, but it can
// contain multiple hyphens. E.g., "--custom-prefix--with-more-info".
'(?:--([a-z0-9][a-z0-9-]+))?$',
'i' // Case insensitive.
);
export const hydratedIslands = new WeakSet();
// Recursive function that transfoms a DOM tree into vDOM.
export function toVdom( node ) {
const props = {};
const { attributes, childNodes } = node;
const directives = {};
let hasDirectives = false;
let ignore = false;
let island = false;
// 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
);
if ( node.nodeType === 3 ) return node.data;
if ( node.nodeType === 4 ) {
node.replaceWith( new Text( node.nodeValue ) );
return node.nodeValue;
}
function walk( node ) {
const { attributes, nodeType } = node;
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;
if ( nodeType === 3 ) return [ node.data ];
if ( nodeType === 4 ) {
const next = treeWalker.nextSibling();
node.replaceWith( new 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[ fullPrefix.length ] &&
n.slice( 0, fullPrefix.length ) === fullPrefix
) {
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;
}
} else if ( n === 'ref' ) {
continue;
} else {
props[ n ] = attributes[ i ].value;
}
}
if ( ignore && ! island )
return h( node.localName, {
dangerouslySetInnerHTML: { __html: node.innerHTML },
} );
if ( island ) hydratedIslands.add( node );
if ( ignore && ! island )
return [
h( node.localName, {
...props,
innerHTML: node.innerHTML,
__directives: { ignore: true },
} ),
];
if ( island ) hydratedIslands.add( node );
if ( hasDirectives ) props.directives = directives;
if ( hasDirectives ) props.__directives = directives;
const children = [];
for ( let i = 0; i < childNodes.length; i++ ) {
const child = childNodes[ i ];
if ( child.nodeType === 8 || child.nodeType === 7 ) {
child.remove();
i--;
} else {
children.push( toVdom( child ) );
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 h( node.localName, props, children );
return walk( treeWalker.currentNode );
}