diff --git a/packages/interactivity/README.md b/packages/interactivity/README.md index 0ea49bdea0782f..7aae18b80f17b6 100644 --- a/packages/interactivity/README.md +++ b/packages/interactivity/README.md @@ -1,17 +1,17 @@ # Interactivity API > **Warning** -> **This package is only available in Gutenberg** at the moment and not in WordPress Core as it is still very experimental, and very likely to change. +> **This package is only available in Gutenberg** at the moment and not in WordPress Core as it is still very experimental, and very likely to change. > **Note** -> This package enables the API shared at [Proposal: The Interactivity API – A better developer experience in building interactive blocks](https://make.wordpress.org/core/2023/03/30/proposal-the-interactivity-api-a-better-developer-experience-in-building-interactive-blocks/). As part of an [Open Source project](https://developer.wordpress.org/block-editor/getting-started/faq/#the-gutenberg-project) we encourage participation in helping shape this API and the [discussions in GitHub](https://github.com/WordPress/gutenberg/discussions/categories/interactivity-api) is the best place to engage. +> This package enables the API shared at [Proposal: The Interactivity API – A better developer experience in building interactive blocks](https://make.wordpress.org/core/2023/03/30/proposal-the-interactivity-api-a-better-developer-experience-in-building-interactive-blocks/). As part of an [Open Source project](https://developer.wordpress.org/block-editor/getting-started/faq/#the-gutenberg-project) participation is encouraged in helping shape this API and the [discussions in GitHub](https://github.com/WordPress/gutenberg/discussions/categories/interactivity-api) is the best place to engage. This package can be tested, but it's still very experimental. -The Interactivity API is [being used in some core blocks](https://github.com/search?q=repo%3AWordPress%2Fgutenberg%20%40wordpress%2Finteractivity&type=code) but its use is still very limited. +The Interactivity API is [being used in some core blocks](https://github.com/search?q=repo%3AWordPress%2Fgutenberg%20%40wordpress%2Finteractivity&type=code) but its use is still very limited. -## Frequently Asked Questions +## Frequently Asked Questions At this point, some of the questions you have about the Interactivity API may be: diff --git a/packages/interactivity/src/constants.js b/packages/interactivity/src/constants.ts similarity index 100% rename from packages/interactivity/src/constants.js rename to packages/interactivity/src/constants.ts diff --git a/packages/interactivity/src/directives.js b/packages/interactivity/src/directives.tsx similarity index 85% rename from packages/interactivity/src/directives.js rename to packages/interactivity/src/directives.tsx index 9184fb1d6d8035..0015e804f488e0 100644 --- a/packages/interactivity/src/directives.js +++ b/packages/interactivity/src/directives.tsx @@ -5,6 +5,7 @@ */ import { h as createElement } from 'preact'; import { useContext, useMemo, useRef } from 'preact/hooks'; +import type { DeepSignal } from 'deepsignal'; import { deepSignal, peek } from 'deepsignal'; /** @@ -13,11 +14,44 @@ import { deepSignal, peek } from 'deepsignal'; import { useWatch, useInit } from './utils'; import { directive, getScope, getEvaluate } from './hooks'; import { kebabToCamelCase } from './utils/kebab-to-camelcase'; +import type { DirectiveArgs } from './types'; -const isObject = ( item ) => +/** + * Checks if the provided item is an object and not an array. + * + * @param item The item to check. + * + * @return Whether the item is an object. + * + * @example + * isObject({}); // returns true + * isObject([]); // returns false + * isObject('string'); // returns false + * isObject(null); // returns false + */ +const isObject = ( item: any ): boolean => item && typeof item === 'object' && ! Array.isArray( item ); -const mergeDeepSignals = ( target, source, overwrite ) => { +/** + * Recursively merges properties from the source object into the target object. + * If `overwrite` is true, existing properties in the target object are overwritten by properties in the source object with the same key. + * If `overwrite` is false, existing properties in the target object are preserved. + * + * @param target - The target object that properties are merged into. + * @param source - The source object that properties are merged from. + * @param overwrite - Whether to overwrite existing properties in the target object. + * + * @example + * const target = { $key1: { peek: () => ({ a: 1 }) }, $key2: { peek: () => ({ b: 2 }) } }; + * const source = { $key1: { peek: () => ({ a: 3 }) }, $key3: { peek: () => ({ c: 3 }) } }; + * mergeDeepSignals(target, source, true); + * // target is now { $key1: { peek: () => ({ a: 3 }) }, $key2: { peek: () => ({ b: 2 }) }, $key3: { peek: () => ({ c: 3 }) } } + */ +const mergeDeepSignals = ( + target: DeepSignal< any >, + source: DeepSignal< any >, + overwrite?: boolean +) => { for ( const k in source ) { if ( isObject( peek( target, k ) ) && isObject( peek( source, k ) ) ) { mergeDeepSignals( @@ -43,10 +77,10 @@ const empty = ' '; * Made by Cristian Bote (@cristianbote) for Goober. * https://unpkg.com/browse/goober@2.1.13/src/core/astish.js * - * @param {string} val CSS string. - * @return {Object} CSS object. + * @param val CSS string. + * @return CSS object. */ -const cssStringToObject = ( val ) => { +const cssStringToObject = ( val: string ): Object => { const tree = [ {} ]; let block, left; @@ -70,22 +104,21 @@ const cssStringToObject = ( val ) => { * Creates a directive that adds an event listener to the global window or * document object. * - * @param {string} type 'window' or 'document' - * @return {void} + * @param type 'window' or 'document' */ const getGlobalEventDirective = - ( type ) => - ( { directives, evaluate } ) => { + ( type: 'window' | 'document' ) => + ( { directives, evaluate }: DirectiveArgs ): void => { directives[ `on-${ type }` ] .filter( ( { suffix } ) => suffix !== 'default' ) .forEach( ( entry ) => { useInit( () => { - const cb = ( event ) => evaluate( entry, event ); + const cb = ( event: Event ) => evaluate( entry, event ); const globalVar = type === 'window' ? window : document; globalVar.addEventListener( entry.suffix, cb ); return () => globalVar.removeEventListener( entry.suffix, cb ); - }, [] ); + } ); } ); }; @@ -121,6 +154,7 @@ export default () => { ); } + return undefined; }, { priority: 5 } ); @@ -195,7 +229,7 @@ export default () => { } ); - // data-wp-style--[style-prop] + // data-wp-style--[style-key] directive( 'style', ( { directives: { style }, element, evaluate } ) => { style .filter( ( { suffix } ) => suffix !== 'default' ) diff --git a/packages/interactivity/src/hooks.tsx b/packages/interactivity/src/hooks.tsx index 726579f50176dc..bf5c3ef50add24 100644 --- a/packages/interactivity/src/hooks.tsx +++ b/packages/interactivity/src/hooks.tsx @@ -10,86 +10,19 @@ import { cloneElement, } from 'preact'; import { useRef, useCallback, useContext } from 'preact/hooks'; -import type { VNode, Context, RefObject } from 'preact'; +import type { VNode } from 'preact'; /** * Internal dependencies */ import { stores } from './store'; -interface DirectiveEntry { - value: string | Object; - namespace: string; - suffix: string; -} - -type DirectiveEntries = Record< string, DirectiveEntry[] >; - -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; -} - -interface DirectiveCallback { - ( args: DirectiveArgs ): VNode | void; -} - -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 >; - attributes: createElement.JSX.HTMLAttributes; -} - -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; -} +import type { + DirectiveCallback, + DirectiveOptions, + DirectivesProps, + GetPriorityLevels, + Scope, +} from './types'; // Main context. const context = createContext< any >( {} ); @@ -117,8 +50,10 @@ const deepImmutable = < T extends Object = {} >( target: T ): T => { return immutableMap.get( target ); }; -// Store stacks for the current scope and the default namespaces and export APIs -// to interact with them. +/* + * Store stacks for the current scope and the default namespaces and export APIs + * to interact with them. + */ const scopeStack: Scope[] = []; const namespaceStack: string[] = []; @@ -154,21 +89,22 @@ export const getElement = () => { } ); }; -export const getScope = () => scopeStack.slice( -1 )[ 0 ]; +export const getScope = (): Scope | undefined => scopeStack.slice( -1 )[ 0 ]; -export const setScope = ( scope: Scope ) => { +export const setScope = ( scope: Scope ): void => { scopeStack.push( scope ); }; -export const resetScope = () => { +export const resetScope = (): void => { scopeStack.pop(); }; -export const getNamespace = () => namespaceStack.slice( -1 )[ 0 ]; +export const getNamespace = (): string | undefined => + namespaceStack.slice( -1 )[ 0 ]; -export const setNamespace = ( namespace: string ) => { +export const setNamespace = ( namespace: string ): void => { namespaceStack.push( namespace ); }; -export const resetNamespace = () => { +export const resetNamespace = (): void => { namespaceStack.pop(); }; @@ -268,7 +204,7 @@ const resolve = ( path, namespace ) => { }; // Generate the evaluate function. -export const getEvaluate: GetEvaluate = +export const getEvaluate: any = ( { scope } ) => ( entry, ...args ) => { let { value: path, namespace } = entry; @@ -285,8 +221,10 @@ export const getEvaluate: GetEvaluate = return hasNegationOperator ? ! result : result; }; -// Separate directives by priority. The resulting array contains objects -// of directives grouped by same priority, and sorted in ascending order. +/* + * Separate directives by priority. The resulting array contains objects + * of directives grouped by same priority, and sorted in ascending order. + */ const getPriorityLevels: GetPriorityLevels = ( directives ) => { const byPriority = Object.keys( directives ).reduce< Record< number, string[] > @@ -311,9 +249,11 @@ const Directives = ( { originalProps, 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. + /* + * 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< Scope >( {} as Scope ).current; scope.evaluate = useCallback( getEvaluate( { scope } ), [] ); scope.context = useContext( context ); @@ -321,8 +261,10 @@ const Directives = ( { scope.ref = previousScope?.ref || useRef( null ); /* eslint-enable react-hooks/rules-of-hooks */ - // Create a fresh copy of the vnode element and add the props to the scope, - // named as attributes (HTML Attributes). + /* + * Create a fresh copy of the vnode element and add the props to the scope, + * named as attributes (HTML Attributes). + */ element = cloneElement( element, { ref: scope.ref } ); scope.attributes = element.props; diff --git a/packages/interactivity/src/init.js b/packages/interactivity/src/init.ts similarity index 78% rename from packages/interactivity/src/init.js rename to packages/interactivity/src/init.ts index fb510a9c00fced..20072bdb192f63 100644 --- a/packages/interactivity/src/init.js +++ b/packages/interactivity/src/init.ts @@ -11,7 +11,7 @@ import { directivePrefix } from './constants'; // Keep the same root fragment for each interactive region node. const regionRootFragments = new WeakMap(); -export const getRegionRootFragment = ( region ) => { +export const getRegionRootFragment = ( region: Node ): Node => { if ( ! regionRootFragments.has( region ) ) { regionRootFragments.set( region, @@ -21,7 +21,7 @@ export const getRegionRootFragment = ( region ) => { return regionRootFragments.get( region ); }; -function yieldToMain() { +function yieldToMain(): Promise< void > { return new Promise( ( resolve ) => { // TODO: Use scheduler.yield() when available. setTimeout( resolve, 0 ); @@ -32,12 +32,12 @@ function yieldToMain() { export const initialVdom = new WeakMap(); // Initialize the router with the initial DOM. -export const init = async () => { - const nodes = document.querySelectorAll( +export const init = async (): Promise< void > => { + const nodes: NodeListOf< Element > = document.querySelectorAll( `[data-${ directivePrefix }-interactive]` ); - - for ( const node of nodes ) { + const nodesArray: Element[] = Array.from( nodes ); + for ( const node of nodesArray ) { if ( ! hydratedIslands.has( node ) ) { await yieldToMain(); const fragment = getRegionRootFragment( node ); diff --git a/packages/interactivity/src/store.ts b/packages/interactivity/src/store.ts index b971bbcd1590cd..363b76b8328b92 100644 --- a/packages/interactivity/src/store.ts +++ b/packages/interactivity/src/store.ts @@ -15,6 +15,7 @@ import { setNamespace, resetNamespace, } from './hooks'; +import type { StoreOptions } from './types'; const isObject = ( item: unknown ): boolean => !! item && typeof item === 'object' && ! Array.isArray( item ); @@ -71,9 +72,11 @@ const handlers = { get: ( target: any, key: string | symbol, receiver: any ) => { const ns = proxyToNs.get( receiver ); - // Check if the property is a getter and we are inside an scope. If that is - // the case, we clone the getter to avoid overwriting the scoped - // dependencies of the computed each time that getter runs. + /* + * Check if the property is a getter and is inside an scope. If that is + * the case, clone the getter to avoid overwriting the scoped + * dependencies of the computed each time that getter runs. + */ const getter = Object.getOwnPropertyDescriptor( target, key )?.get; if ( getter ) { const scope = getScope(); @@ -102,17 +105,21 @@ const handlers = { const result = Reflect.get( target, key, receiver ); - // Check if the proxy is the store root and no key with that name exist. In - // that case, return an empty object for the requested key. + /* + * Check if the proxy is the store root and no key with that name exist. In + * that case, return an empty object for the requested key. + */ if ( typeof result === 'undefined' && receiver === stores.get( ns ) ) { const obj = {}; Reflect.set( target, key, obj, receiver ); return proxify( obj, ns ); } - // Check if the property is a generator. If it is, we turn it into an - // asynchronous function where we restore the default namespace and scope - // each time it awaits/yields. + /* + * Check if the property is a generator. If it is, turn it into an + * asynchronous function where the default namespace and scope are restored + * each time it awaits/yields. + */ if ( result?.constructor?.name === 'GeneratorFunction' ) { return async ( ...args: unknown[] ) => { const scope = getScope(); @@ -144,9 +151,11 @@ const handlers = { }; } - // Check if the property is a synchronous function. If it is, set the - // default namespace. Synchronous functions always run in the proper scope, - // which is set by the Directives component. + /* + * Check if the property is a synchronous function. If it is, set the + * default namespace. Synchronous functions always run in the proper scope, + * which is set by the Directives component. + */ if ( typeof result === 'function' ) { return ( ...args: unknown[] ) => { setNamespace( ns ); @@ -174,34 +183,6 @@ const handlers = { export const getConfig = ( namespace: string ) => storeConfigs.get( namespace || getNamespace() ) || {}; -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; -} - const universalUnlock = 'I acknowledge that using a private store means my plugin will inevitably break on the next store release.'; @@ -268,9 +249,11 @@ export function store( { lock = false }: StoreOptions = {} ) { if ( ! stores.has( namespace ) ) { - // Lock the store if the passed lock is different from the universal - // unlock. Once the lock is set (either false, true, or a given string), - // it cannot change. + /* + * Lock the store if the passed lock is different from the universal + * unlock. Once the lock is set (either false, true, or a given string), + * it cannot change. + */ if ( lock !== universalUnlock ) { storeLocks.set( namespace, lock ); } @@ -280,9 +263,11 @@ export function store( stores.set( namespace, proxiedStore ); proxyToNs.set( proxiedStore, namespace ); } else { - // Lock the store if it wasn't locked yet and the passed lock is - // different from the universal unlock. If no lock is given, the store - // will be public and won't accept any lock from now on. + /* + * Lock the store if it wasn't locked yet and the passed lock is + * different from the universal unlock. If no lock is given, the store + * will be public and won't accept any lock from now on. + */ if ( lock !== universalUnlock && ! storeLocks.has( namespace ) ) { storeLocks.set( namespace, lock ); } else { diff --git a/packages/interactivity/src/types.ts b/packages/interactivity/src/types.ts new file mode 100644 index 00000000000000..3d655038b0d943 --- /dev/null +++ b/packages/interactivity/src/types.ts @@ -0,0 +1,129 @@ +/** + * External dependencies + */ +import type { + VNode, + Context, + RefObject, + h as createElement, + ContainerNode, + ComponentChildren, +} from 'preact'; + +interface DirectiveEntry { + value: string | Object; + namespace: string; + suffix: string; +} + +export interface Scope { + evaluate: Evaluate; + context: Context< any >; + ref: RefObject< HTMLElement >; + attributes: createElement.JSX.HTMLAttributes; +} + +interface Evaluate { + ( entry: DirectiveEntry, ...args: any[] ): any; +} + +type DirectiveEntries = Record< string, DirectiveEntry[] >; + +export interface DirectiveArgs { + /** + * Object map with the defined directives of the element being evaluated. + */ + directives: DirectiveEntries; + /** + * Props present in the current element. + */ + props?: Record< string, any >; + /** + * Virtual node representing the element. + */ + element?: any; + /** + * The inherited context. + */ + context?: Context< any >; + /** + * Function that resolves a given path to a value either in the store or the + * context. + */ + evaluate: Evaluate; +} + +export interface DirectiveCallback { + ( args: DirectiveArgs ): VNode | void; +} + +export interface DirectiveOptions { + /** + * Value that specifies the priority to evaluate directives of this type. + * Lower numbers correspond with earlier execution. + * + * @default 10 + */ + priority?: number; +} + +export interface DirectivesProps { + directives: DirectiveEntries; + priorityLevels: PriorityLevel[]; + element: VNode; + originalProps: any; + previousScope?: Scope; +} + +type PriorityLevel = string[]; + +export interface GetPriorityLevels { + ( directives: DirectiveEntries ): PriorityLevel[]; +} + +export type EffectFunction = { + c: () => void; + x: () => void; +}; + +export 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; +} + +export interface PortalInterface extends VNode { + containerInfo?: ContainerNode; +} + +export interface PortalProps { + _container: ContainerNode; + _vnode: VNode; +} + +export interface ContextProviderProps { + context: any; + children?: ComponentChildren; +} diff --git a/packages/interactivity/src/utils.js b/packages/interactivity/src/utils.ts similarity index 62% rename from packages/interactivity/src/utils.js rename to packages/interactivity/src/utils.ts index 84e04803cea4f5..6169e713df5f15 100644 --- a/packages/interactivity/src/utils.js +++ b/packages/interactivity/src/utils.ts @@ -8,6 +8,7 @@ import { useLayoutEffect as _useLayoutEffect, } from 'preact/hooks'; import { effect } from '@preact/signals'; +import type { ContainerNode } from 'preact'; /** * Internal dependencies @@ -20,8 +21,9 @@ import { setNamespace, resetNamespace, } from './hooks'; +import type { Scope, EffectFunction } from './types'; -const afterNextFrame = ( callback ) => { +const afterNextFrame = ( callback: () => void ): Promise< void > => { return new Promise( ( resolve ) => { const done = () => { clearTimeout( timeout ); @@ -36,13 +38,19 @@ const afterNextFrame = ( callback ) => { } ); }; -// 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 () { +/* + * Using the mangled properties: + * this.c: this._callback + * this.x: this._compute + * https://github.com/preactjs/signals/blob/main/mangle.json + */ + +function createFlusher( + compute: () => void, + notify: () => void +): { flush: () => void; dispose: () => void } { + let flush: () => void; + const dispose = effect( function ( this: EffectFunction ) { flush = this.c.bind( this ); this.x = compute; this.c = notify; @@ -51,12 +59,14 @@ function createFlusher( compute, notify ) { return { flush, dispose }; } -// Version of `useSignalEffect` with a `useEffect`-like execution. This hook -// implementation comes from this PR, but we added short-cirtuiting to avoid -// infinite loops: https://github.com/preactjs/signals/pull/290 -export function useSignalEffect( callback ) { +/* + * Version of `useSignalEffect` with a `useEffect`-like execution. This hook + * implementation comes from this PR, but short-cirtuiting was added to avoid + * infinite loops: https://github.com/preactjs/signals/pull/290 + */ +export function useSignalEffect( callback: () => void ): void { _useEffect( () => { - let eff = null; + let eff: { flush: () => void; dispose: () => void } | null = null; let isExecuting = false; const notify = async () => { if ( eff && ! isExecuting ) { @@ -74,18 +84,16 @@ export function useSignalEffect( callback ) { * Returns the passed function wrapped with the current scope so it is * accessible whenever the function runs. This is primarily to make the scope * available inside hook callbacks. - * - * @param {Function} func The passed function. - * @return {Function} The wrapped function. */ -export const withScope = ( func ) => { - const scope = getScope(); - const ns = getNamespace(); + +export const withScope = ( func: any ): any => { + const scope: Scope = getScope(); + const ns: string | undefined = getNamespace(); if ( func?.constructor?.name === 'GeneratorFunction' ) { - return async ( ...args ) => { + return async ( ...args: any[] ) => { const gen = func( ...args ); - let value; - let it; + let value: any; + let it: IteratorResult< any >; while ( true ) { setNamespace( ns ); setScope( scope ); @@ -105,7 +113,7 @@ export const withScope = ( func ) => { return value; }; } - return ( ...args ) => { + return ( ...args: unknown[] ) => { setNamespace( ns ); setScope( scope ); try { @@ -124,10 +132,9 @@ export const withScope = ( func ) => { * * This hook makes the element's scope available so functions like * `getElement()` and `getContext()` can be used inside the passed callback. - * - * @param {Function} callback The hook callback. + * @param callback */ -export function useWatch( callback ) { +export function useWatch( callback: Function ): void { useSignalEffect( withScope( callback ) ); } @@ -137,10 +144,9 @@ export function useWatch( callback ) { * * This hook makes the element's scope available so functions like * `getElement()` and `getContext()` can be used inside the passed callback. - * - * @param {Function} callback The hook callback. + * @param callback */ -export function useInit( callback ) { +export function useInit( callback: Function ): void { _useEffect( withScope( callback ), [] ); } @@ -152,12 +158,12 @@ export function useInit( callback ) { * available so functions like `getElement()` and `getContext()` can be used * inside the passed callback. * - * @param {Function} callback Imperative function that can return a cleanup - * function. - * @param {any[]} inputs If present, effect will only activate if the - * values in the list change (using `===`). + * @param callback Imperative function that can return a cleanup + * function. + * @param inputs If present, effect will only activate if the + * values in the list change (using `===`). */ -export function useEffect( callback, inputs ) { +export function useEffect( callback: Function, inputs: any[] ): void { _useEffect( withScope( callback ), inputs ); } @@ -169,12 +175,12 @@ export function useEffect( callback, inputs ) { * scope available so functions like `getElement()` and `getContext()` can be * used inside the passed callback. * - * @param {Function} callback Imperative function that can return a cleanup - * function. - * @param {any[]} inputs If present, effect will only activate if the - * values in the list change (using `===`). + * @param callback Imperative function that can return a cleanup + * function. + * @param inputs If present, effect will only activate if the + * values in the list change (using `===`). */ -export function useLayoutEffect( callback, inputs ) { +export function useLayoutEffect( callback: Function, inputs: any[] ): void { _useLayoutEffect( withScope( callback ), inputs ); } @@ -186,12 +192,12 @@ export function useLayoutEffect( callback, inputs ) { * scope available so functions like `getElement()` and `getContext()` can be * used inside the passed callback. * - * @param {Function} callback Imperative function that can return a cleanup - * function. - * @param {any[]} inputs If present, effect will only activate if the - * values in the list change (using `===`). + * @param callback Imperative function that can return a cleanup + * function. + * @param inputs If present, effect will only activate if the + * values in the list change (using `===`). */ -export function useCallback( callback, inputs ) { +export function useCallback( callback: Function, inputs: any[] ): void { _useCallback( withScope( callback ), inputs ); } @@ -208,27 +214,36 @@ export function useCallback( callback, inputs ) { * @param {any[]} inputs If present, effect will only activate if the * values in the list change (using `===`). */ -export function useMemo( factory, inputs ) { +export function useMemo( factory: Function, inputs: any[] ) { _useMemo( withScope( factory ), inputs ); } -// For wrapperless hydration. -// See https://gist.github.com/developit/f4c67a2ede71dc2fab7f357f39cff28c -export const createRootFragment = ( parent, replaceNode ) => { +/* + * For wrapperless hydration. + * See https://gist.github.com/developit/f4c67a2ede71dc2fab7f357f39cff28c + */ +export const createRootFragment = ( + parent: Node, + replaceNode: Node | Node[] +): ContainerNode => { replaceNode = [].concat( replaceNode ); const s = replaceNode[ replaceNode.length - 1 ].nextSibling; - function insert( c, r ) { - parent.insertBefore( c, r || s ); - } - return ( parent.__k = { + return ( ( parent as any ).__k = { nodeType: 1, - parentNode: parent, - firstChild: replaceNode[ 0 ], + parentNode: parent as ParentNode, + firstChild: replaceNode[ 0 ] as ChildNode, childNodes: replaceNode, - insertBefore: insert, - appendChild: insert, + insertBefore: ( c, r ) => { + parent.insertBefore( c, r || s ); + return c; + }, + appendChild: ( c ) => { + parent.insertBefore( c, s ); + return c; + }, removeChild( c ) { parent.removeChild( c ); + return c; }, } ); }; diff --git a/packages/interactivity/src/vdom.ts b/packages/interactivity/src/vdom.ts index 5a997993668094..ad73c4c48c31d4 100644 --- a/packages/interactivity/src/vdom.ts +++ b/packages/interactivity/src/vdom.ts @@ -15,22 +15,29 @@ const currentNamespace = () => namespaces[ namespaces.length - 1 ] ?? null; // 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". + `^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". + /* + * (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_-]+))?$', 'i' // Case insensitive. ); -// Regular expression for reference parsing. It can contain a namespace before -// the reference, separated by `::`, like `some-namespace::state.somePath`. -// Namespaces can contain any alphanumeric characters, hyphens, underscores or -// forward slashes. References don't have any restrictions. +/* + * Regular expression for reference parsing. It can contain a namespace before + * the reference, separated by `::`, like `some-namespace::state.somePath`. + * Namespaces can contain any alphanumeric characters, hyphens, underscores or + * forward slashes. References don't have any restrictions. + */ const nsPathRegExp = /^([\w-_\/]+)::(.+)$/; export const hydratedIslands = new WeakSet(); diff --git a/test/e2e/specs/interactivity/directive-bind.spec.ts b/test/e2e/specs/interactivity/directive-bind.spec.ts index 525fceab5ca6ae..626d62407bf892 100644 --- a/test/e2e/specs/interactivity/directive-bind.spec.ts +++ b/test/e2e/specs/interactivity/directive-bind.spec.ts @@ -112,7 +112,7 @@ test.describe( 'data-wp-bind', () => { */ values: Record< /** - * The type of value we are hydrating. E.g., false is `false`, + * The type of value being hydrated. E.g., false is `false`, * undef is `undefined`, emptyString is `''`, etc. */ string, @@ -224,11 +224,13 @@ test.describe( 'data-wp-bind', () => { propValue, ] ); - // Only check the rendered value if the new value is not - // `undefined` and the attibute is neither `value` nor - // `disabled` because Preact doesn't update the attribute - // for those cases. - // See https://github.com/preactjs/preact/blob/099c38c6ef92055428afbc116d18a6b9e0c2ea2c/src/diff/index.js#L471-L494 + /* + * Only check the rendered value if the new value is not + * `undefined` and the attibute is neither `value` nor + * `disabled` because Preact doesn't update the attribute + * for those cases. + * See https://github.com/preactjs/preact/blob/099c38c6ef92055428afbc116d18a6b9e0c2ea2c/src/diff/index.js#L471-L494 + */ if ( type === 'undef' && ( name === 'value' || name === 'undefined' ) diff --git a/test/e2e/specs/interactivity/directive-each.spec.ts b/test/e2e/specs/interactivity/directive-each.spec.ts index a9f09191685a3b..62bd5c2b0bf6b5 100644 --- a/test/e2e/specs/interactivity/directive-each.spec.ts +++ b/test/e2e/specs/interactivity/directive-each.spec.ts @@ -49,8 +49,10 @@ test.describe( 'data-wp-each', () => { test.beforeEach( async ( { page } ) => { const elements = page.getByTestId( 'fruits' ).getByTestId( 'item' ); - // These tags are included to check that the elements are not unmounted - // and mounted again. If an element remounts, its tag should be missing. + /* + * These tags are included to check that the elements are not unmounted + * and mounted again. If an element remounts, its tag should be missing. + */ await elements.evaluateAll( ( refs ) => refs.forEach( ( ref, index ) => { if ( ref instanceof HTMLElement ) { @@ -105,8 +107,10 @@ test.describe( 'data-wp-each', () => { 'cherimoya', ] ); - // Get the tags. They should not have disappeared or changed, - // except for the newly created element. + /* + * Get the tags. They should not have disappeared or changed, + * except for the newly created element. + */ const [ ananas, avocado, banana, cherimoya ] = await elements.all(); await expect( ananas ).not.toHaveAttribute( 'data-tag' ); await expect( avocado ).toHaveAttribute( 'data-tag', '0' ); @@ -125,8 +129,10 @@ test.describe( 'data-wp-each', () => { 'cherimoya', ] ); - // Get the tags. They should not have disappeared or changed, - // except for the newly created element. + /* + * Get the tags. They should not have disappeared or changed, + * except for the newly created element. + */ const [ ananas, banana, cherimoya ] = await elements.all(); await expect( ananas ).not.toHaveAttribute( 'data-tag' ); await expect( banana ).toHaveAttribute( 'data-tag', '1' ); @@ -138,8 +144,10 @@ test.describe( 'data-wp-each', () => { test.beforeEach( async ( { page } ) => { const elements = page.getByTestId( 'books' ).getByTestId( 'item' ); - // These tags are included to check that the elements are not unmounted - // and mounted again. If an element remounts, its tag should be missing. + /* + * These tags are included to check that the elements are not unmounted + * and mounted again. If an element remounts, its tag should be missing. + */ await elements.evaluateAll( ( refs ) => refs.forEach( ( ref, index ) => { if ( ref instanceof HTMLElement ) { @@ -202,8 +210,10 @@ test.describe( 'data-wp-each', () => { 'A Storm of Swords', ] ); - // Get the tags. They should not have disappeared or changed, - // except for the newly created element. + /* + * Get the tags. They should not have disappeared or changed, + * except for the newly created element. + */ const [ affc, agot, acok, asos ] = await elements.all(); await expect( affc ).not.toHaveAttribute( 'data-tag' ); await expect( agot ).toHaveAttribute( 'data-tag', '0' ); @@ -222,8 +232,10 @@ test.describe( 'data-wp-each', () => { 'A Storm of Swords', ] ); - // Get the tags. They should not have disappeared or changed, - // except for the newly created element. + /* + * Get the tags. They should not have disappeared or changed, + * except for the newly created element. + */ const [ affc, acok, asos ] = await elements.all(); await expect( affc ).not.toHaveAttribute( 'data-tag' ); await expect( acok ).toHaveAttribute( 'data-tag', '1' ); @@ -304,8 +316,10 @@ test.describe( 'data-wp-each', () => { .getByTestId( 'navigation-updated list' ) .getByTestId( 'item' ); - // These tags are included to check that the elements are not unmounted - // and mounted again. If an element remounts, its tag should be missing. + /* + * These tags are included to check that the elements are not unmounted + * and mounted again. If an element remounts, its tag should be missing. + */ await elements.evaluateAll( ( refs ) => refs.forEach( ( ref, index ) => { if ( ref instanceof HTMLElement ) { @@ -328,8 +342,10 @@ test.describe( 'data-wp-each', () => { 'delta', ] ); - // Get the tags. They should not have disappeared or changed, - // except for the newly created element. + /* + * Get the tags. They should not have disappeared or changed, + * except for the newly created element. + */ const [ alpha, beta, gamma, delta ] = await elements.all(); await expect( alpha ).not.toHaveAttribute( 'data-tag' ); await expect( beta ).toHaveAttribute( 'data-tag', '0' ); @@ -340,8 +356,10 @@ test.describe( 'data-wp-each', () => { test( 'should work with nested lists', async ( { page } ) => { const mainElement = page.getByTestId( 'nested' ); - // These tags are included to check that the elements are not unmounted - // and mounted again. If an element remounts, its tag should be missing. + /* + * These tags are included to check that the elements are not unmounted + * and mounted again. If an element remounts, its tag should be missing. + */ const listItems = mainElement.getByRole( 'listitem' ); await listItems.evaluateAll( ( refs ) => refs.forEach( ( ref, index ) => { @@ -465,8 +483,10 @@ test.describe( 'data-wp-each', () => { .getByTestId( 'derived state' ) .getByTestId( 'item' ); - // These tags are included to check that the elements are not unmounted - // and mounted again. If an element remounts, its tag should be missing. + /* + * These tags are included to check that the elements are not unmounted + * and mounted again. If an element remounts, its tag should be missing. + */ await elements.evaluateAll( ( refs ) => refs.forEach( ( ref, index ) => { if ( ref instanceof HTMLElement ) { diff --git a/test/e2e/specs/interactivity/directive-key.spec.ts b/test/e2e/specs/interactivity/directive-key.spec.ts index b780100b92a6dc..848e32385c8909 100644 --- a/test/e2e/specs/interactivity/directive-key.spec.ts +++ b/test/e2e/specs/interactivity/directive-key.spec.ts @@ -21,7 +21,7 @@ test.describe( 'data-wp-key', () => { test( 'should keep the elements when adding items to the start of the array', async ( { page, } ) => { - // Add a number to the node so we can check later that it is still there. + // Add a number to the node so can be checked later that it is still there. await page .getByTestId( 'first-item' ) .evaluate( ( n ) => ( ( n as any )._id = 123 ) ); diff --git a/test/e2e/specs/interactivity/directive-priorities.spec.ts b/test/e2e/specs/interactivity/directive-priorities.spec.ts index 56745bfad0c433..717e3481569ed1 100644 --- a/test/e2e/specs/interactivity/directive-priorities.spec.ts +++ b/test/e2e/specs/interactivity/directive-priorities.spec.ts @@ -33,8 +33,10 @@ test.describe( 'Directives (w/ priority)', () => { 'from context' ); - // Check that text value is correctly received from Provider, and text - // wrapped with an element with `data-testid=text`. + /* + * Check that text value is correctly received from Provider, and text + * wrapped with an element with `data-testid=text`. + */ const text = element.getByTestId( 'text' ); await expect( text ).toHaveText( 'from context' ); } ); @@ -54,10 +56,12 @@ test.describe( 'Directives (w/ priority)', () => { name: 'Update text', } ); - // Modify `attribute` inside context. This triggers a re-render for the - // component that wraps the `attribute` directive, evaluating it again. - // Nested components are re-rendered as well, so their directives are - // also re-evaluated (note how `text` and `children` have run). + /* + * Modify `attribute` inside context. This triggers a re-render for the + * component that wraps the `attribute` directive, evaluating it again. + * Nested components are re-rendered as well, so their directives are + * also re-evaluated (note how `text` and `children` have run). + */ await updateAttribute.click(); await expect( element ).toHaveAttribute( 'data-attribute', 'updated' ); await expect( executionOrder ).toHaveText( @@ -67,9 +71,11 @@ test.describe( 'Directives (w/ priority)', () => { ].join( ', ' ) ); - // Modify `text` inside context. This triggers a re-render of the - // component that wraps the `text` directive. In this case, only - // `children` run as well, right after `text`. + /* + * Modify `text` inside context. This triggers a re-render of the + * component that wraps the `text` directive. In this case, only + * `children` run as well, right after `text`. + */ await updateText.click(); await expect( element ).toHaveAttribute( 'data-attribute', 'updated' ); await expect( text ).toHaveText( 'updated' ); diff --git a/test/e2e/specs/interactivity/fixtures/index.ts b/test/e2e/specs/interactivity/fixtures/index.ts index 607221ffb1ec43..86ad639ef52337 100644 --- a/test/e2e/specs/interactivity/fixtures/index.ts +++ b/test/e2e/specs/interactivity/fixtures/index.ts @@ -18,8 +18,10 @@ export const test = base.extend< Fixtures >( { async ( { requestUtils }, use ) => { await use( new InteractivityUtils( { requestUtils } ) ); }, - // @ts-ignore: The required type is 'test', but can be 'worker' too. See - // https://playwright.dev/docs/test-fixtures#worker-scoped-fixtures + /* + * @ts-ignore: The required type is 'test', but can be 'worker' too. See + * https://playwright.dev/docs/test-fixtures#worker-scoped-fixtures + */ { scope: 'worker' }, ], } );