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 = ( 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',