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: Migrate everything to TypeScript, add missing comments and remove the use of "we" #58718

Closed
8 changes: 4 additions & 4 deletions packages/interactivity/README.md
Original file line number Diff line number Diff line change
@@ -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:

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';

/**
Expand All @@ -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(
Expand All @@ -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;

Expand All @@ -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 );
}, [] );
} );
} );
};

Expand Down Expand Up @@ -121,6 +154,7 @@ export default () => {
</Provider>
);
}
return undefined;
},
{ priority: 5 }
);
Expand Down Expand Up @@ -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' )
Expand Down
124 changes: 33 additions & 91 deletions packages/interactivity/src/hooks.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 >( {} );
Expand Down Expand Up @@ -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[] = [];

Expand Down Expand Up @@ -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();
};

Expand Down Expand Up @@ -268,7 +204,7 @@ const resolve = ( path, namespace ) => {
};

// Generate the evaluate function.
export const getEvaluate: GetEvaluate =
export const getEvaluate: any =
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using GetEvaluate is returning this error:

 Evaluate; ref: RefObject<HTMLElement>; attributes: createElement.JSX.HTMLAttributes<EventTarget>; }' is not assignable to type 'Scope'.
  Types of property 'context' are incompatible.
    Type 'DeepSignalObject<{}>' is missing the following properties from type 'Context<any>': Consumer, Provider
407  ? getEvaluate( { scope } )( eachKey[ 0 ] )

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'll take a look, deep signal types can be complex 🙂

( { scope } ) =>
( entry, ...args ) => {
let { value: path, namespace } = entry;
Expand All @@ -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[] >
Expand All @@ -311,18 +249,22 @@ 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 );
/* eslint-disable react-hooks/rules-of-hooks */
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;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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 );
Expand All @@ -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 );
Expand Down
Loading
Loading