Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Interactivity API: update TS/JSDocs after migrating to the new store() API #56748

Merged
merged 11 commits into from
Dec 5, 2023
225 changes: 153 additions & 72 deletions packages/interactivity/src/hooks.tsx
Original file line number Diff line number Diff line change
@@ -1,44 +1,93 @@
// @ts-nocheck

/**
* External dependencies
*/
import { h, options, createContext, cloneElement } from 'preact';
import { useRef, useCallback, useContext } from 'preact/hooks';
import { deepSignal } from 'deepsignal';
import type { VNode, Context, RefObject } from 'preact';

/**
* Internal dependencies
*/
import { stores } from './store';
interface DirectiveEntry {
value: string | Object;
namespace: string;
suffix: string;
}

/** @typedef {import('preact').VNode} VNode */
/** @typedef {typeof context} Context */
/** @typedef {ReturnType<typeof getEvaluate>} Evaluate */
type DirectiveEntries = Record< string, DirectiveEntry[] >;

/**
* @typedef {Object} DirectiveCallbackParams Callback parameters.
* @property {Object} directives Object map with the defined directives of the element being evaluated.
* @property {Object} props Props present in the current element.
* @property {VNode} element Virtual node representing the original element.
* @property {Context} context The inherited context.
* @property {Evaluate} evaluate Function that resolves a given path to a value either in the store or the context.
*/
interface DirectiveArgs {
DAreRodz marked this conversation as resolved.
Show resolved Hide resolved
/**
* Object map with the defined directives of the element being evaluated.
*/
directives: DirectiveEntries;
/**
* Props present in the current element.
*/
props: Object;
/**
* Virtual node representing the element.
*/
element: VNode;
/**
* The inherited context.
*/
context: Context< any >;
/**
* Function that resolves a given path to a value either in the store or the
* context.
*/
evaluate: Evaluate;
}

/**
* @callback DirectiveCallback Callback that runs the directive logic.
* @param {DirectiveCallbackParams} params Callback parameters.
*/
interface DirectiveCallback {
( args: DirectiveArgs ): VNode | void;
}

/**
* @typedef DirectiveOptions Options object.
* @property {number} [priority=10] Value that specifies the priority to
* evaluate directives of this type. Lower
* numbers correspond with earlier execution.
* Default is `10`.
*/
interface DirectiveOptions {
/**
* Value that specifies the priority to evaluate directives of this type.
* Lower numbers correspond with earlier execution.
*
* @default 10
*/
priority?: number;
}

interface Scope {
evaluate: Evaluate;
context: Context< any >;
ref: RefObject< HTMLElement >;
state: any;
props: any;
}

interface Evaluate {
( entry: DirectiveEntry, ...args: any[] ): any;
}

interface GetEvaluate {
( args: { scope: Scope } ): Evaluate;
}

type PriorityLevel = string[];

interface GetPriorityLevels {
( directives: DirectiveEntries ): PriorityLevel[];
}

interface DirectivesProps {
directives: DirectiveEntries;
priorityLevels: PriorityLevel[];
element: VNode;
originalProps: any;
previousScope?: Scope;
}

// Main context.
const context = createContext( {} );
const context = createContext< any >( {} );

// Wrap the element props to prevent modifications.
const immutableMap = new WeakMap();
Expand All @@ -65,12 +114,28 @@ const deepImmutable = < T extends Object = {} >( target: T ): T => {

// Store stacks for the current scope and the default namespaces and export APIs
// to interact with them.
const scopeStack: any[] = [];
const scopeStack: Scope[] = [];
const namespaceStack: string[] = [];

/**
* Retrieves the context inherited by the element evaluating a function from the
* store. The returned value depends on the element and the namespace where the
* function calling `getContext()` exists.
*
* @param namespace Store namespace. By default, the namespace where the calling
* function exists is used.
* @return The context content.
*/
export const getContext = < T extends object >( namespace?: string ): T =>
getScope()?.context[ namespace || namespaceStack.slice( -1 )[ 0 ] ];

/**
* Retrieves a representation of the element where a function from the store
* is being evalutated. Such representation is read-only, and contains a
* reference to the DOM element, its props and a local reactive state.
*
* @return Element representation.
*/
export const getElement = () => {
if ( ! getScope() ) {
throw Error(
Expand All @@ -87,7 +152,7 @@ export const getElement = () => {

export const getScope = () => scopeStack.slice( -1 )[ 0 ];

DAreRodz marked this conversation as resolved.
Show resolved Hide resolved
export const setScope = ( scope ) => {
export const setScope = ( scope: Scope ) => {
scopeStack.push( scope );
};
export const resetScope = () => {
Expand All @@ -102,8 +167,8 @@ export const resetNamespace = () => {
};

// WordPress Directives.
const directiveCallbacks = {};
const directivePriorities = {};
const directiveCallbacks: Record< string, DirectiveCallback > = {};
const directivePriorities: Record< string, number > = {};

/**
* Register a new directive type in the Interactivity API runtime.
Expand All @@ -112,34 +177,37 @@ const directivePriorities = {};
* ```js
* directive(
* 'alert', // Name without the `data-wp-` prefix.
* ( { directives: { alert }, element, evaluate }) => {
* element.props.onclick = () => {
* alert( evaluate( alert.default ) );
* }
* ( { directives: { alert }, element, evaluate } ) => {
* const defaultEntry = alert.find( entry => entry.suffix === 'default' );
* element.props.onclick = () => { alert( evaluate( defaultEntry ) ); }
* }
* )
* ```
*
* The previous code registers a custom directive type for displaying an alert
* message whenever an element using it is clicked. The message text is obtained
* from the store using `evaluate`.
* from the store under the inherited namespace, using `evaluate`.
*
* When the HTML is processed by the Interactivity API, any element containing
* the `data-wp-alert` directive will have the `onclick` event handler, e.g.,
*
* ```html
* <button data-wp-alert="state.messages.alert">Click me!</button>
* <div data-wp-interactive='{ "namespace": "messages" }'>
* <button data-wp-alert="state.alert">Click me!</button>
* </div>
* ```
* Note that, in the previous example, you access `alert.default` in order to
* retrieve the `state.messages.alert` value passed to the directive. You can
* also define custom names by appending `--` to the directive attribute,
* followed by a suffix, like in the following HTML snippet:
* Note that, in the previous example, the directive callback gets the path
* value (`state.alert`) from the directive entry with suffix `default`. A
* custom suffix can also be specified by appending `--` to the directive
* attribute, followed by the suffix, like in the following HTML snippet:
*
* ```html
* <button
* data-wp-color--text="state.theme.text"
* data-wp-color--background="state.theme.background"
* >Click me!</button>
* <div data-wp-interactive='{ "namespace": "myblock" }'>
* <button
* data-wp-color--text="state.text"
* data-wp-color--background="state.background"
* >Click me!</button>
* </div>
* ```
*
* This could be an hypothetical implementation of the custom directive used in
Expand All @@ -149,28 +217,36 @@ const directivePriorities = {};
* ```js
* directive(
* 'color', // Name without prefix and suffix.
* ( { directives: { color }, ref, evaluate }) => {
* if ( color.text ) {
* ref.style.setProperty(
* 'color',
* evaluate( color.text )
* );
* }
* if ( color.background ) {
* ref.style.setProperty(
* 'background-color',
* evaluate( color.background )
* );
* }
* ( { directives: { color }, ref, evaluate } ) =>
* colors.forEach( ( color ) => {
* if ( color.suffix = 'text' ) {
* ref.style.setProperty(
* 'color',
* evaluate( color.text )
* );
* }
* if ( color.suffix = 'background' ) {
* ref.style.setProperty(
* 'background-color',
* evaluate( color.background )
* );
* }
* } );
* }
* )
* ```
*
* @param {string} name Directive name, without the `data-wp-` prefix.
* @param {DirectiveCallback} callback Function that runs the directive logic.
* @param {DirectiveOptions=} options Options object.
* @param name Directive name, without the `data-wp-` prefix.
* @param callback Function that runs the directive logic.
* @param options Options object.
* @param options.priority Option to control the directive execution order. The
* lesser, the highest priority. Default is `10`.
*/
export const directive = ( name, callback, { priority = 10 } = {} ) => {
export const directive = (
name: string,
callback: DirectiveCallback,
{ priority = 10 }: DirectiveOptions = {}
) => {
directiveCallbacks[ name ] = callback;
directivePriorities[ name ] = priority;
};
Expand All @@ -186,10 +262,13 @@ const resolve = ( path, namespace ) => {
};

// Generate the evaluate function.
const getEvaluate =
( { scope } = {} ) =>
const getEvaluate: GetEvaluate =
( { scope } ) =>
( entry, ...args ) => {
let { value: path, namespace } = entry;
if ( typeof path !== 'string' ) {
throw new Error( 'The `value` prop should be a string path' );
}
// If path starts with !, remove it and save a flag.
const hasNegationOperator =
path[ 0 ] === '!' && !! ( path = path.slice( 1 ) );
Expand All @@ -202,8 +281,10 @@ const getEvaluate =

// Separate directives by priority. The resulting array contains objects
// of directives grouped by same priority, and sorted in ascending order.
const getPriorityLevels = ( directives ) => {
const byPriority = Object.keys( directives ).reduce( ( obj, name ) => {
const getPriorityLevels: GetPriorityLevels = ( directives ) => {
const byPriority = Object.keys( directives ).reduce<
Record< number, string[] >
>( ( obj, name ) => {
if ( directiveCallbacks[ name ] ) {
const priority = directivePriorities[ name ];
( obj[ priority ] = obj[ priority ] || [] ).push( name );
Expand All @@ -212,7 +293,7 @@ const getPriorityLevels = ( directives ) => {
}, {} );

return Object.entries( byPriority )
.sort( ( [ p1 ], [ p2 ] ) => p1 - p2 )
.sort( ( [ p1 ], [ p2 ] ) => parseInt( p1 ) - parseInt( p2 ) )
.map( ( [ , arr ] ) => arr );
};

Expand All @@ -222,17 +303,17 @@ const Directives = ( {
priorityLevels: [ currentPriorityLevel, ...nextPriorityLevels ],
element,
originalProps,
previousScope = {},
} ) => {
previousScope,
}: DirectivesProps ) => {
// Initialize the scope of this element. These scopes are different per each
// level because each level has a different context, but they share the same
// element ref, state and props.
const scope = useRef( {} ).current;
const scope = useRef< Scope >( {} as Scope ).current;
scope.evaluate = useCallback( getEvaluate( { scope } ), [] );
scope.context = useContext( context );
/* eslint-disable react-hooks/rules-of-hooks */
scope.ref = previousScope.ref || useRef( null );
scope.state = previousScope.state || useRef( deepSignal( {} ) ).current;
scope.ref = previousScope?.ref || useRef( null );
scope.state = previousScope?.state || useRef( deepSignal( {} ) ).current;
/* eslint-enable react-hooks/rules-of-hooks */

// Create a fresh copy of the vnode element and add the props to the scope.
Expand Down Expand Up @@ -276,7 +357,7 @@ const Directives = ( {

// Preact Options Hook called each time a vnode is created.
const old = options.vnode;
options.vnode = ( vnode ) => {
options.vnode = ( vnode: VNode< any > ) => {
if ( vnode.props.__directives ) {
const props = vnode.props;
const directives = props.__directives;
Expand All @@ -292,7 +373,7 @@ options.vnode = ( vnode ) => {
priorityLevels,
originalProps: props,
type: vnode.type,
element: h( vnode.type, props ),
element: h( vnode.type as any, props ),
top: true,
};
vnode.type = Directives;
Expand Down
Loading
Loading