From f56cbcaffd220766703e459bda099ae32022d259 Mon Sep 17 00:00:00 2001 From: Carlos Bravo Date: Tue, 6 Feb 2024 10:30:10 +0100 Subject: [PATCH 01/13] Nitpicks regarding multiline comments and we word --- packages/interactivity/README.md | 8 +- packages/interactivity/src/directives.js | 14 +-- packages/interactivity/src/hooks.tsx | 26 +++-- packages/interactivity/src/portals.js | 104 ++++++++++++++++++ packages/interactivity/src/store.ts | 46 +++++--- packages/interactivity/src/utils.js | 24 ++-- packages/interactivity/src/vdom.ts | 29 +++-- .../interactivity/directive-bind.spec.ts | 2 +- .../specs/interactivity/directive-key.spec.ts | 2 +- 9 files changed, 196 insertions(+), 59 deletions(-) create mode 100644 packages/interactivity/src/portals.js 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/directives.js b/packages/interactivity/src/directives.js index 9184fb1d6d8035..8790dadb1aa902 100644 --- a/packages/interactivity/src/directives.js +++ b/packages/interactivity/src/directives.js @@ -182,7 +182,7 @@ export default () => { useInit( () => { /* * This seems necessary because Preact doesn't change the class - * names on the hydration, so we have to do it manually. It doesn't + * names on the hydration, so it must be done manually. It doesn't * need deps because it only needs to do it the first time. */ if ( ! result ) { @@ -213,7 +213,7 @@ export default () => { useInit( () => { /* * This seems necessary because Preact doesn't change the styles on - * the hydration, so we have to do it manually. It doesn't need deps + * the hydration, so it must be done manually. It doesn't need deps * because it only needs to do it the first time. */ if ( ! result ) { @@ -235,15 +235,15 @@ export default () => { /* * This is necessary because Preact doesn't change the attributes on the - * hydration, so we have to do it manually. It only needs to do it the + * hydration, so it must be done manually. It only needs to do it the * first time. After that, Preact will handle the changes. */ useInit( () => { const el = element.ref.current; /* - * We set the value directly to the corresponding HTMLElement instance - * property excluding the following special cases. We follow Preact's + * Set the value directly to the corresponding HTMLElement instance + * property excluding the following special cases. It follows Preact's * logic: https://github.com/preactjs/preact/blob/ea49f7a0f9d1ff2c98c0bdd66aa0cbc583055246/src/diff/props.js#L110-L129 */ if ( attribute === 'style' ) { @@ -285,8 +285,8 @@ export default () => { /* * aria- and data- attributes have no boolean representation. * A `false` value is different from the attribute not being - * present, so we can't remove it. - * We follow Preact's logic: https://github.com/preactjs/preact/blob/ea49f7a0f9d1ff2c98c0bdd66aa0cbc583055246/src/diff/props.js#L131C24-L136 + * present, so it can't be removed. + * It follows Preact's logic: https://github.com/preactjs/preact/blob/ea49f7a0f9d1ff2c98c0bdd66aa0cbc583055246/src/diff/props.js#L131C24-L136 */ if ( result !== null && diff --git a/packages/interactivity/src/hooks.tsx b/packages/interactivity/src/hooks.tsx index 726579f50176dc..97046d8917fa7c 100644 --- a/packages/interactivity/src/hooks.tsx +++ b/packages/interactivity/src/hooks.tsx @@ -117,8 +117,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[] = []; @@ -285,8 +287,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 +315,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 +327,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/portals.js b/packages/interactivity/src/portals.js new file mode 100644 index 00000000000000..9a444729161f7e --- /dev/null +++ b/packages/interactivity/src/portals.js @@ -0,0 +1,104 @@ +/** + * External dependencies + */ +import { createElement, render } from 'preact'; + +/** + * @param {import('../../src/index').RenderableProps<{ context: any }>} props + */ +function ContextProvider( props ) { + this.getChildContext = () => props.context; + return props.children; +} + +/** + * Portal component + * + * @this {import('./internal').Component} + * @param {object | null | undefined} props + * + * TODO: use createRoot() instead of fake root + */ +function Portal( props ) { + const _this = this; + const container = props._container; + + _this.componentWillUnmount = function () { + render( null, _this._temp ); + _this._temp = null; + _this._container = null; + }; + + /* + * When the container changes, it should clear the old container and + * indicate a new mount. + */ + if ( _this._container && _this._container !== container ) { + _this.componentWillUnmount(); + } + + /* + * When props.vnode is undefined/false/null, there is some kind of + * conditional vnode. This should not trigger a render. + */ + + if ( props._vnode ) { + if ( ! _this._temp ) { + _this._container = container; + + // Create a fake DOM parent node that manages a subset of `container`'s children: + _this._temp = { + nodeType: 1, + parentNode: container, + childNodes: [], + appendChild( child ) { + this.childNodes.push( child ); + _this._container.appendChild( child ); + }, + insertBefore( child ) { + this.childNodes.push( child ); + _this._container.appendChild( child ); + }, + removeChild( child ) { + this.childNodes.splice( + // eslint-disable-next-line no-bitwise + this.childNodes.indexOf( child ) >>> 1, + 1 + ); + _this._container.removeChild( child ); + }, + }; + } + + // Render our wrapping element into temp. + render( + createElement( + ContextProvider, + { context: _this.context }, + props._vnode + ), + _this._temp + ); + } else if ( _this._temp ) { + /* + * If it is a conditional render, on a mounted + * portal, the DOM should be cleared. + */ + _this.componentWillUnmount(); + } +} + +/** + * Create a `Portal` to continue rendering the vnode tree at a different DOM node + * + * @param {import('./internal').VNode} vnode The vnode to render + * @param {import('./internal').PreactElement} container The DOM node to continue rendering in to. + */ +export function createPortal( vnode, container ) { + const el = createElement( Portal, { + _vnode: vnode, + _container: container, + } ); + el.containerInfo = container; + return el; +} diff --git a/packages/interactivity/src/store.ts b/packages/interactivity/src/store.ts index b971bbcd1590cd..9adbbc99002cde 100644 --- a/packages/interactivity/src/store.ts +++ b/packages/interactivity/src/store.ts @@ -71,9 +71,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 +104,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 +150,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 ); @@ -268,9 +276,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 +290,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/utils.js b/packages/interactivity/src/utils.js index 84e04803cea4f5..45474e5e0fb110 100644 --- a/packages/interactivity/src/utils.js +++ b/packages/interactivity/src/utils.js @@ -36,10 +36,12 @@ const afterNextFrame = ( callback ) => { } ); }; -// Using the mangled properties: -// this.c: this._callback -// this.x: this._compute -// https://github.com/preactjs/signals/blob/main/mangle.json +/* + * 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 () { @@ -51,9 +53,11 @@ 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 +/* + * 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 ) { _useEffect( () => { let eff = null; @@ -212,8 +216,10 @@ export function useMemo( factory, inputs ) { _useMemo( withScope( factory ), inputs ); } -// For wrapperless hydration. -// See https://gist.github.com/developit/f4c67a2ede71dc2fab7f357f39cff28c +/* + * For wrapperless hydration. + * See https://gist.github.com/developit/f4c67a2ede71dc2fab7f357f39cff28c + */ export const createRootFragment = ( parent, replaceNode ) => { replaceNode = [].concat( replaceNode ); const s = replaceNode[ replaceNode.length - 1 ].nextSibling; 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..9b772bed0cbcc5 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, 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 ) ); From 837e79af028ccb744dd8ed6641693f2d6f36e71f Mon Sep 17 00:00:00 2001 From: Carlos Bravo Date: Tue, 6 Feb 2024 10:38:35 +0100 Subject: [PATCH 02/13] More nitpicks --- .../interactivity/directive-bind.spec.ts | 12 ++-- .../interactivity/directive-each.spec.ts | 60 ++++++++++++------- .../directive-priorities.spec.ts | 24 +++++--- .../e2e/specs/interactivity/fixtures/index.ts | 6 +- 4 files changed, 66 insertions(+), 36 deletions(-) diff --git a/test/e2e/specs/interactivity/directive-bind.spec.ts b/test/e2e/specs/interactivity/directive-bind.spec.ts index 9b772bed0cbcc5..626d62407bf892 100644 --- a/test/e2e/specs/interactivity/directive-bind.spec.ts +++ b/test/e2e/specs/interactivity/directive-bind.spec.ts @@ -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-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' }, ], } ); From 0ee7771e6c6377219f595c6251da000f41a3528f Mon Sep 17 00:00:00 2001 From: Carlos Bravo Date: Tue, 6 Feb 2024 17:50:27 +0100 Subject: [PATCH 03/13] Move to typescript v1 --- packages/interactivity/src/hooks.tsx | 84 ++----------- packages/interactivity/src/store.ts | 1 + .../interactivity/src/{utils.js => utils.ts} | 103 ++++++++-------- packages/interactivity/types/index.d.ts | 112 ++++++++++++++++++ 4 files changed, 178 insertions(+), 122 deletions(-) rename packages/interactivity/src/{utils.js => utils.ts} (69%) create mode 100644 packages/interactivity/types/index.d.ts diff --git a/packages/interactivity/src/hooks.tsx b/packages/interactivity/src/hooks.tsx index 97046d8917fa7c..5d8b96bbb080b1 100644 --- a/packages/interactivity/src/hooks.tsx +++ b/packages/interactivity/src/hooks.tsx @@ -10,86 +10,20 @@ 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, + GetEvaluate, + GetPriorityLevels, + Scope, +} from '../types'; // Main context. const context = createContext< any >( {} ); diff --git a/packages/interactivity/src/store.ts b/packages/interactivity/src/store.ts index 9adbbc99002cde..2cd3e80927f14c 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 ); diff --git a/packages/interactivity/src/utils.js b/packages/interactivity/src/utils.ts similarity index 69% rename from packages/interactivity/src/utils.js rename to packages/interactivity/src/utils.ts index 45474e5e0fb110..a0a9f1c4d61c54 100644 --- a/packages/interactivity/src/utils.js +++ b/packages/interactivity/src/utils.ts @@ -20,8 +20,10 @@ import { setNamespace, resetNamespace, } from './hooks'; +import type { Scope, EffectFunction } from '../types'; +import type { ContainerNode } from 'preact'; -const afterNextFrame = ( callback ) => { +const afterNextFrame = ( callback: () => void ): Promise< void > => { return new Promise( ( resolve ) => { const done = () => { clearTimeout( timeout ); @@ -42,9 +44,13 @@ const afterNextFrame = ( callback ) => { * this.x: this._compute * https://github.com/preactjs/signals/blob/main/mangle.json */ -function createFlusher( compute, notify ) { - let flush; - const dispose = effect( function () { + +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; @@ -58,9 +64,9 @@ function createFlusher( compute, notify ) { * 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 ) { +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 ) { @@ -78,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 ); @@ -109,7 +113,7 @@ export const withScope = ( func ) => { return value; }; } - return ( ...args ) => { + return ( ...args: unknown[] ) => { setNamespace( ns ); setScope( scope ); try { @@ -128,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 ) ); } @@ -141,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 ), [] ); } @@ -156,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 ); } @@ -173,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 ); } @@ -190,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 ); } @@ -212,7 +214,7 @@ 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 ); } @@ -220,21 +222,28 @@ export function useMemo( factory, inputs ) { * For wrapperless hydration. * See https://gist.github.com/developit/f4c67a2ede71dc2fab7f357f39cff28c */ -export const createRootFragment = ( parent, replaceNode ) => { +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/types/index.d.ts b/packages/interactivity/types/index.d.ts new file mode 100644 index 00000000000000..129d72558a8232 --- /dev/null +++ b/packages/interactivity/types/index.d.ts @@ -0,0 +1,112 @@ +/** + * External dependencies + */ +import type { VNode, Context, RefObject, h as createElement } from 'preact'; + +interface DirectiveEntry { + value: string | Object; + namespace: string; + suffix: string; +} + +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[] >; + +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 DirectivesProps { + directives: DirectiveEntries; + priorityLevels: PriorityLevel[]; + element: VNode; + originalProps: any; + previousScope?: Scope; +} + +interface GetEvaluate { + ( args: { scope: Scope } ): Evaluate; +} + +type PriorityLevel = string[]; + +interface GetPriorityLevels { + ( directives: DirectiveEntries ): PriorityLevel[]; +} + +type EffectFunction = { + c: () => void; + x: () => void; +}; + +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; +} From 323007e970464ee97abaabbb049a4a38891e7dd4 Mon Sep 17 00:00:00 2001 From: Carlos Bravo Date: Tue, 6 Feb 2024 18:09:11 +0100 Subject: [PATCH 04/13] Move import type to external dependency --- packages/interactivity/src/utils.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/interactivity/src/utils.ts b/packages/interactivity/src/utils.ts index a0a9f1c4d61c54..89489362a6a777 100644 --- a/packages/interactivity/src/utils.ts +++ 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 @@ -21,7 +22,6 @@ import { resetNamespace, } from './hooks'; import type { Scope, EffectFunction } from '../types'; -import type { ContainerNode } from 'preact'; const afterNextFrame = ( callback: () => void ): Promise< void > => { return new Promise( ( resolve ) => { From 91999597952387c2a9d341e76187fefe023b23e2 Mon Sep 17 00:00:00 2001 From: Carlos Bravo Date: Tue, 6 Feb 2024 22:17:06 +0100 Subject: [PATCH 05/13] Typing portals --- packages/interactivity/src/hooks.tsx | 13 +++--- .../src/{portals.js => portals.ts} | 42 ++++++++++++++----- packages/interactivity/types/index.d.ts | 24 ++++++++++- 3 files changed, 62 insertions(+), 17 deletions(-) rename packages/interactivity/src/{portals.js => portals.ts} (62%) diff --git a/packages/interactivity/src/hooks.tsx b/packages/interactivity/src/hooks.tsx index 5d8b96bbb080b1..5ad587872d1a7e 100644 --- a/packages/interactivity/src/hooks.tsx +++ b/packages/interactivity/src/hooks.tsx @@ -90,21 +90,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(); }; diff --git a/packages/interactivity/src/portals.js b/packages/interactivity/src/portals.ts similarity index 62% rename from packages/interactivity/src/portals.js rename to packages/interactivity/src/portals.ts index 9a444729161f7e..00e65e2e0f10c4 100644 --- a/packages/interactivity/src/portals.js +++ b/packages/interactivity/src/portals.ts @@ -1,12 +1,32 @@ /** * External dependencies */ +import type { ContainerNode, VNode } from 'preact'; import { createElement, render } from 'preact'; +/** + * Internal dependencies + */ +import type { + ContextProviderProps, + PortalInterface, + PortalProps, +} from '../types'; /** - * @param {import('../../src/index').RenderableProps<{ context: any }>} props + * `ContextProvider` is a function component that provides context to its child components. + * + * @param props - The properties passed to the component. + * It includes a `context` property which is the context to be provided to the child components, + * and a `children` property which are the child components that will receive the context. + * + * @return The child components passed in through `props.children`. + * + * @example + * + * + * */ -function ContextProvider( props ) { +function ContextProvider( props: ContextProviderProps ): any { this.getChildContext = () => props.context; return props.children; } @@ -14,12 +34,11 @@ function ContextProvider( props ) { /** * Portal component * - * @this {import('./internal').Component} - * @param {object | null | undefined} props + * @param props - The properties passed to the component. * - * TODO: use createRoot() instead of fake root + * TODO: use createRoot() instead of fake root */ -function Portal( props ) { +function Portal( props: PortalProps | null | undefined ): any { const _this = this; const container = props._container; @@ -91,14 +110,17 @@ function Portal( props ) { /** * Create a `Portal` to continue rendering the vnode tree at a different DOM node * - * @param {import('./internal').VNode} vnode The vnode to render - * @param {import('./internal').PreactElement} container The DOM node to continue rendering in to. + * @param vnode The vnode to render + * @param container The DOM node to continue rendering in to. */ -export function createPortal( vnode, container ) { +export function createPortal( + vnode: VNode, + container: ContainerNode +): PortalInterface { const el = createElement( Portal, { _vnode: vnode, _container: container, - } ); + } ) as PortalInterface; el.containerInfo = container; return el; } diff --git a/packages/interactivity/types/index.d.ts b/packages/interactivity/types/index.d.ts index 129d72558a8232..3ac4f86283ae09 100644 --- a/packages/interactivity/types/index.d.ts +++ b/packages/interactivity/types/index.d.ts @@ -1,7 +1,15 @@ /** * External dependencies */ -import type { VNode, Context, RefObject, h as createElement } from 'preact'; +import type { + VNode, + Context, + RefObject, + h as createElement, + ContainerNode, + ComponentChild, + ComponentChildren, +} from 'preact'; interface DirectiveEntry { value: string | Object; @@ -110,3 +118,17 @@ interface StoreOptions { */ lock?: boolean | string; } + +interface PortalInterface extends VNode { + containerInfo?: ContainerNode; +} + +interface PortalProps { + _container: ContainerNode; + _vnode: VNode; +} + +interface ContextProviderProps { + context: any; + children?: ComponentChildren; +} From adcec5a415f16b772975d14cdaee1cb21d4d4c77 Mon Sep 17 00:00:00 2001 From: Carlos Bravo Date: Wed, 7 Feb 2024 10:25:18 +0100 Subject: [PATCH 06/13] Add consistency to the types, like other packages --- packages/interactivity/src/hooks.tsx | 2 +- packages/interactivity/src/portals.ts | 2 +- packages/interactivity/src/store.ts | 2 +- .../{types/index.d.ts => src/types.ts} | 23 +++++++++---------- packages/interactivity/src/utils.ts | 2 +- 5 files changed, 15 insertions(+), 16 deletions(-) rename packages/interactivity/{types/index.d.ts => src/types.ts} (86%) diff --git a/packages/interactivity/src/hooks.tsx b/packages/interactivity/src/hooks.tsx index 5ad587872d1a7e..a98e57ef19b235 100644 --- a/packages/interactivity/src/hooks.tsx +++ b/packages/interactivity/src/hooks.tsx @@ -23,7 +23,7 @@ import type { GetEvaluate, GetPriorityLevels, Scope, -} from '../types'; +} from './types'; // Main context. const context = createContext< any >( {} ); diff --git a/packages/interactivity/src/portals.ts b/packages/interactivity/src/portals.ts index 00e65e2e0f10c4..b80cc420f762a1 100644 --- a/packages/interactivity/src/portals.ts +++ b/packages/interactivity/src/portals.ts @@ -10,7 +10,7 @@ import type { ContextProviderProps, PortalInterface, PortalProps, -} from '../types'; +} from './types'; /** * `ContextProvider` is a function component that provides context to its child components. diff --git a/packages/interactivity/src/store.ts b/packages/interactivity/src/store.ts index 2cd3e80927f14c..de604202e428ba 100644 --- a/packages/interactivity/src/store.ts +++ b/packages/interactivity/src/store.ts @@ -15,7 +15,7 @@ import { setNamespace, resetNamespace, } from './hooks'; -import type { StoreOptions } from '../types'; +import type { StoreOptions } from './types'; const isObject = ( item: unknown ): boolean => !! item && typeof item === 'object' && ! Array.isArray( item ); diff --git a/packages/interactivity/types/index.d.ts b/packages/interactivity/src/types.ts similarity index 86% rename from packages/interactivity/types/index.d.ts rename to packages/interactivity/src/types.ts index 3ac4f86283ae09..4794d224f73cce 100644 --- a/packages/interactivity/types/index.d.ts +++ b/packages/interactivity/src/types.ts @@ -7,7 +7,6 @@ import type { RefObject, h as createElement, ContainerNode, - ComponentChild, ComponentChildren, } from 'preact'; @@ -17,7 +16,7 @@ interface DirectiveEntry { suffix: string; } -interface Scope { +export interface Scope { evaluate: Evaluate; context: Context< any >; ref: RefObject< HTMLElement >; @@ -54,11 +53,11 @@ interface DirectiveArgs { evaluate: Evaluate; } -interface DirectiveCallback { +export interface DirectiveCallback { ( args: DirectiveArgs ): VNode | void; } -interface DirectiveOptions { +export interface DirectiveOptions { /** * Value that specifies the priority to evaluate directives of this type. * Lower numbers correspond with earlier execution. @@ -68,7 +67,7 @@ interface DirectiveOptions { priority?: number; } -interface DirectivesProps { +export interface DirectivesProps { directives: DirectiveEntries; priorityLevels: PriorityLevel[]; element: VNode; @@ -76,22 +75,22 @@ interface DirectivesProps { previousScope?: Scope; } -interface GetEvaluate { +export interface GetEvaluate { ( args: { scope: Scope } ): Evaluate; } type PriorityLevel = string[]; -interface GetPriorityLevels { +export interface GetPriorityLevels { ( directives: DirectiveEntries ): PriorityLevel[]; } -type EffectFunction = { +export type EffectFunction = { c: () => void; x: () => void; }; -interface StoreOptions { +export interface StoreOptions { /** * Property to block/unblock private store namespaces. * @@ -119,16 +118,16 @@ interface StoreOptions { lock?: boolean | string; } -interface PortalInterface extends VNode { +export interface PortalInterface extends VNode { containerInfo?: ContainerNode; } -interface PortalProps { +export interface PortalProps { _container: ContainerNode; _vnode: VNode; } -interface ContextProviderProps { +export interface ContextProviderProps { context: any; children?: ComponentChildren; } diff --git a/packages/interactivity/src/utils.ts b/packages/interactivity/src/utils.ts index 89489362a6a777..6169e713df5f15 100644 --- a/packages/interactivity/src/utils.ts +++ b/packages/interactivity/src/utils.ts @@ -21,7 +21,7 @@ import { setNamespace, resetNamespace, } from './hooks'; -import type { Scope, EffectFunction } from '../types'; +import type { Scope, EffectFunction } from './types'; const afterNextFrame = ( callback: () => void ): Promise< void > => { return new Promise( ( resolve ) => { From 7b53b37140cc0d2d043e84ea105883708a770b64 Mon Sep 17 00:00:00 2001 From: Carlos Bravo Date: Wed, 7 Feb 2024 11:12:08 +0100 Subject: [PATCH 07/13] Add typescript to init --- packages/interactivity/src/{init.js => init.ts} | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) rename packages/interactivity/src/{init.js => init.ts} (78%) 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 ); From 03b742c3af83d552582809b9442c4a88ab3fd255 Mon Sep 17 00:00:00 2001 From: Carlos Bravo Date: Wed, 7 Feb 2024 12:08:15 +0100 Subject: [PATCH 08/13] wip type directives --- packages/interactivity/src/{constants.js => constants.ts} | 0 packages/interactivity/src/types.ts | 6 ++++++ 2 files changed, 6 insertions(+) rename packages/interactivity/src/{constants.js => constants.ts} (100%) 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/types.ts b/packages/interactivity/src/types.ts index 4794d224f73cce..58cf1f01d1f36e 100644 --- a/packages/interactivity/src/types.ts +++ b/packages/interactivity/src/types.ts @@ -131,3 +131,9 @@ export interface ContextProviderProps { context: any; children?: ComponentChildren; } + +export interface SignalObject { + [ key: string ]: { + peek: () => any; // Cannot export types from deepsignal library. + }; +} From 3eb836c491df91fdf7305a4f6670d2c313776824 Mon Sep 17 00:00:00 2001 From: Carlos Bravo Date: Thu, 8 Feb 2024 10:34:53 +0100 Subject: [PATCH 09/13] Wip, still typing --- packages/interactivity/src/directives.ts | 414 +++++++++++++++++++++++ packages/interactivity/src/types.ts | 13 +- 2 files changed, 421 insertions(+), 6 deletions(-) create mode 100644 packages/interactivity/src/directives.ts diff --git a/packages/interactivity/src/directives.ts b/packages/interactivity/src/directives.ts new file mode 100644 index 00000000000000..bf46a9b21574a1 --- /dev/null +++ b/packages/interactivity/src/directives.ts @@ -0,0 +1,414 @@ +/* @jsx createElement */ + +/** + * External dependencies + */ +import { h as createElement } from 'preact'; +import { useContext, useMemo, useRef } from 'preact/hooks'; +import { deepSignal, peek } from 'deepsignal'; + +/** + * Internal dependencies + */ +import { createPortal } from './portals'; +import { useWatch, useInit } from './utils'; +import { directive, getScope, getEvaluate } from './hooks'; +import { kebabToCamelCase } from './utils/kebab-to-camelcase'; +import { DirectiveArgs, SignalObject } from './types'; + +/** + * Checks if the provided item is an object and not an array. + * + * @param item The item to check. + * + * @returns 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 ); + + +/** + * 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: SignalObject, source: SignalObject, overwrite: boolean = false ) => { + for ( const k in source ) { + if ( isObject( peek( target, k ) ) && isObject( peek( source, k ) ) ) { + mergeDeepSignals( + target[ `$${ k }` ].peek(), + source[ `$${ k }` ].peek(), + overwrite + ); + } else if ( overwrite || typeof peek( target, k ) === 'undefined' ) { + target[ `$${ k }` ] = source[ `$${ k }` ]; + } + } +}; + +const newRule = + /(?:([\u0080-\uFFFF\w-%@]+) *:? *([^{;]+?);|([^;}{]*?) *{)|(}\s*)/g; +const ruleClean = /\/\*[^]*?\*\/| +/g; +const ruleNewline = /\n+/g; +const empty = ' '; + +/** + * Convert a css style string into a object. + * + * Made by Cristian Bote (@cristianbote) for Goober. + * https://unpkg.com/browse/goober@2.1.13/src/core/astish.js + * + * @param val CSS string. + * @return CSS object. + */ +const cssStringToObject = ( val: string ): Object => { + const tree = [ {} ]; + let block, left; + + while ( ( block = newRule.exec( val.replace( ruleClean, '' ) ) ) ) { + if ( block[ 4 ] ) { + tree.shift(); + } else if ( block[ 3 ] ) { + left = block[ 3 ].replace( ruleNewline, empty ).trim(); + tree.unshift( ( tree[ 0 ][ left ] = tree[ 0 ][ left ] || {} ) ); + } else { + tree[ 0 ][ block[ 1 ] ] = block[ 2 ] + .replace( ruleNewline, empty ) + .trim(); + } + } + + return tree[ 0 ]; +}; + +/** + * Creates a directive that adds an event listener to the global window or + * document object. + * + * @param type 'window' or 'document' + */ +const getGlobalEventDirective = + ( type: 'window' | 'document' ) => + ( { directives, evaluate }: DirectiveArgs ): void => { + directives[ `on-${ type }` ] + .filter( ( { suffix } ) => suffix !== 'default' ) + .forEach( ( entry ) => { + useInit( () => { + const cb = ( event: Event ) => evaluate( entry, event ); + const globalVar = type === 'window' ? window : document; + globalVar.addEventListener( entry.suffix, cb ); + return () => + globalVar.removeEventListener( entry.suffix, cb ); + }); + } ); + }; + +export default (): void => { + // data-wp-context + directive( + 'context', + ( { + directives: { context }, + props: { children }, + context: inheritedContext, + }: DirectiveArgs ): Element => { + + const { Provider } = inheritedContext; + const inheritedValue = useContext( inheritedContext ); + const currentValue = useRef( deepSignal( {} ) ); + const passedValues = context.map( ( { value } ) => value ); + + currentValue.current = useMemo( () => { + const newValue = context + .map( ( c ) => deepSignal( { [ c.namespace ]: c.value } ) ) + .reduceRight( mergeDeepSignals ); + + mergeDeepSignals( newValue, inheritedValue ); + mergeDeepSignals( currentValue.current, newValue, true ); + return currentValue.current; + }, [ inheritedValue, ...passedValues ] ); + + return ( + { children } + ); + }, + { priority: 5 } + ); + + // data-wp-body + directive( 'body', ( { props: { children } } ) => { + return createPortal( children, document.body ); + } ); + + // data-wp-watch--[name] + directive( 'watch', ( { directives: { watch }, evaluate } ) => { + watch.forEach( ( entry ) => { + useWatch( () => evaluate( entry ) ); + } ); + } ); + + // data-wp-init--[name] + directive( 'init', ( { directives: { init }, evaluate } ) => { + init.forEach( ( entry ) => { + // TODO: Replace with useEffect to prevent unneeded scopes. + useInit( () => evaluate( entry ) ); + } ); + } ); + + // data-wp-on--[event] + directive( 'on', ( { directives: { on }, element, evaluate } ) => { + on.filter( ( { suffix } ) => suffix !== 'default' ).forEach( + ( entry ) => { + element.props[ `on${ entry.suffix }` ] = ( event ) => { + evaluate( entry, event ); + }; + } + ); + } ); + + // data-wp-on-window--[event] + directive( 'on-window', getGlobalEventDirective( 'window' ) ); + // data-wp-on-document--[event] + directive( 'on-document', getGlobalEventDirective( 'document' ) ); + + // data-wp-class--[classname] + directive( + 'class', + ( { directives: { class: className }, element, evaluate } ) => { + className + .filter( ( { suffix } ) => suffix !== 'default' ) + .forEach( ( entry ) => { + const name = entry.suffix; + const result = evaluate( entry, { className: name } ); + const currentClass = element.props.class || ''; + const classFinder = new RegExp( + `(^|\\s)${ name }(\\s|$)`, + 'g' + ); + if ( ! result ) + element.props.class = currentClass + .replace( classFinder, ' ' ) + .trim(); + else if ( ! classFinder.test( currentClass ) ) + element.props.class = currentClass + ? `${ currentClass } ${ name }` + : name; + + useInit( () => { + /* + * This seems necessary because Preact doesn't change the class + * names on the hydration, so it must be done manually. It doesn't + * need deps because it only needs to do it the first time. + */ + if ( ! result ) { + element.ref.current.classList.remove( name ); + } else { + element.ref.current.classList.add( name ); + } + } ); + } ); + } + ); + + // data-wp-style--[style-key] + directive( 'style', ( { directives: { style }, element, evaluate } ) => { + style + .filter( ( { suffix } ) => suffix !== 'default' ) + .forEach( ( entry ) => { + const key = entry.suffix; + const result = evaluate( entry, { key } ); + element.props.style = element.props.style || {}; + if ( typeof element.props.style === 'string' ) + element.props.style = cssStringToObject( + element.props.style + ); + if ( ! result ) delete element.props.style[ key ]; + else element.props.style[ key ] = result; + + useInit( () => { + /* + * This seems necessary because Preact doesn't change the styles on + * the hydration, so it must be done manually. It doesn't need deps + * because it only needs to do it the first time. + */ + if ( ! result ) { + element.ref.current.style.removeProperty( key ); + } else { + element.ref.current.style[ key ] = result; + } + } ); + } ); + } ); + + // data-wp-bind--[attribute] + directive( 'bind', ( { directives: { bind }, element, evaluate } ) => { + bind.filter( ( { suffix } ) => suffix !== 'default' ).forEach( + ( entry ) => { + const attribute = entry.suffix; + const result = evaluate( entry ); + element.props[ attribute ] = result; + + /* + * This is necessary because Preact doesn't change the attributes on the + * hydration, so it must be done manually. It only needs to do it the + * first time. After that, Preact will handle the changes. + */ + useInit( () => { + const el = element.ref.current; + + /* + * Set the value directly to the corresponding HTMLElement instance + * property excluding the following special cases. It follows Preact's + * logic: https://github.com/preactjs/preact/blob/ea49f7a0f9d1ff2c98c0bdd66aa0cbc583055246/src/diff/props.js#L110-L129 + */ + if ( + attribute !== 'width' && + attribute !== 'height' && + attribute !== 'href' && + attribute !== 'list' && + attribute !== 'form' && + /* + * The value for `tabindex` follows the parsing rules for an + * integer. If that fails, or if the attribute isn't present, then + * the browsers should "follow platform conventions to determine if + * the element should be considered as a focusable area", + * practically meaning that most elements get a default of `-1` (not + * focusable), but several also get a default of `0` (focusable in + * order after all elements with a positive `tabindex` value). + * + * @see https://html.spec.whatwg.org/#tabindex-value + */ + attribute !== 'tabIndex' && + attribute !== 'download' && + attribute !== 'rowSpan' && + attribute !== 'colSpan' && + attribute !== 'role' && + attribute in el + ) { + try { + el[ attribute ] = + result === null || result === undefined + ? '' + : result; + return; + } catch ( err ) {} + } + /* + * aria- and data- attributes have no boolean representation. + * A `false` value is different from the attribute not being + * present, so it can't be removed. + * It follows Preact's logic: https://github.com/preactjs/preact/blob/ea49f7a0f9d1ff2c98c0bdd66aa0cbc583055246/src/diff/props.js#L131C24-L136 + */ + if ( + result !== null && + result !== undefined && + ( result !== false || attribute[ 4 ] === '-' ) + ) { + el.setAttribute( attribute, result ); + } else { + el.removeAttribute( attribute ); + } + } ); + } + ); + } ); + + // data-wp-ignore + directive( + 'ignore', + ( { + element: { + type: Type, + props: { innerHTML, ...rest }, + }, + } ) => { + // Preserve the initial inner HTML. + const cached = useMemo( () => innerHTML, [] ); + return ( + + ); + } + ); + + // data-wp-text + directive( 'text', ( { directives: { text }, element, evaluate } ) => { + const entry = text.find( ( { suffix } ) => suffix === 'default' ); + try { + const result = evaluate( entry ); + element.props.children = + typeof result === 'object' ? null : result.toString(); + } catch ( e ) { + element.props.children = null; + } + } ); + + // data-wp-run + directive( 'run', ( { directives: { run }, evaluate } ) => { + run.forEach( ( entry ) => evaluate( entry ) ); + } ); + + // data-wp-each--[item] + directive( + 'each', + ( { + directives: { each, 'each-key': eachKey }, + context: inheritedContext, + element, + evaluate, + } ) => { + if ( element.type !== 'template' ) return; + + const { Provider } = inheritedContext; + const inheritedValue = useContext( inheritedContext ); + + const [ entry ] = each; + const { namespace, suffix } = entry; + + const list = evaluate( entry ); + return list.map( ( item ) => { + const mergedContext = deepSignal( {} ); + + const itemProp = + suffix === 'default' ? 'item' : kebabToCamelCase( suffix ); + const newValue = deepSignal( { + [ namespace ]: { [ itemProp ]: item }, + } ); + mergeDeepSignals( newValue, inheritedValue ); + mergeDeepSignals( mergedContext, newValue, true ); + + const scope = { ...getScope(), context: mergedContext }; + const key = eachKey + ? getEvaluate( { scope } )( eachKey[ 0 ] ) + : item; + + return ( + + { element.props.content } + + ); + } ); + }, + { priority: 20 } + ); + + directive( 'each-child', () => null ); +}; diff --git a/packages/interactivity/src/types.ts b/packages/interactivity/src/types.ts index 58cf1f01d1f36e..fc959547a7398e 100644 --- a/packages/interactivity/src/types.ts +++ b/packages/interactivity/src/types.ts @@ -29,7 +29,7 @@ interface Evaluate { type DirectiveEntries = Record< string, DirectiveEntry[] >; -interface DirectiveArgs { +export interface DirectiveArgs { /** * Object map with the defined directives of the element being evaluated. */ @@ -37,15 +37,15 @@ interface DirectiveArgs { /** * Props present in the current element. */ - props: Object; + props?: Record< string, any >; /** * Virtual node representing the element. */ - element: VNode; + element?: Element; /** * The inherited context. */ - context: Context< any >; + context?: Context< any >; /** * Function that resolves a given path to a value either in the store or the * context. @@ -54,7 +54,7 @@ interface DirectiveArgs { } export interface DirectiveCallback { - ( args: DirectiveArgs ): VNode | void; + ( args: DirectiveArgs ): Element | void; } export interface DirectiveOptions { @@ -132,8 +132,9 @@ export interface ContextProviderProps { children?: ComponentChildren; } +// Cannot export some types needed from deepsignal library. export interface SignalObject { [ key: string ]: { - peek: () => any; // Cannot export types from deepsignal library. + peek: () => any; }; } From 1c0c8bd59188949c1080033b22c81de7ab607b03 Mon Sep 17 00:00:00 2001 From: Carlos Bravo Date: Thu, 8 Feb 2024 13:12:44 +0100 Subject: [PATCH 10/13] More WIP --- .../src/{directives.ts => directives.tsx} | 21 +++++++++++-------- packages/interactivity/src/types.ts | 7 ------- 2 files changed, 12 insertions(+), 16 deletions(-) rename packages/interactivity/src/{directives.ts => directives.tsx} (96%) diff --git a/packages/interactivity/src/directives.ts b/packages/interactivity/src/directives.tsx similarity index 96% rename from packages/interactivity/src/directives.ts rename to packages/interactivity/src/directives.tsx index bf46a9b21574a1..e59ae81feef7eb 100644 --- a/packages/interactivity/src/directives.ts +++ 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 { DeepSignalObject } from 'deepsignal'; import { deepSignal, peek } from 'deepsignal'; /** @@ -14,14 +15,14 @@ import { createPortal } from './portals'; import { useWatch, useInit } from './utils'; import { directive, getScope, getEvaluate } from './hooks'; import { kebabToCamelCase } from './utils/kebab-to-camelcase'; -import { DirectiveArgs, SignalObject } from './types'; +import type { DirectiveArgs } from './types'; /** * Checks if the provided item is an object and not an array. * * @param item The item to check. * - * @returns Whether the item is an object. + * @return Whether the item is an object. * * @example * isObject({}); // returns true @@ -29,17 +30,16 @@ import { DirectiveArgs, SignalObject } from './types'; * isObject('string'); // returns false * isObject(null); // returns false */ -const isObject = ( item:any ): boolean => +const isObject = ( item: any ): boolean => item && typeof item === 'object' && ! Array.isArray( item ); - /** * 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 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 @@ -48,7 +48,11 @@ const isObject = ( item:any ): boolean => * mergeDeepSignals(target, source, true); * // target is now { $key1: { peek: () => ({ a: 3 }) }, $key2: { peek: () => ({ b: 2 }) }, $key3: { peek: () => ({ c: 3 }) } } */ -const mergeDeepSignals = ( target: SignalObject, source: SignalObject, overwrite: boolean = false ) => { +const mergeDeepSignals = ( + target: DeepSignalObject< any >, + source: DeepSignalObject< any >, + overwrite: boolean = false +) => { for ( const k in source ) { if ( isObject( peek( target, k ) ) && isObject( peek( source, k ) ) ) { mergeDeepSignals( @@ -115,7 +119,7 @@ const getGlobalEventDirective = globalVar.addEventListener( entry.suffix, cb ); return () => globalVar.removeEventListener( entry.suffix, cb ); - }); + } ); } ); }; @@ -128,7 +132,6 @@ export default (): void => { props: { children }, context: inheritedContext, }: DirectiveArgs ): Element => { - const { Provider } = inheritedContext; const inheritedValue = useContext( inheritedContext ); const currentValue = useRef( deepSignal( {} ) ); diff --git a/packages/interactivity/src/types.ts b/packages/interactivity/src/types.ts index fc959547a7398e..39040e01a949ea 100644 --- a/packages/interactivity/src/types.ts +++ b/packages/interactivity/src/types.ts @@ -131,10 +131,3 @@ export interface ContextProviderProps { context: any; children?: ComponentChildren; } - -// Cannot export some types needed from deepsignal library. -export interface SignalObject { - [ key: string ]: { - peek: () => any; - }; -} From bb8727269c54feee83b805eccd7bd212bb3a500b Mon Sep 17 00:00:00 2001 From: Carlos Bravo Date: Thu, 8 Feb 2024 14:00:22 +0100 Subject: [PATCH 11/13] More WIP typing --- packages/interactivity/src/directives.tsx | 34 +++++++++++++---------- packages/interactivity/src/hooks.tsx | 3 +- packages/interactivity/src/types.ts | 4 +-- 3 files changed, 23 insertions(+), 18 deletions(-) diff --git a/packages/interactivity/src/directives.tsx b/packages/interactivity/src/directives.tsx index e59ae81feef7eb..09190da1d10fbb 100644 --- a/packages/interactivity/src/directives.tsx +++ b/packages/interactivity/src/directives.tsx @@ -51,7 +51,7 @@ const isObject = ( item: any ): boolean => const mergeDeepSignals = ( target: DeepSignalObject< any >, source: DeepSignalObject< any >, - overwrite: boolean = false + overwrite?: boolean ) => { for ( const k in source ) { if ( isObject( peek( target, k ) ) && isObject( peek( source, k ) ) ) { @@ -123,7 +123,7 @@ const getGlobalEventDirective = } ); }; -export default (): void => { +export default () => { // data-wp-context directive( 'context', @@ -131,25 +131,31 @@ export default (): void => { directives: { context }, props: { children }, context: inheritedContext, - }: DirectiveArgs ): Element => { + } ) => { const { Provider } = inheritedContext; const inheritedValue = useContext( inheritedContext ); const currentValue = useRef( deepSignal( {} ) ); - const passedValues = context.map( ( { value } ) => value ); + const defaultEntry = context.find( + ( { suffix } ) => suffix === 'default' + ); currentValue.current = useMemo( () => { - const newValue = context - .map( ( c ) => deepSignal( { [ c.namespace ]: c.value } ) ) - .reduceRight( mergeDeepSignals ); - + if ( ! defaultEntry ) return null; + const { namespace, value } = defaultEntry; + const newValue = deepSignal( { [ namespace ]: value } ); mergeDeepSignals( newValue, inheritedValue ); mergeDeepSignals( currentValue.current, newValue, true ); return currentValue.current; - }, [ inheritedValue, ...passedValues ] ); + }, [ inheritedValue, defaultEntry ] ); - return ( - { children } - ); + if ( currentValue.current ) { + return ( + + { children } + + ); + } + return undefined; }, { priority: 5 } ); @@ -220,9 +226,9 @@ export default (): void => { * need deps because it only needs to do it the first time. */ if ( ! result ) { - element.ref.current.classList.remove( name ); + element.ref!.current.classList.remove( name ); } else { - element.ref.current.classList.add( name ); + element.ref!.current.classList.add( name ); } } ); } ); diff --git a/packages/interactivity/src/hooks.tsx b/packages/interactivity/src/hooks.tsx index a98e57ef19b235..bf5c3ef50add24 100644 --- a/packages/interactivity/src/hooks.tsx +++ b/packages/interactivity/src/hooks.tsx @@ -20,7 +20,6 @@ import type { DirectiveCallback, DirectiveOptions, DirectivesProps, - GetEvaluate, GetPriorityLevels, Scope, } from './types'; @@ -205,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; diff --git a/packages/interactivity/src/types.ts b/packages/interactivity/src/types.ts index 39040e01a949ea..a379c62c33de86 100644 --- a/packages/interactivity/src/types.ts +++ b/packages/interactivity/src/types.ts @@ -41,7 +41,7 @@ export interface DirectiveArgs { /** * Virtual node representing the element. */ - element?: Element; + element?: any; /** * The inherited context. */ @@ -54,7 +54,7 @@ export interface DirectiveArgs { } export interface DirectiveCallback { - ( args: DirectiveArgs ): Element | void; + ( args: DirectiveArgs ): VNode | void; } export interface DirectiveOptions { From 5a7963becb6658fccf8d2f32a9b696f686980bea Mon Sep 17 00:00:00 2001 From: Carlos Bravo Date: Fri, 9 Feb 2024 16:25:46 +0100 Subject: [PATCH 12/13] Type directives file and store --- packages/interactivity/src/directives.js | 387 ---------------------- packages/interactivity/src/directives.tsx | 62 ++-- packages/interactivity/src/store.ts | 28 -- 3 files changed, 30 insertions(+), 447 deletions(-) delete mode 100644 packages/interactivity/src/directives.js diff --git a/packages/interactivity/src/directives.js b/packages/interactivity/src/directives.js deleted file mode 100644 index 8790dadb1aa902..00000000000000 --- a/packages/interactivity/src/directives.js +++ /dev/null @@ -1,387 +0,0 @@ -/* @jsx createElement */ - -/** - * External dependencies - */ -import { h as createElement } from 'preact'; -import { useContext, useMemo, useRef } from 'preact/hooks'; -import { deepSignal, peek } from 'deepsignal'; - -/** - * Internal dependencies - */ -import { useWatch, useInit } from './utils'; -import { directive, getScope, getEvaluate } from './hooks'; -import { kebabToCamelCase } from './utils/kebab-to-camelcase'; - -const isObject = ( item ) => - item && typeof item === 'object' && ! Array.isArray( item ); - -const mergeDeepSignals = ( target, source, overwrite ) => { - for ( const k in source ) { - if ( isObject( peek( target, k ) ) && isObject( peek( source, k ) ) ) { - mergeDeepSignals( - target[ `$${ k }` ].peek(), - source[ `$${ k }` ].peek(), - overwrite - ); - } else if ( overwrite || typeof peek( target, k ) === 'undefined' ) { - target[ `$${ k }` ] = source[ `$${ k }` ]; - } - } -}; - -const newRule = - /(?:([\u0080-\uFFFF\w-%@]+) *:? *([^{;]+?);|([^;}{]*?) *{)|(}\s*)/g; -const ruleClean = /\/\*[^]*?\*\/| +/g; -const ruleNewline = /\n+/g; -const empty = ' '; - -/** - * Convert a css style string into a object. - * - * 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. - */ -const cssStringToObject = ( val ) => { - const tree = [ {} ]; - let block, left; - - while ( ( block = newRule.exec( val.replace( ruleClean, '' ) ) ) ) { - if ( block[ 4 ] ) { - tree.shift(); - } else if ( block[ 3 ] ) { - left = block[ 3 ].replace( ruleNewline, empty ).trim(); - tree.unshift( ( tree[ 0 ][ left ] = tree[ 0 ][ left ] || {} ) ); - } else { - tree[ 0 ][ block[ 1 ] ] = block[ 2 ] - .replace( ruleNewline, empty ) - .trim(); - } - } - - return tree[ 0 ]; -}; - -/** - * Creates a directive that adds an event listener to the global window or - * document object. - * - * @param {string} type 'window' or 'document' - * @return {void} - */ -const getGlobalEventDirective = - ( type ) => - ( { directives, evaluate } ) => { - directives[ `on-${ type }` ] - .filter( ( { suffix } ) => suffix !== 'default' ) - .forEach( ( entry ) => { - useInit( () => { - const cb = ( event ) => evaluate( entry, event ); - const globalVar = type === 'window' ? window : document; - globalVar.addEventListener( entry.suffix, cb ); - return () => - globalVar.removeEventListener( entry.suffix, cb ); - }, [] ); - } ); - }; - -export default () => { - // data-wp-context - directive( - 'context', - ( { - directives: { context }, - props: { children }, - context: inheritedContext, - } ) => { - const { Provider } = inheritedContext; - const inheritedValue = useContext( inheritedContext ); - const currentValue = useRef( deepSignal( {} ) ); - const defaultEntry = context.find( - ( { suffix } ) => suffix === 'default' - ); - - currentValue.current = useMemo( () => { - if ( ! defaultEntry ) return null; - const { namespace, value } = defaultEntry; - const newValue = deepSignal( { [ namespace ]: value } ); - mergeDeepSignals( newValue, inheritedValue ); - mergeDeepSignals( currentValue.current, newValue, true ); - return currentValue.current; - }, [ inheritedValue, defaultEntry ] ); - - if ( currentValue.current ) { - return ( - - { children } - - ); - } - }, - { priority: 5 } - ); - - // data-wp-watch--[name] - directive( 'watch', ( { directives: { watch }, evaluate } ) => { - watch.forEach( ( entry ) => { - useWatch( () => evaluate( entry ) ); - } ); - } ); - - // data-wp-init--[name] - directive( 'init', ( { directives: { init }, evaluate } ) => { - init.forEach( ( entry ) => { - // TODO: Replace with useEffect to prevent unneeded scopes. - useInit( () => evaluate( entry ) ); - } ); - } ); - - // data-wp-on--[event] - directive( 'on', ( { directives: { on }, element, evaluate } ) => { - on.filter( ( { suffix } ) => suffix !== 'default' ).forEach( - ( entry ) => { - element.props[ `on${ entry.suffix }` ] = ( event ) => { - evaluate( entry, event ); - }; - } - ); - } ); - - // data-wp-on-window--[event] - directive( 'on-window', getGlobalEventDirective( 'window' ) ); - // data-wp-on-document--[event] - directive( 'on-document', getGlobalEventDirective( 'document' ) ); - - // data-wp-class--[classname] - directive( - 'class', - ( { directives: { class: classNames }, element, evaluate } ) => { - classNames - .filter( ( { suffix } ) => suffix !== 'default' ) - .forEach( ( entry ) => { - const className = entry.suffix; - const result = evaluate( entry ); - const currentClass = element.props.class || ''; - const classFinder = new RegExp( - `(^|\\s)${ className }(\\s|$)`, - 'g' - ); - if ( ! result ) - element.props.class = currentClass - .replace( classFinder, ' ' ) - .trim(); - else if ( ! classFinder.test( currentClass ) ) - element.props.class = currentClass - ? `${ currentClass } ${ className }` - : className; - - useInit( () => { - /* - * This seems necessary because Preact doesn't change the class - * names on the hydration, so it must be done manually. It doesn't - * need deps because it only needs to do it the first time. - */ - if ( ! result ) { - element.ref.current.classList.remove( className ); - } else { - element.ref.current.classList.add( className ); - } - } ); - } ); - } - ); - - // data-wp-style--[style-prop] - directive( 'style', ( { directives: { style }, element, evaluate } ) => { - style - .filter( ( { suffix } ) => suffix !== 'default' ) - .forEach( ( entry ) => { - const styleProp = entry.suffix; - const result = evaluate( entry ); - element.props.style = element.props.style || {}; - if ( typeof element.props.style === 'string' ) - element.props.style = cssStringToObject( - element.props.style - ); - if ( ! result ) delete element.props.style[ styleProp ]; - else element.props.style[ styleProp ] = result; - - useInit( () => { - /* - * This seems necessary because Preact doesn't change the styles on - * the hydration, so it must be done manually. It doesn't need deps - * because it only needs to do it the first time. - */ - if ( ! result ) { - element.ref.current.style.removeProperty( styleProp ); - } else { - element.ref.current.style[ styleProp ] = result; - } - } ); - } ); - } ); - - // data-wp-bind--[attribute] - directive( 'bind', ( { directives: { bind }, element, evaluate } ) => { - bind.filter( ( { suffix } ) => suffix !== 'default' ).forEach( - ( entry ) => { - const attribute = entry.suffix; - const result = evaluate( entry ); - element.props[ attribute ] = result; - - /* - * This is necessary because Preact doesn't change the attributes on the - * hydration, so it must be done manually. It only needs to do it the - * first time. After that, Preact will handle the changes. - */ - useInit( () => { - const el = element.ref.current; - - /* - * Set the value directly to the corresponding HTMLElement instance - * property excluding the following special cases. It follows Preact's - * logic: https://github.com/preactjs/preact/blob/ea49f7a0f9d1ff2c98c0bdd66aa0cbc583055246/src/diff/props.js#L110-L129 - */ - if ( attribute === 'style' ) { - if ( typeof result === 'string' ) - el.style.cssText = result; - return; - } else if ( - attribute !== 'width' && - attribute !== 'height' && - attribute !== 'href' && - attribute !== 'list' && - attribute !== 'form' && - /* - * The value for `tabindex` follows the parsing rules for an - * integer. If that fails, or if the attribute isn't present, then - * the browsers should "follow platform conventions to determine if - * the element should be considered as a focusable area", - * practically meaning that most elements get a default of `-1` (not - * focusable), but several also get a default of `0` (focusable in - * order after all elements with a positive `tabindex` value). - * - * @see https://html.spec.whatwg.org/#tabindex-value - */ - attribute !== 'tabIndex' && - attribute !== 'download' && - attribute !== 'rowSpan' && - attribute !== 'colSpan' && - attribute !== 'role' && - attribute in el - ) { - try { - el[ attribute ] = - result === null || result === undefined - ? '' - : result; - return; - } catch ( err ) {} - } - /* - * aria- and data- attributes have no boolean representation. - * A `false` value is different from the attribute not being - * present, so it can't be removed. - * It follows Preact's logic: https://github.com/preactjs/preact/blob/ea49f7a0f9d1ff2c98c0bdd66aa0cbc583055246/src/diff/props.js#L131C24-L136 - */ - if ( - result !== null && - result !== undefined && - ( result !== false || attribute[ 4 ] === '-' ) - ) { - el.setAttribute( attribute, result ); - } else { - el.removeAttribute( attribute ); - } - } ); - } - ); - } ); - - // data-wp-ignore - directive( - 'ignore', - ( { - element: { - type: Type, - props: { innerHTML, ...rest }, - }, - } ) => { - // Preserve the initial inner HTML. - const cached = useMemo( () => innerHTML, [] ); - return ( - - ); - } - ); - - // data-wp-text - directive( 'text', ( { directives: { text }, element, evaluate } ) => { - const entry = text.find( ( { suffix } ) => suffix === 'default' ); - try { - const result = evaluate( entry ); - element.props.children = - typeof result === 'object' ? null : result.toString(); - } catch ( e ) { - element.props.children = null; - } - } ); - - // data-wp-run - directive( 'run', ( { directives: { run }, evaluate } ) => { - run.forEach( ( entry ) => evaluate( entry ) ); - } ); - - // data-wp-each--[item] - directive( - 'each', - ( { - directives: { each, 'each-key': eachKey }, - context: inheritedContext, - element, - evaluate, - } ) => { - if ( element.type !== 'template' ) return; - - const { Provider } = inheritedContext; - const inheritedValue = useContext( inheritedContext ); - - const [ entry ] = each; - const { namespace, suffix } = entry; - - const list = evaluate( entry ); - return list.map( ( item ) => { - const mergedContext = deepSignal( {} ); - - const itemProp = - suffix === 'default' ? 'item' : kebabToCamelCase( suffix ); - const newValue = deepSignal( { - [ namespace ]: { [ itemProp ]: item }, - } ); - mergeDeepSignals( newValue, inheritedValue ); - mergeDeepSignals( mergedContext, newValue, true ); - - const scope = { ...getScope(), context: mergedContext }; - const key = eachKey - ? getEvaluate( { scope } )( eachKey[ 0 ] ) - : item; - - return ( - - { element.props.content } - - ); - } ); - }, - { priority: 20 } - ); - - directive( 'each-child', () => null ); -}; diff --git a/packages/interactivity/src/directives.tsx b/packages/interactivity/src/directives.tsx index 09190da1d10fbb..0015e804f488e0 100644 --- a/packages/interactivity/src/directives.tsx +++ b/packages/interactivity/src/directives.tsx @@ -5,13 +5,12 @@ */ import { h as createElement } from 'preact'; import { useContext, useMemo, useRef } from 'preact/hooks'; -import type { DeepSignalObject } from 'deepsignal'; +import type { DeepSignal } from 'deepsignal'; import { deepSignal, peek } from 'deepsignal'; /** * Internal dependencies */ -import { createPortal } from './portals'; import { useWatch, useInit } from './utils'; import { directive, getScope, getEvaluate } from './hooks'; import { kebabToCamelCase } from './utils/kebab-to-camelcase'; @@ -49,8 +48,8 @@ const isObject = ( item: any ): boolean => * // target is now { $key1: { peek: () => ({ a: 3 }) }, $key2: { peek: () => ({ b: 2 }) }, $key3: { peek: () => ({ c: 3 }) } } */ const mergeDeepSignals = ( - target: DeepSignalObject< any >, - source: DeepSignalObject< any >, + target: DeepSignal< any >, + source: DeepSignal< any >, overwrite?: boolean ) => { for ( const k in source ) { @@ -160,11 +159,6 @@ export default () => { { priority: 5 } ); - // data-wp-body - directive( 'body', ( { props: { children } } ) => { - return createPortal( children, document.body ); - } ); - // data-wp-watch--[name] directive( 'watch', ( { directives: { watch }, evaluate } ) => { watch.forEach( ( entry ) => { @@ -199,15 +193,15 @@ export default () => { // data-wp-class--[classname] directive( 'class', - ( { directives: { class: className }, element, evaluate } ) => { - className + ( { directives: { class: classNames }, element, evaluate } ) => { + classNames .filter( ( { suffix } ) => suffix !== 'default' ) .forEach( ( entry ) => { - const name = entry.suffix; - const result = evaluate( entry, { className: name } ); + const className = entry.suffix; + const result = evaluate( entry ); const currentClass = element.props.class || ''; const classFinder = new RegExp( - `(^|\\s)${ name }(\\s|$)`, + `(^|\\s)${ className }(\\s|$)`, 'g' ); if ( ! result ) @@ -216,19 +210,19 @@ export default () => { .trim(); else if ( ! classFinder.test( currentClass ) ) element.props.class = currentClass - ? `${ currentClass } ${ name }` - : name; + ? `${ currentClass } ${ className }` + : className; useInit( () => { /* * This seems necessary because Preact doesn't change the class - * names on the hydration, so it must be done manually. It doesn't + * names on the hydration, so we have to do it manually. It doesn't * need deps because it only needs to do it the first time. */ if ( ! result ) { - element.ref!.current.classList.remove( name ); + element.ref.current.classList.remove( className ); } else { - element.ref!.current.classList.add( name ); + element.ref.current.classList.add( className ); } } ); } ); @@ -240,26 +234,26 @@ export default () => { style .filter( ( { suffix } ) => suffix !== 'default' ) .forEach( ( entry ) => { - const key = entry.suffix; - const result = evaluate( entry, { key } ); + const styleProp = entry.suffix; + const result = evaluate( entry ); element.props.style = element.props.style || {}; if ( typeof element.props.style === 'string' ) element.props.style = cssStringToObject( element.props.style ); - if ( ! result ) delete element.props.style[ key ]; - else element.props.style[ key ] = result; + if ( ! result ) delete element.props.style[ styleProp ]; + else element.props.style[ styleProp ] = result; useInit( () => { /* * This seems necessary because Preact doesn't change the styles on - * the hydration, so it must be done manually. It doesn't need deps + * the hydration, so we have to do it manually. It doesn't need deps * because it only needs to do it the first time. */ if ( ! result ) { - element.ref.current.style.removeProperty( key ); + element.ref.current.style.removeProperty( styleProp ); } else { - element.ref.current.style[ key ] = result; + element.ref.current.style[ styleProp ] = result; } } ); } ); @@ -275,18 +269,22 @@ export default () => { /* * This is necessary because Preact doesn't change the attributes on the - * hydration, so it must be done manually. It only needs to do it the + * hydration, so we have to do it manually. It only needs to do it the * first time. After that, Preact will handle the changes. */ useInit( () => { const el = element.ref.current; /* - * Set the value directly to the corresponding HTMLElement instance - * property excluding the following special cases. It follows Preact's + * We set the value directly to the corresponding HTMLElement instance + * property excluding the following special cases. We follow Preact's * logic: https://github.com/preactjs/preact/blob/ea49f7a0f9d1ff2c98c0bdd66aa0cbc583055246/src/diff/props.js#L110-L129 */ - if ( + if ( attribute === 'style' ) { + if ( typeof result === 'string' ) + el.style.cssText = result; + return; + } else if ( attribute !== 'width' && attribute !== 'height' && attribute !== 'href' && @@ -321,8 +319,8 @@ export default () => { /* * aria- and data- attributes have no boolean representation. * A `false` value is different from the attribute not being - * present, so it can't be removed. - * It follows Preact's logic: https://github.com/preactjs/preact/blob/ea49f7a0f9d1ff2c98c0bdd66aa0cbc583055246/src/diff/props.js#L131C24-L136 + * present, so we can't remove it. + * We follow Preact's logic: https://github.com/preactjs/preact/blob/ea49f7a0f9d1ff2c98c0bdd66aa0cbc583055246/src/diff/props.js#L131C24-L136 */ if ( result !== null && diff --git a/packages/interactivity/src/store.ts b/packages/interactivity/src/store.ts index de604202e428ba..363b76b8328b92 100644 --- a/packages/interactivity/src/store.ts +++ b/packages/interactivity/src/store.ts @@ -183,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.'; From 071db981411b243c61b4ceebf864669a5d5287ea Mon Sep 17 00:00:00 2001 From: Carlos Bravo Date: Fri, 9 Feb 2024 17:10:15 +0100 Subject: [PATCH 13/13] Typing --- packages/interactivity/src/portals.ts | 126 -------------------------- packages/interactivity/src/types.ts | 4 - 2 files changed, 130 deletions(-) delete mode 100644 packages/interactivity/src/portals.ts diff --git a/packages/interactivity/src/portals.ts b/packages/interactivity/src/portals.ts deleted file mode 100644 index b80cc420f762a1..00000000000000 --- a/packages/interactivity/src/portals.ts +++ /dev/null @@ -1,126 +0,0 @@ -/** - * External dependencies - */ -import type { ContainerNode, VNode } from 'preact'; -import { createElement, render } from 'preact'; -/** - * Internal dependencies - */ -import type { - ContextProviderProps, - PortalInterface, - PortalProps, -} from './types'; - -/** - * `ContextProvider` is a function component that provides context to its child components. - * - * @param props - The properties passed to the component. - * It includes a `context` property which is the context to be provided to the child components, - * and a `children` property which are the child components that will receive the context. - * - * @return The child components passed in through `props.children`. - * - * @example - * - * - * - */ -function ContextProvider( props: ContextProviderProps ): any { - this.getChildContext = () => props.context; - return props.children; -} - -/** - * Portal component - * - * @param props - The properties passed to the component. - * - * TODO: use createRoot() instead of fake root - */ -function Portal( props: PortalProps | null | undefined ): any { - const _this = this; - const container = props._container; - - _this.componentWillUnmount = function () { - render( null, _this._temp ); - _this._temp = null; - _this._container = null; - }; - - /* - * When the container changes, it should clear the old container and - * indicate a new mount. - */ - if ( _this._container && _this._container !== container ) { - _this.componentWillUnmount(); - } - - /* - * When props.vnode is undefined/false/null, there is some kind of - * conditional vnode. This should not trigger a render. - */ - - if ( props._vnode ) { - if ( ! _this._temp ) { - _this._container = container; - - // Create a fake DOM parent node that manages a subset of `container`'s children: - _this._temp = { - nodeType: 1, - parentNode: container, - childNodes: [], - appendChild( child ) { - this.childNodes.push( child ); - _this._container.appendChild( child ); - }, - insertBefore( child ) { - this.childNodes.push( child ); - _this._container.appendChild( child ); - }, - removeChild( child ) { - this.childNodes.splice( - // eslint-disable-next-line no-bitwise - this.childNodes.indexOf( child ) >>> 1, - 1 - ); - _this._container.removeChild( child ); - }, - }; - } - - // Render our wrapping element into temp. - render( - createElement( - ContextProvider, - { context: _this.context }, - props._vnode - ), - _this._temp - ); - } else if ( _this._temp ) { - /* - * If it is a conditional render, on a mounted - * portal, the DOM should be cleared. - */ - _this.componentWillUnmount(); - } -} - -/** - * Create a `Portal` to continue rendering the vnode tree at a different DOM node - * - * @param vnode The vnode to render - * @param container The DOM node to continue rendering in to. - */ -export function createPortal( - vnode: VNode, - container: ContainerNode -): PortalInterface { - const el = createElement( Portal, { - _vnode: vnode, - _container: container, - } ) as PortalInterface; - el.containerInfo = container; - return el; -} diff --git a/packages/interactivity/src/types.ts b/packages/interactivity/src/types.ts index a379c62c33de86..3d655038b0d943 100644 --- a/packages/interactivity/src/types.ts +++ b/packages/interactivity/src/types.ts @@ -75,10 +75,6 @@ export interface DirectivesProps { previousScope?: Scope; } -export interface GetEvaluate { - ( args: { scope: Scope } ): Evaluate; -} - type PriorityLevel = string[]; export interface GetPriorityLevels {