diff --git a/packages/interactivity/src/hooks.tsx b/packages/interactivity/src/hooks.tsx index f782d99849862..14f0dc6683b46 100644 --- a/packages/interactivity/src/hooks.tsx +++ b/packages/interactivity/src/hooks.tsx @@ -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} 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(); @@ -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( @@ -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 = () => { @@ -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. @@ -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 - * + *
+ * + *
* ``` - * 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 - * + *
+ * + *
* ``` * * This could be an hypothetical implementation of the custom directive used in @@ -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; }; @@ -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 ) ); @@ -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 ); @@ -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 ); }; @@ -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. @@ -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; @@ -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; diff --git a/packages/interactivity/src/router.js b/packages/interactivity/src/router.js index 68d1bc677addf..1082d43ff3a6a 100644 --- a/packages/interactivity/src/router.js +++ b/packages/interactivity/src/router.js @@ -59,8 +59,18 @@ const regionsToVdom = ( dom ) => { return { regions, title }; }; -// Prefetch a page. We store the promise to avoid triggering a second fetch for -// a page if a fetching has already started. +/** + * Prefetchs the page with the passed URL. + * + * The function normalizes the URL and stores internally the fetch promise, to + * avoid triggering a second fetch for an ongoing request. + * + * @param {string} url The page URL. + * @param {Object} [options] Options object. + * @param {boolean} [options.force] Force fetching the URL again. + * @param {string} [options.html] HTML string to be used instead of fetching + * the requested URL. + */ export const prefetch = ( url, options = {} ) => { url = cleanUrl( url ); if ( options.force || ! pages.has( url ) ) { @@ -84,7 +94,26 @@ const renderRegions = ( page ) => { // Variable to store the current navigation. let navigatingTo = ''; -// Navigate to a new page. +/** + * Navigates to the specified page. + * + * This function normalizes the passed href, fetchs the page HTML if needed, and + * updates any interactive regions whose contents have changed. It also creates + * a new entry in the browser session history. + * + * @param {string} href The page href. + * @param {Object} [options] Options object. + * @param {boolean} [options.force] If true, it forces re-fetching the URL. + * @param {string} [options.html] HTML string to be used instead of fetching + * the requested URL. + * @param {boolean} [options.replace] If true, it replaces the current entry in + * the browser session history. + * @param {number} [options.timeout] Time until the navigation is aborted, in + * milliseconds. Default is 10000. + * + * @return {Promise} Promise that resolves once the navigation is completed or + * aborted. + */ export const navigate = async ( href, options = {} ) => { const url = cleanUrl( href ); navigatingTo = href; diff --git a/packages/interactivity/src/store.ts b/packages/interactivity/src/store.ts index 1e9ab7e1a8f46..8463d1a0a5132 100644 --- a/packages/interactivity/src/store.ts +++ b/packages/interactivity/src/store.ts @@ -164,71 +164,88 @@ const handlers = { return result; }, }; +interface StoreOptions { + /** + * Property to block/unblock private store namespaces. + * + * If the passed value is `true`, it blocks the given namespace, making it + * accessible only trough the returned variables of the `store()` call. In + * the case a lock string is passed, it also blocks the namespace, but can + * be unblocked for other `store()` calls using the same lock string. + * + * @example + * ``` + * // The store can only be accessed where the `state` const can. + * const { state } = store( 'myblock/private', { ... }, { lock: true } ); + * ``` + * + * @example + * ``` + * // Other modules knowing `SECRET_LOCK_STRING` can access the namespace. + * const { state } = store( + * 'myblock/private', + * { ... }, + * { lock: 'SECRET_LOCK_STRING' } + * ); + * ``` + */ + lock?: boolean | string; +} -/** - * @typedef StoreProps Properties object passed to `store`. - * @property {Object} state State to be added to the global store. All the - * properties included here become reactive. - */ - -/** - * @typedef StoreOptions Options object. - */ +const universalUnlock = + 'I acknowledge that using a private store means my plugin will inevitably break on the next store release.'; /** - * Extends the Interactivity API global store with the passed properties. + * Extends the Interactivity API global store adding the passed properties to + * the given namespace. It also returns stable references to the namespace + * content. * - * These props typically consist of `state`, which is reactive, and other - * properties like `selectors`, `actions`, `effects`, etc. which can store - * callbacks and derived state. These props can then be referenced by any - * directive to make the HTML interactive. + * These props typically consist of `state`, which is the reactive part of the + * store ― which means that any directive referencing a state property will be + * re-rendered anytime it changes ― and function properties like `actions` and + * `callbacks`, mostly used for event handlers. These props can then be + * referenced by any directive to make the HTML interactive. * * @example * ```js - * store({ + * const { state } = store( 'counter', { * state: { - * counter: { value: 0 }, + * value: 0, + * get double() { return state.value * 2; }, * }, * actions: { - * counter: { - * increment: ({ state }) => { - * state.counter.value += 1; - * }, + * increment() { + * state.value += 1; * }, * }, - * }); + * } ); * ``` * * The code from the example above allows blocks to subscribe and interact with * the store by using directives in the HTML, e.g.: * * ```html - *
+ *
* *
* ``` + * @param namespace The store namespace to interact with. + * @param storePart Properties to add to the store namespace. + * @param options Options for the given namespace. * - * @param {StoreProps} properties Properties to be added to the global store. - * @param {StoreOptions} [options] Options passed to the `store` call. + * @return A reference to the namespace content. */ - -interface StoreOptions { - lock?: boolean | string; -} - -const universalUnlock = - 'I acknowledge that using a private store means my plugin will inevitably break on the next store release.'; - export function store< S extends object = {} >( namespace: string, storePart?: S, options?: StoreOptions ): S; + export function store< T extends object >( namespace: string, storePart?: T,