Skip to content

Commit

Permalink
Interactivity API: update TS/JSDocs after migrating to the new `store…
Browse files Browse the repository at this point in the history
…()` API (#56748)

* Update tsdocs for `store()`

* Fix TS errors in hooks.tsx

* Minor changes to context types

* Rename DirectiveCallback params to args

* Update directive() tsdocs

* Add tsdocs for `getContext` and `getElement`

* Add jsdocs for `navigate` and `prefetch`

* Fix previousScope ref and state

* Remove unnecessary `!` operator

* Add removed comments for `DirectiveArgs`

* Fix example format in `directive()`
  • Loading branch information
DAreRodz authored and cbravobernal committed Dec 7, 2023
1 parent 610c8f5 commit fa76e8a
Show file tree
Hide file tree
Showing 3 changed files with 236 additions and 109 deletions.
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 {
/**
* 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 ];

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

0 comments on commit fa76e8a

Please sign in to comment.