diff --git a/packages/e2e-tests/plugins/interactive-blocks/directive-on-document/render.php b/packages/e2e-tests/plugins/interactive-blocks/directive-on-document/render.php index 8fdaed8907712c..d04cb79ea58522 100644 --- a/packages/e2e-tests/plugins/interactive-blocks/directive-on-document/render.php +++ b/packages/e2e-tests/plugins/interactive-blocks/directive-on-document/render.php @@ -8,8 +8,11 @@ gutenberg_enqueue_module( 'directive-on-document-view' ); ?> -
-
-

0

+
+ +
+
+

0

+
diff --git a/packages/e2e-tests/plugins/interactive-blocks/directive-on-document/view.js b/packages/e2e-tests/plugins/interactive-blocks/directive-on-document/view.js index 3a170f54489a0f..b3cb891e4d6cb5 100644 --- a/packages/e2e-tests/plugins/interactive-blocks/directive-on-document/view.js +++ b/packages/e2e-tests/plugins/interactive-blocks/directive-on-document/view.js @@ -1,7 +1,20 @@ /** * WordPress dependencies */ -import { store } from '@wordpress/interactivity'; +import { store, directive, getContext } from '@wordpress/interactivity'; + +// Mock `data-wp-show` directive to test when things are removed from the +// DOM. Replace with `data-wp-show` when it's ready. +directive( + 'show-mock', + ( { directives: { 'show-mock': showMock }, element, evaluate } ) => { + const entry = showMock.find( ( { suffix } ) => suffix === 'default' ); + if ( ! evaluate( entry ) ) { + return null; + } + return element; + } +); const { state } = store( 'directive-on-document', { state: { @@ -12,4 +25,10 @@ const { state } = store( 'directive-on-document', { state.counter += 1; }, }, + actions: { + visibilityHandler: () => { + const context = getContext(); + context.isVisible = ! context.isVisible; + }, + } } ); diff --git a/packages/e2e-tests/plugins/interactive-blocks/directive-on-window/render.php b/packages/e2e-tests/plugins/interactive-blocks/directive-on-window/render.php index 3a7fcc5e8e9e69..e9c8792354e2a5 100644 --- a/packages/e2e-tests/plugins/interactive-blocks/directive-on-window/render.php +++ b/packages/e2e-tests/plugins/interactive-blocks/directive-on-window/render.php @@ -8,8 +8,11 @@ gutenberg_enqueue_module( 'directive-on-window-view' ); ?> -
-
-

0

+
+ +
+
+

0

+
diff --git a/packages/e2e-tests/plugins/interactive-blocks/directive-on-window/view.js b/packages/e2e-tests/plugins/interactive-blocks/directive-on-window/view.js index 1fc1e938972de6..11d01b7a216d1c 100644 --- a/packages/e2e-tests/plugins/interactive-blocks/directive-on-window/view.js +++ b/packages/e2e-tests/plugins/interactive-blocks/directive-on-window/view.js @@ -1,7 +1,20 @@ /** * WordPress dependencies */ -import { store } from '@wordpress/interactivity'; +import { store, directive, getContext } from '@wordpress/interactivity'; + +// Mock `data-wp-show` directive to test when things are removed from the +// DOM. Replace with `data-wp-show` when it's ready. +directive( + 'show-mock', + ( { directives: { 'show-mock': showMock }, element, evaluate } ) => { + const entry = showMock.find( ( { suffix } ) => suffix === 'default' ); + if ( ! evaluate( entry ) ) { + return null; + } + return element; + } +); const { state } = store( 'directive-on-window', { state: { @@ -12,4 +25,10 @@ const { state } = store( 'directive-on-window', { state.counter += 1; }, }, + actions: { + visibilityHandler: () => { + const context = getContext(); + context.isVisible = ! context.isVisible; + }, + } } ); diff --git a/packages/interactivity/src/directives.js b/packages/interactivity/src/directives.js index 9ab51b0ba9bdb4..902925586645c4 100644 --- a/packages/interactivity/src/directives.js +++ b/packages/interactivity/src/directives.js @@ -8,7 +8,7 @@ import { deepSignal, peek } from 'deepsignal'; * Internal dependencies */ import { createPortal } from './portals'; -import { useWatch, useInit, useEffect } from './utils'; +import { useWatch, useInit } from './utils'; import { directive } from './hooks'; const isObject = ( item ) => @@ -28,6 +28,64 @@ const mergeDeepSignals = ( target, source, overwrite ) => { } }; +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( @@ -87,22 +145,6 @@ export default () => { } ); } ); - const getGlobalEventDirective = - ( type ) => - ( { directives, evaluate } ) => { - directives[ `on-${ type }` ] - .filter( ( { suffix } ) => suffix !== 'default' ) - .forEach( ( entry ) => { - useEffect( () => { - const cb = ( event ) => evaluate( entry, event ); - const globalVar = type === 'window' ? window : document; - globalVar.addEventListener( entry.suffix, cb ); - return () => - globalVar.removeEventListener( entry.suffix, cb ); - }, [] ); - } ); - }; - // data-wp-on-window--[event] directive( 'on-window', getGlobalEventDirective( 'window' ) ); // data-wp-on-document--[event] @@ -145,41 +187,6 @@ export default () => { } ); - 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 ]; - }; - // data-wp-style--[style-key] directive( 'style', ( { directives: { style }, element, evaluate } ) => { style diff --git a/test/e2e/specs/interactivity/directive-on-document.spec.ts b/test/e2e/specs/interactivity/directive-on-document.spec.ts index e7333a98419381..918f3945e010f1 100644 --- a/test/e2e/specs/interactivity/directive-on-document.spec.ts +++ b/test/e2e/specs/interactivity/directive-on-document.spec.ts @@ -26,4 +26,20 @@ test.describe( 'data-wp-on-document', () => { await page.keyboard.press( 'ArrowDown' ); await expect( counter ).toHaveText( '1' ); } ); + test( 'the event listener is removed when the element is removed', async ( { + page, + } ) => { + const counter = page.getByTestId( 'counter' ); + const visibilityButton = page.getByTestId( 'visibility' ); + await expect( counter ).toHaveText( '0' ); + await page.keyboard.press( 'ArrowDown' ); + await expect( counter ).toHaveText( '1' ); + // Remove the element. + await visibilityButton.click(); + // This keyboard press should not increase the counter. + await page.keyboard.press( 'ArrowDown' ); + // Add the element back. + await visibilityButton.click(); + await expect( counter ).toHaveText( '1' ); + } ); } ); diff --git a/test/e2e/specs/interactivity/directive-on-window.spec.ts b/test/e2e/specs/interactivity/directive-on-window.spec.ts index 991a44586c7e1e..ff6abf04971b58 100644 --- a/test/e2e/specs/interactivity/directive-on-window.spec.ts +++ b/test/e2e/specs/interactivity/directive-on-window.spec.ts @@ -25,4 +25,19 @@ test.describe( 'data-wp-on-window', () => { const counter = page.getByTestId( 'counter' ); await expect( counter ).toHaveText( '1' ); } ); + test( 'the event listener is removed when the element is removed', async ( { + page, + } ) => { + const counter = page.getByTestId( 'counter' ); + const visibilityButton = page.getByTestId( 'visibility' ); + await page.setViewportSize( { width: 600, height: 600 } ); + await expect( counter ).toHaveText( '1' ); + // Remove the element. + await visibilityButton.click(); + // This resize should not increase the counter. + await page.setViewportSize( { width: 300, height: 600 } ); + // Add the element back. + await visibilityButton.click(); + await expect( counter ).toHaveText( '1' ); + } ); } );