diff --git a/packages/e2e-tests/plugins/interactive-blocks/directive-run/block.json b/packages/e2e-tests/plugins/interactive-blocks/directive-run/block.json new file mode 100644 index 0000000000000..5242ca2c872ae --- /dev/null +++ b/packages/e2e-tests/plugins/interactive-blocks/directive-run/block.json @@ -0,0 +1,15 @@ +{ + "$schema": "https://schemas.wp.org/trunk/block.json", + "apiVersion": 2, + "name": "test/directive-run", + "title": "E2E Interactivity tests - directive run", + "category": "text", + "icon": "heart", + "description": "", + "supports": { + "interactivity": true + }, + "textdomain": "e2e-interactivity", + "viewScript": "directive-run-view", + "render": "file:./render.php" +} diff --git a/packages/e2e-tests/plugins/interactive-blocks/directive-run/render.php b/packages/e2e-tests/plugins/interactive-blocks/directive-run/render.php new file mode 100644 index 0000000000000..a7eaaa984035e --- /dev/null +++ b/packages/e2e-tests/plugins/interactive-blocks/directive-run/render.php @@ -0,0 +1,54 @@ + + +
+
+
+
+
no
+ +
+
+ +
+ + + + + + + +
+
+ Element with wp-run using hooks +
+
+
diff --git a/packages/e2e-tests/plugins/interactive-blocks/directive-run/view.js b/packages/e2e-tests/plugins/interactive-blocks/directive-run/view.js new file mode 100644 index 0000000000000..b6dd68154d716 --- /dev/null +++ b/packages/e2e-tests/plugins/interactive-blocks/directive-run/view.js @@ -0,0 +1,108 @@ +/** + * WordPress dependencies + */ +import { + store, + directive, + navigate, + useInit, + useWatch, + cloneElement, + getElement, +} from '@wordpress/interactivity'; + +// Custom directive to show hide the content elements in which it is placed. +directive( + 'show-children', + ( { directives: { 'show-children': showChildren }, element, evaluate } ) => { + const entry = showChildren.find( + ( { suffix } ) => suffix === 'default' + ); + return evaluate( entry ) + ? element + : cloneElement( element, { children: null } ); + }, + { priority: 9 } +); + +const html = ` +
+
+
+
+
yes
+ +
+
+`; + +const { state } = store( 'directive-run', { + state: { + isOpen: false, + isHydrated: 'no', + isMounted: 'no', + renderCount: 0, + clickCount: 0 + }, + actions: { + toggle() { + state.isOpen = ! state.isOpen; + }, + increment() { + state.clickCount = state.clickCount + 1; + }, + navigate() { + navigate( window.location, { + force: true, + html, + } ); + }, + }, + callbacks: { + updateIsHydrated() { + setTimeout( () => ( state.isHydrated = 'yes' ) ); + }, + updateIsMounted() { + setTimeout( () => ( state.isMounted = 'yes' ) ); + }, + updateRenderCount() { + setTimeout( () => ( state.renderCount = state.renderCount + 1 ) ); + }, + useHooks() { + // Runs only on first render. + useInit( () => { + const { ref } = getElement(); + ref + .closest( '[data-testid="wp-run hooks results"]') + .setAttribute( 'data-init', 'initialized' ); + return () => { + ref + .closest( '[data-testid="wp-run hooks results"]') + .setAttribute( 'data-init', 'cleaned up' ); + }; + } ); + + // Runs whenever a signal consumed inside updates its value. Also + // executes for the first render. + useWatch( () => { + const { ref } = getElement(); + const { clickCount } = state; + ref + .closest( '[data-testid="wp-run hooks results"]') + .setAttribute( 'data-watch', clickCount ); + return () => { + ref + .closest( '[data-testid="wp-run hooks results"]') + .setAttribute( 'data-watch', 'cleaned up' ); + }; + } ); + } + } +} ); diff --git a/packages/interactivity/CHANGELOG.md b/packages/interactivity/CHANGELOG.md index 8c03cfc314efe..ff0c4942abb3f 100644 --- a/packages/interactivity/CHANGELOG.md +++ b/packages/interactivity/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +### New Features + +- Add the `data-wp-run` directive along with the `useInit` and `useWatch` hooks. ([57805](https://github.com/WordPress/gutenberg/pull/57805)) + ### Bug Fix - Fix namespaces when there are nested interactive regions. ([#57029](https://github.com/WordPress/gutenberg/pull/57029)) diff --git a/packages/interactivity/docs/2-api-reference.md b/packages/interactivity/docs/2-api-reference.md index 3c8f179861f52..7980c31b984f8 100644 --- a/packages/interactivity/docs/2-api-reference.md +++ b/packages/interactivity/docs/2-api-reference.md @@ -22,6 +22,7 @@ DOM elements are connected to data stored in the state and context through direc - [`wp-on`](#wp-on) ![](https://img.shields.io/badge/EVENT_HANDLERS-afd2e3.svg) - [`wp-watch`](#wp-watch) ![](https://img.shields.io/badge/SIDE_EFFECTS-afd2e3.svg) - [`wp-init`](#wp-init) ![](https://img.shields.io/badge/SIDE_EFFECTS-afd2e3.svg) + - [`wp-run`](#wp-run) ![](https://img.shields.io/badge/SIDE_EFFECTS-afd2e3.svg) - [`wp-key`](#wp-key) ![](https://img.shields.io/badge/TEMPLATING-afd2e3.svg) - [Values of directives are references to store properties](#values-of-directives-are-references-to-store-properties) - [The store](#the-store) @@ -471,6 +472,62 @@ store( "myPlugin", { The `wp-init` can return a function. If it does, the returned function will run when the element is removed from the DOM. +#### `wp-run` + +It runs the passed callback **during node's render execution**. + +You can use and compose hooks like `useState`, `useWatch` or `useEffect` inside inside the passed callback and create your own logic, providing more flexibility than previous directives. + +You can attach several `wp-run` to the same DOM element by using the syntax `data-wp-run--[unique-id]`. _The unique id doesn't need to be unique globally, it just needs to be different than the other unique ids of the `wp-run` directives of that DOM element._ + +_Example of `data-wp-run` directive_ + +```html +
+

Hi!

+
+``` + +
+ See store used with the directive above + +```js +import { store, useState, useEffect } from '@wordpress/interactivity'; + +// Unlike `data-wp-init` and `data-wp-watch`, you can use any hooks inside +// `data-wp-run` callbacks. +const useInView = ( ref ) => { + const [ inView, setInView ] = useState( false ); + useEffect( () => { + const observer = new IntersectionObserver( ( [ entry ] ) => { + setInView( entry.isIntersecting ); + } ); + if ( ref ) observer.observe( ref ); + return () => ref && observer.unobserve( ref ); + }, []); + return inView; +}; + +store( 'myPlugin', { + callbacks: { + logInView: () => { + const { ref } = getElement(); + const isInView = useInView( ref ); + useEffect( () => { + if ( isInView ) { + console.log( 'Inside' ); + } else { + console.log( 'Outside' ); + } + }); + } + }, +} ); +``` + +
+
+ #### `wp-key` The `wp-key` directive assigns a unique key to an element to help the Interactivity API identify it when iterating through arrays of elements. This becomes important if your array elements can move (e.g., due to sorting), get inserted, or get deleted. A well-chosen key value helps the Interactivity API infer what exactly has changed in the array, allowing it to make the correct updates to the DOM. @@ -528,7 +585,7 @@ In the example below, we get `state.isPlaying` from `otherPlugin` instead of `my ```html
-
+
diff --git a/packages/interactivity/src/directives.js b/packages/interactivity/src/directives.js index 0793dc0cc5d5b..e9cc66fa50a6f 100644 --- a/packages/interactivity/src/directives.js +++ b/packages/interactivity/src/directives.js @@ -14,7 +14,7 @@ import { deepSignal, peek } from 'deepsignal'; * Internal dependencies */ import { createPortal } from './portals'; -import { useSignalEffect } from './utils'; +import { useWatch, useInit } from './utils'; import { directive } from './hooks'; import { SlotProvider, Slot, Fill } from './slots'; import { navigate } from './router'; @@ -75,14 +75,14 @@ export default () => { // data-wp-watch--[name] directive( 'watch', ( { directives: { watch }, evaluate } ) => { watch.forEach( ( entry ) => { - useSignalEffect( () => evaluate( entry ) ); + useWatch( () => evaluate( entry ) ); } ); } ); // data-wp-init--[name] directive( 'init', ( { directives: { init }, evaluate } ) => { init.forEach( ( entry ) => { - useEffect( () => evaluate( entry ), [] ); + useInit( () => evaluate( entry ) ); } ); } ); @@ -118,7 +118,7 @@ export default () => { ? `${ currentClass } ${ name }` : name; - useEffect( () => { + 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 // need deps because it only needs to do it the first time. @@ -127,7 +127,7 @@ export default () => { } else { element.ref.current.classList.add( name ); } - }, [] ); + } ); } ); } ); @@ -182,7 +182,7 @@ export default () => { if ( ! result ) delete element.props.style[ key ]; else element.props.style[ key ] = result; - useEffect( () => { + 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 // because it only needs to do it the first time. @@ -191,7 +191,7 @@ export default () => { } else { element.ref.current.style[ key ] = result; } - }, [] ); + } ); } ); } ); @@ -217,7 +217,7 @@ export default () => { // This seems necessary because Preact doesn't change the attributes // 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. - useEffect( () => { + useInit( () => { const el = element.ref.current; // We set the value directly to the corresponding @@ -260,7 +260,7 @@ export default () => { } else { el.removeAttribute( attribute ); } - }, [] ); + } ); } ); } ); @@ -390,4 +390,9 @@ export default () => { ), { priority: 4 } ); + + // data-wp-run + directive( 'run', ( { directives: { run }, evaluate } ) => { + run.forEach( ( entry ) => evaluate( entry ) ); + } ); }; diff --git a/packages/interactivity/src/index.js b/packages/interactivity/src/index.js index 6c7b98e8e7a79..cf0b4c88cac4b 100644 --- a/packages/interactivity/src/index.js +++ b/packages/interactivity/src/index.js @@ -7,8 +7,17 @@ import { init } from './router'; export { store } from './store'; export { directive, getContext, getElement } from './hooks'; export { navigate, prefetch } from './router'; -export { h as createElement } from 'preact'; -export { useEffect, useContext, useMemo } from 'preact/hooks'; +export { + useWatch, + useInit, + useEffect, + useLayoutEffect, + useCallback, + useMemo, +} from './utils'; + +export { h as createElement, cloneElement } from 'preact'; +export { useContext, useState, useRef } from 'preact/hooks'; export { deepSignal } from 'deepsignal'; document.addEventListener( 'DOMContentLoaded', async () => { diff --git a/packages/interactivity/src/utils.js b/packages/interactivity/src/utils.js index 10b53104fb9c8..021df983cb4f0 100644 --- a/packages/interactivity/src/utils.js +++ b/packages/interactivity/src/utils.js @@ -1,9 +1,19 @@ /** * External dependencies */ -import { useEffect } from 'preact/hooks'; +import { + useMemo as _useMemo, + useCallback as _useCallback, + useEffect as _useEffect, + useLayoutEffect as _useLayoutEffect, +} from 'preact/hooks'; import { effect } from '@preact/signals'; +/** + * Internal dependencies + */ +import { getScope, setScope, resetScope } from './hooks'; + const afterNextFrame = ( callback ) => { return new Promise( ( resolve ) => { const done = () => { @@ -38,7 +48,7 @@ function createFlusher( compute, notify ) { // implementation comes from this PR, but we added short-cirtuiting to avoid // infinite loops: https://github.com/preactjs/signals/pull/290 export function useSignalEffect( callback ) { - useEffect( () => { + _useEffect( () => { let eff = null; let isExecuting = false; const notify = async () => { @@ -53,6 +63,121 @@ 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. + */ +const withScope = ( func ) => { + const scope = getScope(); + return ( ...args ) => { + setScope( scope ); + try { + return func( ...args ); + } finally { + resetScope(); + } + }; +}; + +/** + * Accepts a function that contains imperative code which runs whenever any of + * the accessed _reactive_ properties (e.g., values from the global state or the + * context) is modified. + * + * 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. + */ +export function useWatch( callback ) { + useSignalEffect( withScope( callback ) ); +} + +/** + * Accepts a function that contains imperative code which runs only after the + * element's first render, mainly useful for intialization logic. + * + * 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. + */ +export function useInit( callback ) { + _useEffect( withScope( callback ), [] ); +} + +/** + * Accepts a function that contains imperative, possibly effectful code. The + * effects run after browser paint, without blocking it. + * + * This hook is equivalent to Preact's `useEffect` and makes the element's 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 `===`). + */ +export function useEffect( callback, inputs ) { + _useEffect( withScope( callback ), inputs ); +} + +/** + * Accepts a function that contains imperative, possibly effectful code. Use + * this to read layout from the DOM and synchronously re-render. + * + * This hook is equivalent to Preact's `useLayoutEffect` and makes the element's + * 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 `===`). + */ +export function useLayoutEffect( callback, inputs ) { + _useLayoutEffect( withScope( callback ), inputs ); +} + +/** + * Returns a memoized version of the callback that only changes if one of the + * inputs has changed (using `===`). + * + * This hook is equivalent to Preact's `useCallback` and makes the element's + * 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 `===`). + */ +export function useCallback( callback, inputs ) { + _useCallback( withScope( callback ), inputs ); +} + +/** + * Pass a factory function and an array of inputs. `useMemo` will only recompute + * the memoized value when one of the inputs has changed. + * + * This hook is equivalent to Preact's `useMemo` and makes the element's scope + * available so functions like `getElement()` and `getContext()` can be used + * inside the passed factory function. + * + * @param {Function} factory 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 `===`). + */ +export function useMemo( factory, inputs ) { + _useMemo( withScope( factory ), inputs ); +} + // For wrapperless hydration. // See https://gist.github.com/developit/f4c67a2ede71dc2fab7f357f39cff28c export const createRootFragment = ( parent, replaceNode ) => { diff --git a/test/e2e/specs/interactivity/directive-run.spec.ts b/test/e2e/specs/interactivity/directive-run.spec.ts new file mode 100644 index 0000000000000..0348bdb95c2ab --- /dev/null +++ b/test/e2e/specs/interactivity/directive-run.spec.ts @@ -0,0 +1,61 @@ +/** + * Internal dependencies + */ +import { test, expect } from './fixtures'; + +test.describe( 'data-wp-run', () => { + test.beforeAll( async ( { interactivityUtils: utils } ) => { + await utils.activatePlugins(); + await utils.addPostWithBlock( 'test/directive-run' ); + } ); + + test.beforeEach( async ( { interactivityUtils: utils, page } ) => { + await page.goto( utils.getLink( 'test/directive-run' ) ); + } ); + + test.afterAll( async ( { interactivityUtils: utils } ) => { + await utils.deactivatePlugins(); + await utils.deleteAllPosts(); + } ); + + test( 'should execute each element render', async ( { page } ) => { + await expect( page.getByTestId( 'hydrated' ) ).toHaveText( 'yes' ); + await expect( page.getByTestId( 'renderCount' ) ).toHaveText( '1' ); + await page.getByTestId( 'increment' ).click(); + await expect( page.getByTestId( 'renderCount' ) ).toHaveText( '2' ); + await page.getByTestId( 'increment' ).click(); + await expect( page.getByTestId( 'renderCount' ) ).toHaveText( '3' ); + } ); + + test( 'should execute when an element is mounted', async ( { page } ) => { + await expect( page.getByTestId( 'mounted' ) ).toHaveText( 'no' ); + await page.getByTestId( 'toggle' ).click(); + await expect( page.getByTestId( 'mounted' ) ).toHaveText( 'yes' ); + } ); + + test( 'should work with client-side navigation', async ( { page } ) => { + await page.getByTestId( 'increment' ).click(); + await page.getByTestId( 'increment' ).click(); + await expect( page.getByTestId( 'navigated' ) ).toHaveText( 'no' ); + await expect( page.getByTestId( 'renderCount' ) ).toHaveText( '3' ); + await page.getByTestId( 'navigate' ).click(); + await expect( page.getByTestId( 'navigated' ) ).toHaveText( 'yes' ); + await expect( page.getByTestId( 'renderCount' ) ).toHaveText( '4' ); + } ); + + test( 'should allow executing hooks', async ( { page } ) => { + await page.getByTestId( 'toggle' ).click(); + const results = page.getByTestId( 'wp-run hooks results' ); + await expect( results ).toHaveAttribute( 'data-init', 'initialized' ); + + await expect( results ).toHaveAttribute( 'data-watch', '0' ); + await page.getByTestId( 'increment' ).click(); + await expect( results ).toHaveAttribute( 'data-watch', '1' ); + await page.getByTestId( 'increment' ).click(); + await expect( results ).toHaveAttribute( 'data-watch', '2' ); + + await page.getByTestId( 'toggle' ).click(); + await expect( results ).toHaveAttribute( 'data-init', 'cleaned up' ); + await expect( results ).toHaveAttribute( 'data-watch', 'cleaned up' ); + } ); +} );