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 @@
+
+
+
-
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' );
+ } );
+} );