diff --git a/e2e/html/directives-effect.html b/e2e/html/directives-effect.html
new file mode 100644
index 00000000..1f03784e
--- /dev/null
+++ b/e2e/html/directives-effect.html
@@ -0,0 +1,31 @@
+
+
+
+ Directives -- data-wp-effect
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/e2e/js/directive-effect.js b/e2e/js/directive-effect.js
new file mode 100644
index 00000000..a0a6181e
--- /dev/null
+++ b/e2e/js/directive-effect.js
@@ -0,0 +1,60 @@
+import { store } from '../../src/runtime/store';
+import { directive } from '../../src/runtime/hooks';
+import { useContext, useMemo } from 'preact/hooks';
+
+// Fake `data-wp-fakeshow` directive to test when things are removed from the DOM.
+// Replace with `data-wp-show` when it's ready.
+directive(
+ 'fakeshow',
+ ({
+ directives: {
+ fakeshow: { default: fakeshow },
+ },
+ element,
+ evaluate,
+ context,
+ }) => {
+ const contextValue = useContext(context);
+ const children = useMemo(
+ () =>
+ element.type === 'template'
+ ? element.props.templateChildren
+ : element,
+ []
+ );
+ if (!evaluate(fakeshow, { context: contextValue })) return null;
+ return children;
+ }
+);
+
+store({
+ state: {
+ isOpen: true,
+ isElementInTheDOM: false,
+ },
+ selectors: {
+ elementInTheDOM: ({ state }) =>
+ state.isElementInTheDOM
+ ? 'element is in the DOM'
+ : 'element is not in the DOM',
+ },
+ actions: {
+ toggle({ state }) {
+ state.isOpen = !state.isOpen;
+ },
+ },
+ effects: {
+ elementAddedToTheDOM: ({ state }) => {
+ state.isElementInTheDOM = true;
+
+ return () => {
+ state.isElementInTheDOM = false;
+ };
+ },
+ changeFocus: ({ state }) => {
+ if (state.isOpen) {
+ document.querySelector("[data-testid='input']").focus();
+ }
+ },
+ },
+});
diff --git a/e2e/specs/directive-effect.spec.ts b/e2e/specs/directive-effect.spec.ts
new file mode 100644
index 00000000..07bca45e
--- /dev/null
+++ b/e2e/specs/directive-effect.spec.ts
@@ -0,0 +1,26 @@
+import { test, expect } from '../tests';
+
+test.describe('data-wp-effect', () => {
+ test.beforeEach(async ({ goToFile }) => {
+ await goToFile('directives-effect.html');
+ });
+
+ test('check that effect runs when it is added', async ({ page }) => {
+ const el = page.getByTestId('element in the DOM');
+ await expect(el).toContainText('element is in the DOM');
+ });
+
+ test('check that effect runs when it is removed', async ({ page }) => {
+ await page.getByTestId('toggle').click();
+ const el = page.getByTestId('element in the DOM');
+ await expect(el).toContainText('element is not in the DOM');
+ });
+
+ test('change focus after DOM changes', async ({ page }) => {
+ const el = page.getByTestId('input');
+ await expect(el).toBeFocused();
+ await page.getByTestId('toggle').click();
+ await page.getByTestId('toggle').click();
+ await expect(el).toBeFocused();
+ });
+});
diff --git a/src/runtime/directives.js b/src/runtime/directives.js
index 2217c389..831a72a3 100644
--- a/src/runtime/directives.js
+++ b/src/runtime/directives.js
@@ -1,6 +1,6 @@
import { useContext, useMemo, useEffect } from 'preact/hooks';
-import { useSignalEffect } from '@preact/signals';
import { deepSignal, peek } from 'deepsignal';
+import { useSignalEffect } from './utils';
import { directive } from './hooks';
import { prefetch, navigate, canDoClientSideNavigation } from './router';
@@ -48,7 +48,7 @@ export default () => {
const contextValue = useContext(context);
Object.values(effect).forEach((path) => {
useSignalEffect(() => {
- evaluate(path, { context: contextValue });
+ return evaluate(path, { context: contextValue });
});
});
});
@@ -185,6 +185,7 @@ export default () => {
context,
}) => {
const contextValue = useContext(context);
+
if (!evaluate(show, { context: contextValue }))
element.props.children = (
{element.props.children}
diff --git a/src/runtime/utils.js b/src/runtime/utils.js
index f1748293..ecae87f1 100644
--- a/src/runtime/utils.js
+++ b/src/runtime/utils.js
@@ -1,4 +1,47 @@
-// For wrapperless hydration of document.body.
+import { useRef, useEffect } from 'preact/hooks';
+import { effect } from '@preact/signals';
+
+function afterNextFrame(callback) {
+ const done = () => {
+ cancelAnimationFrame(raf);
+ setTimeout(callback);
+ };
+ const raf = requestAnimationFrame(done);
+}
+
+// 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 () {
+ flush = this.c.bind(this);
+ this.x = compute;
+ this.c = notify;
+ return compute();
+ });
+ return { flush, dispose };
+}
+
+// Version of `useSignalEffect` with a `useEffect`-like execution. This hook
+// implementation comes from this PR:
+// https://github.com/preactjs/signals/pull/290.
+//
+// We need to include it here in this repo until the mentioned PR is merged.
+export function useSignalEffect(cb) {
+ const callback = useRef(cb);
+ callback.current = cb;
+
+ useEffect(() => {
+ const execute = () => callback.current();
+ const notify = () => afterNextFrame(eff.flush);
+ const eff = createFlusher(execute, notify);
+ return eff.dispose;
+ }, []);
+}
+
+// For wrapperless hydration.
// See https://gist.github.com/developit/f4c67a2ede71dc2fab7f357f39cff28c
export const createRootFragment = (parent, replaceNode) => {
replaceNode = [].concat(replaceNode);
diff --git a/webpack.config.js b/webpack.config.js
index d3b84f2e..1a9927db 100644
--- a/webpack.config.js
+++ b/webpack.config.js
@@ -11,6 +11,7 @@ module.exports = [
'e2e/page-1': './e2e/page-1',
'e2e/page-2': './e2e/page-2',
'e2e/directive-bind': './e2e/js/directive-bind',
+ 'e2e/directive-effect': './e2e/js/directive-effect',
},
output: {
filename: '[name].js',