Skip to content
This repository was archived by the owner on Feb 23, 2024. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
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
26 changes: 0 additions & 26 deletions assets/js/interactivity/components.js

This file was deleted.

5 changes: 2 additions & 3 deletions assets/js/interactivity/constants.js
Original file line number Diff line number Diff line change
@@ -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';
105 changes: 90 additions & 15 deletions assets/js/interactivity/directives.js
Original file line number Diff line number Diff line change
@@ -1,14 +1,9 @@
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 );

Expand All @@ -32,7 +27,7 @@ const mergeDeepSignals = ( target, source ) => {
};

export default () => {
// wp-context
// data-wc-context
directive(
'context',
( {
Expand All @@ -51,20 +46,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 ] ) => {
Expand All @@ -74,7 +70,7 @@ export default () => {
} );
} );

// wp-class:[classname]
// data-wc-class--[classname]
directive(
'class',
( {
Expand Down Expand Up @@ -119,22 +115,43 @@ export default () => {
}
);

// wp-bind:[attribute]
// data-wc-bind--[attribute]
directive(
'bind',
( { directives: { bind }, element, context, evaluate } ) => {
const contextValue = useContext( context );
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-link
directive(
'link',
( {
Expand Down Expand Up @@ -173,4 +190,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,
} );
}
);
};
138 changes: 96 additions & 42 deletions assets/js/interactivity/hooks.js
Original file line number Diff line number Diff line change
@@ -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;
};
Expand All @@ -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 ) {
Expand All @@ -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 );
Expand Down
8 changes: 4 additions & 4 deletions assets/js/interactivity/index.js
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
import registerDirectives from './directives';
import registerComponents from './components';
import { init } from './router';
export { store } from './store';
export { navigate } from './router';

/**
* Initialize the initial vDOM.
* Initialize the Interactivity API.
*/
document.addEventListener( 'DOMContentLoaded', async () => {
registerDirectives();
registerComponents();
await init();
console.log( 'hydrated!' );
// eslint-disable-next-line no-console
console.log( 'Interactivity API started' );
} );
Loading