diff --git a/.gitignore b/.gitignore
index 4a7f4708ce399a..19e43aecea7b82 100644
--- a/.gitignore
+++ b/.gitignore
@@ -15,6 +15,7 @@ coverage
*.log
yarn.lock
/artifacts
+/test/e2e/artifacts
/perf-envs
/composer.lock
diff --git a/packages/e2e-test-utils-playwright/src/request-utils/posts.ts b/packages/e2e-test-utils-playwright/src/request-utils/posts.ts
index e02dc11d3842a0..5e32c0c877555c 100644
--- a/packages/e2e-test-utils-playwright/src/request-utils/posts.ts
+++ b/packages/e2e-test-utils-playwright/src/request-utils/posts.ts
@@ -7,6 +7,7 @@ export interface Post {
id: number;
content: string;
status: 'publish' | 'future' | 'draft' | 'pending' | 'private';
+ link: string;
}
export interface CreatePostPayload {
diff --git a/packages/e2e-tests/plugins/interactive-blocks.php b/packages/e2e-tests/plugins/interactive-blocks.php
new file mode 100644
index 00000000000000..8a44e5a0efd4ac
--- /dev/null
+++ b/packages/e2e-tests/plugins/interactive-blocks.php
@@ -0,0 +1,48 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Some Text
+
+
diff --git a/packages/e2e-tests/plugins/interactive-blocks/directive-bind/view.js b/packages/e2e-tests/plugins/interactive-blocks/directive-bind/view.js
new file mode 100644
index 00000000000000..0cd590f184cc85
--- /dev/null
+++ b/packages/e2e-tests/plugins/interactive-blocks/directive-bind/view.js
@@ -0,0 +1,23 @@
+( ( { wp } ) => {
+ const { store } = wp.interactivity;
+
+ store( {
+ state: {
+ url: '/some-url',
+ checked: true,
+ show: false,
+ width: 1,
+ },
+ foo: {
+ bar: 1,
+ },
+ actions: {
+ toggle: ( { state, foo } ) => {
+ state.url = '/some-other-url';
+ state.checked = ! state.checked;
+ state.show = ! state.show;
+ state.width += foo.bar;
+ },
+ },
+ } );
+} )( window );
diff --git a/packages/e2e-tests/plugins/interactive-blocks/directive-class/block.json b/packages/e2e-tests/plugins/interactive-blocks/directive-class/block.json
new file mode 100644
index 00000000000000..af2764db986919
--- /dev/null
+++ b/packages/e2e-tests/plugins/interactive-blocks/directive-class/block.json
@@ -0,0 +1,14 @@
+{
+ "apiVersion": 2,
+ "name": "test/directive-class",
+ "title": "E2E Interactivity tests - directive class",
+ "category": "text",
+ "icon": "heart",
+ "description": "",
+ "supports": {
+ "interactivity": true
+ },
+ "textdomain": "e2e-interactivity",
+ "viewScript": "directive-class-view",
+ "render": "file:./render.php"
+}
diff --git a/packages/e2e-tests/plugins/interactive-blocks/directive-class/render.php b/packages/e2e-tests/plugins/interactive-blocks/directive-class/render.php
new file mode 100644
index 00000000000000..19da052dc1148c
--- /dev/null
+++ b/packages/e2e-tests/plugins/interactive-blocks/directive-class/render.php
@@ -0,0 +1,75 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/packages/e2e-tests/plugins/interactive-blocks/directive-class/view.js b/packages/e2e-tests/plugins/interactive-blocks/directive-class/view.js
new file mode 100644
index 00000000000000..bb06cf38412811
--- /dev/null
+++ b/packages/e2e-tests/plugins/interactive-blocks/directive-class/view.js
@@ -0,0 +1,21 @@
+( ( { wp } ) => {
+ const { store } = wp.interactivity;
+
+ store( {
+ state: {
+ trueValue: true,
+ falseValue: false,
+ },
+ actions: {
+ toggleTrueValue: ( { state } ) => {
+ state.trueValue = ! state.trueValue;
+ },
+ toggleFalseValue: ( { state } ) => {
+ state.falseValue = ! state.falseValue;
+ },
+ toggleContextFalseValue: ( { context } ) => {
+ context.falseValue = ! context.falseValue;
+ },
+ },
+ } );
+} )( window );
diff --git a/packages/e2e-tests/plugins/interactive-blocks/directive-context/block.json b/packages/e2e-tests/plugins/interactive-blocks/directive-context/block.json
new file mode 100644
index 00000000000000..1b3c448cc62aac
--- /dev/null
+++ b/packages/e2e-tests/plugins/interactive-blocks/directive-context/block.json
@@ -0,0 +1,14 @@
+{
+ "apiVersion": 2,
+ "name": "test/directive-context",
+ "title": "E2E Interactivity tests - directive context",
+ "category": "text",
+ "icon": "heart",
+ "description": "",
+ "supports": {
+ "interactivity": true
+ },
+ "textdomain": "e2e-interactivity",
+ "viewScript": "directive-context-view",
+ "render": "file:./render.php"
+}
diff --git a/packages/e2e-tests/plugins/interactive-blocks/directive-context/render.php b/packages/e2e-tests/plugins/interactive-blocks/directive-context/render.php
new file mode 100644
index 00000000000000..a9b0402d1b094e
--- /dev/null
+++ b/packages/e2e-tests/plugins/interactive-blocks/directive-context/render.php
@@ -0,0 +1,121 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/packages/e2e-tests/plugins/interactive-blocks/directive-context/view.js b/packages/e2e-tests/plugins/interactive-blocks/directive-context/view.js
new file mode 100644
index 00000000000000..46483aaa2ea53d
--- /dev/null
+++ b/packages/e2e-tests/plugins/interactive-blocks/directive-context/view.js
@@ -0,0 +1,22 @@
+( ( { wp } ) => {
+ const { store } = wp.interactivity;
+
+ store( {
+ derived: {
+ renderContext: ( { context } ) => {
+ return JSON.stringify( context, undefined, 2 );
+ },
+ },
+ actions: {
+ updateContext: ( { context, event } ) => {
+ const { name, value } = event.target;
+ const [ key, ...path ] = name.split( '.' ).reverse();
+ const obj = path.reduceRight( ( o, k ) => o[ k ], context );
+ obj[ key ] = value;
+ },
+ toggleContextText: ( { context } ) => {
+ context.text = context.text === 'Text 1' ? 'Text 2' : 'Text 1';
+ },
+ },
+ } );
+} )( window );
diff --git a/packages/e2e-tests/plugins/interactive-blocks/directive-effect/block.json b/packages/e2e-tests/plugins/interactive-blocks/directive-effect/block.json
new file mode 100644
index 00000000000000..b9cb2f782b2e6f
--- /dev/null
+++ b/packages/e2e-tests/plugins/interactive-blocks/directive-effect/block.json
@@ -0,0 +1,14 @@
+{
+ "apiVersion": 2,
+ "name": "test/directive-effect",
+ "title": "E2E Interactivity tests - directive effect",
+ "category": "text",
+ "icon": "heart",
+ "description": "",
+ "supports": {
+ "interactivity": true
+ },
+ "textdomain": "e2e-interactivity",
+ "viewScript": "directive-effect-view",
+ "render": "file:./render.php"
+}
diff --git a/packages/e2e-tests/plugins/interactive-blocks/directive-effect/render.php b/packages/e2e-tests/plugins/interactive-blocks/directive-effect/render.php
new file mode 100644
index 00000000000000..243826aae35046
--- /dev/null
+++ b/packages/e2e-tests/plugins/interactive-blocks/directive-effect/render.php
@@ -0,0 +1,27 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/packages/e2e-tests/plugins/interactive-blocks/directive-effect/view.js b/packages/e2e-tests/plugins/interactive-blocks/directive-effect/view.js
new file mode 100644
index 00000000000000..debb363a9ac0fd
--- /dev/null
+++ b/packages/e2e-tests/plugins/interactive-blocks/directive-effect/view.js
@@ -0,0 +1,61 @@
+( ( { wp } ) => {
+ const { store, directive, useContext, useMemo } = wp.interactivity;
+
+ // 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();
+ }
+ },
+ },
+ } );
+
+} )( window );
diff --git a/packages/e2e-tests/plugins/interactive-blocks/directive-priorities/block.json b/packages/e2e-tests/plugins/interactive-blocks/directive-priorities/block.json
new file mode 100644
index 00000000000000..c7361c3d5f121a
--- /dev/null
+++ b/packages/e2e-tests/plugins/interactive-blocks/directive-priorities/block.json
@@ -0,0 +1,14 @@
+{
+ "apiVersion": 2,
+ "name": "test/directive-priorities",
+ "title": "E2E Interactivity tests - directive priorities",
+ "category": "text",
+ "icon": "heart",
+ "description": "",
+ "supports": {
+ "interactivity": true
+ },
+ "textdomain": "e2e-interactivity",
+ "viewScript": "directive-priorities-view",
+ "render": "file:./render.php"
+}
diff --git a/packages/e2e-tests/plugins/interactive-blocks/directive-priorities/render.php b/packages/e2e-tests/plugins/interactive-blocks/directive-priorities/render.php
new file mode 100644
index 00000000000000..a5f0b045ab0d67
--- /dev/null
+++ b/packages/e2e-tests/plugins/interactive-blocks/directive-priorities/render.php
@@ -0,0 +1,20 @@
+
+
diff --git a/packages/e2e-tests/plugins/interactive-blocks/directive-priorities/view.js b/packages/e2e-tests/plugins/interactive-blocks/directive-priorities/view.js
new file mode 100644
index 00000000000000..cedc0c7c1d3ad3
--- /dev/null
+++ b/packages/e2e-tests/plugins/interactive-blocks/directive-priorities/view.js
@@ -0,0 +1,121 @@
+( ( { wp } ) => {
+ /**
+ * WordPress dependencies
+ */
+ const {
+ store,
+ directive,
+ deepSignal,
+ useContext,
+ useEffect,
+ createElement: h
+ } = wp.interactivity;
+
+ /**
+ * Util to check that render calls happen in order.
+ *
+ * @param {string} n Name passed from the directive being executed.
+ */
+ const executionProof = ( n ) => {
+ const el = document.querySelector( '[data-testid="execution order"]' );
+ if ( ! el.textContent ) el.textContent = n;
+ else el.textContent += `, ${ n }`;
+ };
+
+ /**
+ * Simple context directive, just for testing purposes. It provides a deep
+ * signal with these two properties:
+ * - attribute: 'from context'
+ * - text: 'from context'
+ */
+ directive(
+ 'test-context',
+ ( { context: { Provider }, props: { children } } ) => {
+ executionProof( 'context' );
+ const value = deepSignal( {
+ attribute: 'from context',
+ text: 'from context',
+ } );
+ return h( Provider, { value }, children );
+ },
+ { priority: 8 }
+ );
+
+ /**
+ * Simple attribute directive, for testing purposes. It reads the value of
+ * `attribute` from context and populates `data-attribute` with it.
+ */
+ directive( 'test-attribute', ( { context, evaluate, element } ) => {
+ executionProof( 'attribute' );
+ const contextValue = useContext( context );
+ const attributeValue = evaluate( 'context.attribute', {
+ context: contextValue,
+ } );
+ useEffect( () => {
+ element.ref.current.setAttribute(
+ 'data-attribute',
+ attributeValue,
+ );
+ }, [] );
+ element.props[ 'data-attribute' ] = attributeValue;
+ } );
+
+ /**
+ * Simple text directive, for testing purposes. It reads the value of
+ * `text` from context and populates `children` with it.
+ */
+ directive(
+ 'test-text',
+ ( { context, evaluate, element } ) => {
+ executionProof( 'text' );
+ const contextValue = useContext( context );
+ const textValue = evaluate( 'context.text', {
+ context: contextValue,
+ } );
+ element.props.children =
+ h( 'p', { 'data-testid': 'text' }, textValue );
+ },
+ { priority: 12 }
+ );
+
+ /**
+ * Children directive, for testing purposes. It adds a wrapper around
+ * `children`, including two buttons to modify `text` and `attribute` values
+ * from the received context.
+ */
+ directive(
+ 'test-children',
+ ( { context, evaluate, element } ) => {
+ executionProof( 'children' );
+ const contextValue = useContext( context );
+ const updateAttribute = () => {
+ evaluate(
+ 'actions.updateAttribute',
+ { context: contextValue }
+ );
+ };
+ const updateText = () => {
+ evaluate( 'actions.updateText', { context: contextValue } );
+ };
+ element.props.children = h(
+ 'div',
+ {},
+ element.props.children,
+ h( 'button', { onClick: updateAttribute }, 'Update attribute' ),
+ h( 'button', { onClick: updateText }, 'Update text' )
+ );
+ },
+ { priority: 14 }
+ );
+
+ store( {
+ actions: {
+ updateText( { context } ) {
+ context.text = 'updated';
+ },
+ updateAttribute( { context } ) {
+ context.attribute = 'updated';
+ },
+ },
+ } );
+} )( window );
diff --git a/packages/e2e-tests/plugins/interactive-blocks/directive-show/block.json b/packages/e2e-tests/plugins/interactive-blocks/directive-show/block.json
new file mode 100644
index 00000000000000..ab6801843a7cae
--- /dev/null
+++ b/packages/e2e-tests/plugins/interactive-blocks/directive-show/block.json
@@ -0,0 +1,14 @@
+{
+ "apiVersion": 2,
+ "name": "test/directive-show",
+ "title": "E2E Interactivity tests - directive show",
+ "category": "text",
+ "icon": "heart",
+ "description": "",
+ "supports": {
+ "interactivity": true
+ },
+ "textdomain": "e2e-interactivity",
+ "viewScript": "directive-show-view",
+ "render": "file:./render.php"
+}
diff --git a/packages/e2e-tests/plugins/interactive-blocks/directive-show/render.php b/packages/e2e-tests/plugins/interactive-blocks/directive-show/render.php
new file mode 100644
index 00000000000000..5818f3b7c10b63
--- /dev/null
+++ b/packages/e2e-tests/plugins/interactive-blocks/directive-show/render.php
@@ -0,0 +1,53 @@
+
+
+
+
+
+
+
+
+
+
+
+
+ falseValue
+
+
+
+
diff --git a/packages/e2e-tests/plugins/interactive-blocks/directive-show/view.js b/packages/e2e-tests/plugins/interactive-blocks/directive-show/view.js
new file mode 100644
index 00000000000000..9398321b4cfe43
--- /dev/null
+++ b/packages/e2e-tests/plugins/interactive-blocks/directive-show/view.js
@@ -0,0 +1,24 @@
+( ( { wp } ) => {
+ /**
+ * WordPress dependencies
+ */
+ const { store } = wp.interactivity;
+
+ store( {
+ state: {
+ trueValue: true,
+ falseValue: false,
+ },
+ actions: {
+ toggleTrueValue: ( { state } ) => {
+ state.trueValue = ! state.trueValue;
+ },
+ toggleFalseValue: ( { state } ) => {
+ state.falseValue = ! state.falseValue;
+ },
+ toggleContextFalseValue: ( { context } ) => {
+ context.falseValue = ! context.falseValue;
+ },
+ },
+ } );
+} )( window );
diff --git a/packages/e2e-tests/plugins/interactive-blocks/directive-text/block.json b/packages/e2e-tests/plugins/interactive-blocks/directive-text/block.json
new file mode 100644
index 00000000000000..7295849b9912d7
--- /dev/null
+++ b/packages/e2e-tests/plugins/interactive-blocks/directive-text/block.json
@@ -0,0 +1,14 @@
+{
+ "apiVersion": 2,
+ "name": "test/directive-text",
+ "title": "E2E Interactivity tests - directive text",
+ "category": "text",
+ "icon": "heart",
+ "description": "",
+ "supports": {
+ "interactivity": true
+ },
+ "textdomain": "e2e-interactivity",
+ "viewScript": "directive-text-view",
+ "render": "file:./render.php"
+}
diff --git a/packages/e2e-tests/plugins/interactive-blocks/directive-text/render.php b/packages/e2e-tests/plugins/interactive-blocks/directive-text/render.php
new file mode 100644
index 00000000000000..54ac9c09e7d863
--- /dev/null
+++ b/packages/e2e-tests/plugins/interactive-blocks/directive-text/render.php
@@ -0,0 +1,35 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/packages/e2e-tests/plugins/interactive-blocks/directive-text/view.js b/packages/e2e-tests/plugins/interactive-blocks/directive-text/view.js
new file mode 100644
index 00000000000000..49121213f2b04e
--- /dev/null
+++ b/packages/e2e-tests/plugins/interactive-blocks/directive-text/view.js
@@ -0,0 +1,17 @@
+( ( { wp } ) => {
+ const { store } = wp.interactivity;
+
+ store( {
+ state: {
+ text: 'Text 1',
+ },
+ actions: {
+ toggleStateText: ( { state } ) => {
+ state.text = state.text === 'Text 1' ? 'Text 2' : 'Text 1';
+ },
+ toggleContextText: ( { context } ) => {
+ context.text = context.text === 'Text 1' ? 'Text 2' : 'Text 1';
+ },
+ },
+ } );
+} )( window );
diff --git a/packages/e2e-tests/plugins/interactive-blocks/negation-operator/block.json b/packages/e2e-tests/plugins/interactive-blocks/negation-operator/block.json
new file mode 100644
index 00000000000000..68da53367ad63a
--- /dev/null
+++ b/packages/e2e-tests/plugins/interactive-blocks/negation-operator/block.json
@@ -0,0 +1,14 @@
+{
+ "apiVersion": 2,
+ "name": "test/negation-operator",
+ "title": "E2E Interactivity tests - negation operator",
+ "category": "text",
+ "icon": "heart",
+ "description": "",
+ "supports": {
+ "interactivity": true
+ },
+ "textdomain": "e2e-interactivity",
+ "viewScript": "negation-operator-view",
+ "render": "file:./render.php"
+}
diff --git a/packages/e2e-tests/plugins/interactive-blocks/negation-operator/render.php b/packages/e2e-tests/plugins/interactive-blocks/negation-operator/render.php
new file mode 100644
index 00000000000000..e087a5eceb8369
--- /dev/null
+++ b/packages/e2e-tests/plugins/interactive-blocks/negation-operator/render.php
@@ -0,0 +1,26 @@
+
+
+
+
+
+
+
+
diff --git a/packages/e2e-tests/plugins/interactive-blocks/negation-operator/view.js b/packages/e2e-tests/plugins/interactive-blocks/negation-operator/view.js
new file mode 100644
index 00000000000000..64a84269c356e3
--- /dev/null
+++ b/packages/e2e-tests/plugins/interactive-blocks/negation-operator/view.js
@@ -0,0 +1,22 @@
+( ( { wp } ) => {
+ /**
+ * WordPress dependencies
+ */
+ const { store } = wp.interactivity;
+
+ store( {
+ selectors: {
+ active: ( { state } ) => {
+ return state.active;
+ },
+ },
+ state: {
+ active: false,
+ },
+ actions: {
+ toggle: ( { state } ) => {
+ state.active = ! state.active;
+ },
+ },
+ } );
+} )( window );
diff --git a/packages/e2e-tests/plugins/interactive-blocks/store-tag/block.json b/packages/e2e-tests/plugins/interactive-blocks/store-tag/block.json
new file mode 100644
index 00000000000000..4611288de796c9
--- /dev/null
+++ b/packages/e2e-tests/plugins/interactive-blocks/store-tag/block.json
@@ -0,0 +1,14 @@
+{
+ "apiVersion": 2,
+ "name": "test/store-tag",
+ "title": "E2E Interactivity tests - store tag",
+ "category": "text",
+ "icon": "heart",
+ "description": "",
+ "supports": {
+ "interactivity": true
+ },
+ "textdomain": "e2e-interactivity",
+ "viewScript": "store-tag-view",
+ "render": "file:./render.php"
+}
diff --git a/packages/e2e-tests/plugins/interactive-blocks/store-tag/render.php b/packages/e2e-tests/plugins/interactive-blocks/store-tag/render.php
new file mode 100644
index 00000000000000..9bc8126720b9b9
--- /dev/null
+++ b/packages/e2e-tests/plugins/interactive-blocks/store-tag/render.php
@@ -0,0 +1,64 @@
+
+
+
+ Counter:
+
+
+ Double:
+
+
+
+ 0
+ clicks
+
+
+
+ $test_store_tag_json
+
+HTML;
+}
diff --git a/packages/e2e-tests/plugins/interactive-blocks/store-tag/view.js b/packages/e2e-tests/plugins/interactive-blocks/store-tag/view.js
new file mode 100644
index 00000000000000..140cab6463137f
--- /dev/null
+++ b/packages/e2e-tests/plugins/interactive-blocks/store-tag/view.js
@@ -0,0 +1,24 @@
+( ( { wp } ) => {
+ /**
+ * WordPress dependencies
+ */
+ const { store } = wp.interactivity;
+
+ store( {
+ state: {
+ counter: {
+ // `value` is defined in the server.
+ double: ( { state } ) => state.counter.value * 2,
+ clicks: 0,
+ },
+ },
+ actions: {
+ counter: {
+ increment: ( { state } ) => {
+ state.counter.value += 1;
+ state.counter.clicks += 1;
+ },
+ },
+ },
+ } );
+} )( window );
diff --git a/packages/e2e-tests/plugins/interactive-blocks/tovdom-islands/block.json b/packages/e2e-tests/plugins/interactive-blocks/tovdom-islands/block.json
new file mode 100644
index 00000000000000..fb852acefb9116
--- /dev/null
+++ b/packages/e2e-tests/plugins/interactive-blocks/tovdom-islands/block.json
@@ -0,0 +1,14 @@
+{
+ "apiVersion": 2,
+ "name": "test/tovdom-islands",
+ "title": "E2E Interactivity tests - tovdom islands",
+ "category": "text",
+ "icon": "heart",
+ "description": "",
+ "supports": {
+ "interactivity": true
+ },
+ "textdomain": "e2e-interactivity",
+ "viewScript": "tovdom-islands-view",
+ "render": "file:./render.php"
+}
diff --git a/packages/e2e-tests/plugins/interactive-blocks/tovdom-islands/render.php b/packages/e2e-tests/plugins/interactive-blocks/tovdom-islands/render.php
new file mode 100644
index 00000000000000..aa0f2e68d3b7cf
--- /dev/null
+++ b/packages/e2e-tests/plugins/interactive-blocks/tovdom-islands/render.php
@@ -0,0 +1,66 @@
+
+
+
+
+ This should be shown because it is inside an island.
+
+
+
+
+
+
+ This should not be shown because it is inside an island.
+
+
+
+
+
+
+
+
+ This should be shown because it is inside an inner
+ block of an isolated island.
+
+
+
+
+
+
+
+
+
+ This should not have two template wrappers because
+ that means we hydrated twice.
+
+
+
+
+
+
+
+
+
+
+ This should not be shown because even though it
+ is inside an inner block of an isolated island,
+ it's inside an new island.
+
+
+
+
+
+
diff --git a/packages/e2e-tests/plugins/interactive-blocks/tovdom-islands/view.js b/packages/e2e-tests/plugins/interactive-blocks/tovdom-islands/view.js
new file mode 100644
index 00000000000000..3ccb09203e6bb6
--- /dev/null
+++ b/packages/e2e-tests/plugins/interactive-blocks/tovdom-islands/view.js
@@ -0,0 +1,9 @@
+( ( { wp } ) => {
+ const { store } = wp.interactivity;
+
+ store( {
+ state: {
+ falseValue: false,
+ },
+ } );
+} )( window );
diff --git a/packages/e2e-tests/plugins/interactive-blocks/tovdom/block.json b/packages/e2e-tests/plugins/interactive-blocks/tovdom/block.json
new file mode 100644
index 00000000000000..b685919e164821
--- /dev/null
+++ b/packages/e2e-tests/plugins/interactive-blocks/tovdom/block.json
@@ -0,0 +1,14 @@
+{
+ "apiVersion": 2,
+ "name": "test/tovdom",
+ "title": "E2E Interactivity tests - tovdom",
+ "category": "text",
+ "icon": "heart",
+ "description": "",
+ "supports": {
+ "interactivity": true
+ },
+ "textdomain": "e2e-interactivity",
+ "viewScript": "tovdom-view",
+ "render": "file:./render.php"
+}
diff --git a/packages/e2e-tests/plugins/interactive-blocks/tovdom/cdata.js b/packages/e2e-tests/plugins/interactive-blocks/tovdom/cdata.js
new file mode 100644
index 00000000000000..506e899e42850c
--- /dev/null
+++ b/packages/e2e-tests/plugins/interactive-blocks/tovdom/cdata.js
@@ -0,0 +1,15 @@
+const cdata = `
+
+ `;
+
+const cdataElement = new DOMParser()
+ .parseFromString( cdata, 'text/xml' )
+ .querySelector( 'div' );
+document
+ .getElementById( 'replace-with-cdata' )
+ .replaceWith( cdataElement );
diff --git a/packages/e2e-tests/plugins/interactive-blocks/tovdom/processing-instructions.js b/packages/e2e-tests/plugins/interactive-blocks/tovdom/processing-instructions.js
new file mode 100644
index 00000000000000..b65095ef6cde4f
--- /dev/null
+++ b/packages/e2e-tests/plugins/interactive-blocks/tovdom/processing-instructions.js
@@ -0,0 +1,16 @@
+const processingInstructions = `
+
+
+
+ Processing instructions inner node
+
+
+
+ `;
+
+const processingInstructionsElement = new DOMParser()
+ .parseFromString( processingInstructions, 'text/xml' )
+ .querySelector( 'div' );
+document
+ .getElementById( 'replace-with-processing-instructions' )
+ .replaceWith( processingInstructionsElement );
diff --git a/packages/e2e-tests/plugins/interactive-blocks/tovdom/render.php b/packages/e2e-tests/plugins/interactive-blocks/tovdom/render.php
new file mode 100644
index 00000000000000..952a4f6c0a455d
--- /dev/null
+++ b/packages/e2e-tests/plugins/interactive-blocks/tovdom/render.php
@@ -0,0 +1,33 @@
+
+
+
+
+
+
+ Comments inner node
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/packages/e2e-tests/plugins/interactive-blocks/tovdom/view.js b/packages/e2e-tests/plugins/interactive-blocks/tovdom/view.js
new file mode 100644
index 00000000000000..734ccbd801bb1e
--- /dev/null
+++ b/packages/e2e-tests/plugins/interactive-blocks/tovdom/view.js
@@ -0,0 +1,5 @@
+( ( { wp } ) => {
+ const { store } = wp.interactivity;
+
+ store( {} );
+} )( window );
diff --git a/packages/interactivity/src/directives.js b/packages/interactivity/src/directives.js
index 5ecdcf6c4d6b93..ca87f7655dba68 100644
--- a/packages/interactivity/src/directives.js
+++ b/packages/interactivity/src/directives.js
@@ -175,21 +175,23 @@ export default () => {
}
);
- // data-wp-text
+ // data-wp-show
directive(
- 'text',
+ 'show',
( {
directives: {
- text: { default: text },
+ show: { default: show },
},
element,
evaluate,
context,
} ) => {
const contextValue = useContext( context );
- element.props.children = evaluate( text, {
- context: contextValue,
- } );
+
+ if ( ! evaluate( show, { context: contextValue } ) )
+ element.props.children = (
+ { element.props.children }
+ );
}
);
@@ -212,4 +214,22 @@ export default () => {
);
}
);
+
+ // data-wp-text
+ directive(
+ 'text',
+ ( {
+ directives: {
+ text: { default: text },
+ },
+ element,
+ evaluate,
+ context,
+ } ) => {
+ const contextValue = useContext( context );
+ element.props.children = evaluate( text, {
+ context: contextValue,
+ } );
+ }
+ );
};
diff --git a/packages/interactivity/src/index.js b/packages/interactivity/src/index.js
index 71b6a2b790704d..9d1b6b695b66b7 100644
--- a/packages/interactivity/src/index.js
+++ b/packages/interactivity/src/index.js
@@ -4,6 +4,10 @@
import registerDirectives from './directives';
import { init } from './hydration';
export { store } from './store';
+export { directive } from './hooks';
+export { h as createElement } from 'preact';
+export { useEffect, useContext, useMemo } from 'preact/hooks';
+export { deepSignal } from 'deepsignal';
/**
* Initialize the Interactivity API.
diff --git a/test/e2e/specs/interactivity/directive-bind.spec.ts b/test/e2e/specs/interactivity/directive-bind.spec.ts
new file mode 100644
index 00000000000000..67ee1232a6798e
--- /dev/null
+++ b/test/e2e/specs/interactivity/directive-bind.spec.ts
@@ -0,0 +1,96 @@
+/**
+ * Internal dependencies
+ */
+import { test, expect } from './fixtures';
+
+test.describe( 'data-wp-bind', () => {
+ test.beforeAll( async ( { interactivityUtils: utils } ) => {
+ await utils.activatePlugins();
+ await utils.addPostWithBlock( 'test/directive-bind' );
+ } );
+
+ test.beforeEach( async ( { interactivityUtils: utils, page } ) => {
+ await page.goto( utils.getLink( 'test/directive-bind' ) );
+ } );
+
+ test.afterAll( async ( { interactivityUtils: utils } ) => {
+ await utils.deactivatePlugins();
+ await utils.deleteAllPosts();
+ } );
+
+ test( 'add missing href at hydration', async ( { page } ) => {
+ const el = page.getByTestId( 'add missing href at hydration' );
+ await expect( el ).toHaveAttribute( 'href', '/some-url' );
+ } );
+
+ test( 'change href at hydration', async ( { page } ) => {
+ const el = page.getByTestId( 'change href at hydration' );
+ await expect( el ).toHaveAttribute( 'href', '/some-url' );
+ } );
+
+ test( 'update missing href at hydration', async ( { page } ) => {
+ const el = page.getByTestId( 'add missing href at hydration' );
+ await expect( el ).toHaveAttribute( 'href', '/some-url' );
+ await page.getByTestId( 'toggle' ).click();
+ await expect( el ).toHaveAttribute( 'href', '/some-other-url' );
+ } );
+
+ test( 'add missing checked at hydration', async ( { page } ) => {
+ const el = page.getByTestId( 'add missing checked at hydration' );
+ await expect( el ).toHaveAttribute( 'checked', '' );
+ } );
+
+ test( 'remove existing checked at hydration', async ( { page } ) => {
+ const el = page.getByTestId( 'remove existing checked at hydration' );
+ await expect( el ).not.toHaveAttribute( 'checked', '' );
+ } );
+
+ test( 'update existing checked', async ( { page } ) => {
+ const el = page.getByTestId( 'add missing checked at hydration' );
+ const el2 = page.getByTestId( 'remove existing checked at hydration' );
+ let checked = await el.evaluate(
+ ( element: HTMLInputElement ) => element.checked
+ );
+ let checked2 = await el2.evaluate(
+ ( element: HTMLInputElement ) => element.checked
+ );
+ expect( checked ).toBe( true );
+ expect( checked2 ).toBe( false );
+ await page.getByTestId( 'toggle' ).click();
+ checked = await el.evaluate(
+ ( element: HTMLInputElement ) => element.checked
+ );
+ checked2 = await el2.evaluate(
+ ( element: HTMLInputElement ) => element.checked
+ );
+ expect( checked ).toBe( false );
+ expect( checked2 ).toBe( true );
+ } );
+
+ test( 'nested binds', async ( { page } ) => {
+ const el = page.getByTestId( 'nested binds - 1' );
+ await expect( el ).toHaveAttribute( 'href', '/some-url' );
+ const el2 = page.getByTestId( 'nested binds - 2' );
+ await expect( el2 ).toHaveAttribute( 'width', '1' );
+ await page.getByTestId( 'toggle' ).click();
+ await expect( el ).toHaveAttribute( 'href', '/some-other-url' );
+ await expect( el2 ).toHaveAttribute( 'width', '2' );
+ } );
+
+ test( 'check enumerated attributes with true/false values', async ( {
+ page,
+ } ) => {
+ const el = page.getByTestId(
+ 'check enumerated attributes with true/false exist and have a string value'
+ );
+ await expect( el ).toHaveAttribute( 'hidden', '' );
+ await expect( el ).toHaveAttribute( 'aria-hidden', 'true' );
+ await expect( el ).toHaveAttribute( 'aria-expanded', 'false' );
+ await expect( el ).toHaveAttribute( 'data-some-value', 'false' );
+ await page.getByTestId( 'toggle' ).click();
+ await expect( el ).not.toHaveAttribute( 'hidden', '' );
+ await expect( el ).toHaveAttribute( 'aria-hidden', 'false' );
+ await expect( el ).toHaveAttribute( 'aria-expanded', 'true' );
+ await expect( el ).toHaveAttribute( 'data-some-value', 'true' );
+ } );
+} );
diff --git a/test/e2e/specs/interactivity/directive-effect.spec.ts b/test/e2e/specs/interactivity/directive-effect.spec.ts
new file mode 100644
index 00000000000000..2ec52444cfd9b8
--- /dev/null
+++ b/test/e2e/specs/interactivity/directive-effect.spec.ts
@@ -0,0 +1,39 @@
+/**
+ * Internal dependencies
+ */
+import { test, expect } from './fixtures';
+
+test.describe( 'data-wp-effect', () => {
+ test.beforeAll( async ( { interactivityUtils: utils } ) => {
+ await utils.activatePlugins();
+ await utils.addPostWithBlock( 'test/directive-effect' );
+ } );
+
+ test.beforeEach( async ( { interactivityUtils: utils, page } ) => {
+ await page.goto( utils.getLink( 'test/directive-effect' ) );
+ } );
+
+ test.afterAll( async ( { interactivityUtils: utils } ) => {
+ await utils.deactivatePlugins();
+ await utils.deleteAllPosts();
+ } );
+
+ 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/test/e2e/specs/interactivity/directive-priorities.spec.ts b/test/e2e/specs/interactivity/directive-priorities.spec.ts
new file mode 100644
index 00000000000000..1734d6f5aecc36
--- /dev/null
+++ b/test/e2e/specs/interactivity/directive-priorities.spec.ts
@@ -0,0 +1,84 @@
+/**
+ * Internal dependencies
+ */
+import { test, expect } from './fixtures';
+
+test.describe( 'Directives (w/ priority)', () => {
+ test.beforeAll( async ( { interactivityUtils: utils } ) => {
+ await utils.activatePlugins();
+ await utils.addPostWithBlock( 'test/directive-priorities' );
+ } );
+
+ test.beforeEach( async ( { interactivityUtils: utils, page } ) => {
+ await page.goto( utils.getLink( 'test/directive-priorities' ) );
+ } );
+
+ test.afterAll( async ( { interactivityUtils: utils } ) => {
+ await utils.deactivatePlugins();
+ await utils.deleteAllPosts();
+ } );
+
+ test( 'should run in priority order', async ( { page } ) => {
+ const executionOrder = page.getByTestId( 'execution order' );
+ await expect( executionOrder ).toHaveText(
+ 'context, attribute, text, children'
+ );
+ } );
+
+ test( 'should wrap those with less priority', async ( { page } ) => {
+ // Check that attribute value is correctly received from Provider.
+ const element = page.getByTestId( 'test directives' );
+ await expect( element ).toHaveAttribute(
+ 'data-attribute',
+ 'from context'
+ );
+
+ // Check that text value is correctly received from Provider, and text
+ // wrapped with an element with `data-testid=text`.
+ const text = element.getByTestId( 'text' );
+ await expect( text ).toHaveText( 'from context' );
+ } );
+
+ test( 'should propagate element modifications top-down', async ( {
+ page,
+ } ) => {
+ const executionOrder = page.getByTestId( 'execution order' );
+ const element = page.getByTestId( 'test directives' );
+ const text = element.getByTestId( 'text' );
+
+ // Get buttons.
+ const updateAttribute = element.getByRole( 'button', {
+ name: 'Update attribute',
+ } );
+ const updateText = element.getByRole( 'button', {
+ name: 'Update text',
+ } );
+
+ // Modify `attribute` inside context. This triggers a re-render for the
+ // component that wraps the `attribute` directive, evaluating it again.
+ // Nested components are re-rendered as well, so their directives are
+ // also re-evaluated (note how `text` and `children` have run).
+ await updateAttribute.click();
+ await expect( element ).toHaveAttribute( 'data-attribute', 'updated' );
+ await expect( executionOrder ).toHaveText(
+ [
+ 'context, attribute, text, children',
+ 'attribute, text, children',
+ ].join( ', ' )
+ );
+
+ // Modify `text` inside context. This triggers a re-render of the
+ // component that wraps the `text` directive. In this case, only
+ // `children` run as well, right after `text`.
+ await updateText.click();
+ await expect( element ).toHaveAttribute( 'data-attribute', 'updated' );
+ await expect( text ).toHaveText( 'updated' );
+ await expect( executionOrder ).toHaveText(
+ [
+ 'context, attribute, text, children',
+ 'attribute, text, children',
+ 'text, children',
+ ].join( ', ' )
+ );
+ } );
+} );
diff --git a/test/e2e/specs/interactivity/directives-class.spec.ts b/test/e2e/specs/interactivity/directives-class.spec.ts
new file mode 100644
index 00000000000000..c0ec77e14d0567
--- /dev/null
+++ b/test/e2e/specs/interactivity/directives-class.spec.ts
@@ -0,0 +1,101 @@
+/**
+ * Internal dependencies
+ */
+import { test, expect } from './fixtures';
+
+test.describe( 'data-wp-class', () => {
+ test.beforeAll( async ( { interactivityUtils: utils } ) => {
+ await utils.activatePlugins();
+ await utils.addPostWithBlock( 'test/directive-class' );
+ } );
+
+ test.beforeEach( async ( { interactivityUtils: utils, page } ) => {
+ await page.goto( utils.getLink( 'test/directive-class' ) );
+ } );
+
+ test.afterAll( async ( { interactivityUtils: utils } ) => {
+ await utils.deactivatePlugins();
+ await utils.deleteAllPosts();
+ } );
+
+ test( 'remove class if callback returns falsy value', async ( {
+ page,
+ } ) => {
+ const el = page.getByTestId(
+ 'remove class if callback returns falsy value'
+ );
+ await expect( el ).toHaveClass( 'bar' );
+ await page.getByTestId( 'toggle falseValue' ).click();
+ await expect( el ).toHaveClass( 'foo bar' );
+ await page.getByTestId( 'toggle falseValue' ).click();
+ await expect( el ).toHaveClass( 'bar' );
+ } );
+
+ test( 'add class if callback returns truthy value', async ( { page } ) => {
+ const el = page.getByTestId(
+ 'add class if callback returns truthy value'
+ );
+ await expect( el ).toHaveClass( 'foo bar' );
+ await page.getByTestId( 'toggle trueValue' ).click();
+ await expect( el ).toHaveClass( 'foo' );
+ await page.getByTestId( 'toggle trueValue' ).click();
+ await expect( el ).toHaveClass( 'foo bar' );
+ } );
+
+ test( 'handles multiple classes and callbacks', async ( { page } ) => {
+ const el = page.getByTestId( 'handles multiple classes and callbacks' );
+ await expect( el ).toHaveClass( 'bar baz' );
+ await page.getByTestId( 'toggle trueValue' ).click();
+ await expect( el ).toHaveClass( '' );
+ await page.getByTestId( 'toggle trueValue' ).click();
+ await expect( el ).toHaveClass( 'bar baz' );
+ await page.getByTestId( 'toggle falseValue' ).click();
+ await expect( el ).toHaveClass( 'foo bar baz' );
+ await page.getByTestId( 'toggle trueValue' ).click();
+ await expect( el ).toHaveClass( 'foo' );
+ } );
+
+ test( 'handles class names that are contained inside other class names', async ( {
+ page,
+ } ) => {
+ const el = page.getByTestId(
+ 'handles class names that are contained inside other class names'
+ );
+ await expect( el ).toHaveClass( 'foo-bar' );
+ await page.getByTestId( 'toggle falseValue' ).click();
+ await expect( el ).toHaveClass( 'foo foo-bar' );
+ await page.getByTestId( 'toggle trueValue' ).click();
+ await expect( el ).toHaveClass( 'foo' );
+ } );
+
+ test( 'can toggle class in the middle', async ( { page } ) => {
+ const el = page.getByTestId( 'can toggle class in the middle' );
+ await expect( el ).toHaveClass( 'foo bar baz' );
+ await page.getByTestId( 'toggle trueValue' ).click();
+ await expect( el ).toHaveClass( 'foo baz' );
+ await page.getByTestId( 'toggle trueValue' ).click();
+ await expect( el ).toHaveClass( 'foo bar baz' );
+ } );
+
+ test( 'can toggle class when class attribute is missing', async ( {
+ page,
+ } ) => {
+ const el = page.getByTestId(
+ 'can toggle class when class attribute is missing'
+ );
+ await expect( el ).toHaveClass( '' );
+ await page.getByTestId( 'toggle falseValue' ).click();
+ await expect( el ).toHaveClass( 'foo' );
+ await page.getByTestId( 'toggle falseValue' ).click();
+ await expect( el ).toHaveClass( '' );
+ } );
+
+ test( 'can use context values', async ( { page } ) => {
+ const el = page.getByTestId( 'can use context values' );
+ await expect( el ).toHaveClass( '' );
+ await page.getByTestId( 'toggle context false value' ).click();
+ await expect( el ).toHaveClass( 'foo' );
+ await page.getByTestId( 'toggle context false value' ).click();
+ await expect( el ).toHaveClass( '' );
+ } );
+} );
diff --git a/test/e2e/specs/interactivity/directives-context.spec.ts b/test/e2e/specs/interactivity/directives-context.spec.ts
new file mode 100644
index 00000000000000..5c74e8054bf19d
--- /dev/null
+++ b/test/e2e/specs/interactivity/directives-context.spec.ts
@@ -0,0 +1,165 @@
+/**
+ * External dependencies
+ */
+import type { Locator } from '@playwright/test';
+/**
+ * Internal dependencies
+ */
+import { test, expect } from './fixtures';
+
+const parseContent = async ( loc: Locator ) =>
+ JSON.parse( ( await loc.textContent() ) || '' );
+
+test.describe( 'data-wp-context', () => {
+ test.beforeAll( async ( { interactivityUtils: utils } ) => {
+ await utils.activatePlugins();
+ await utils.addPostWithBlock( 'test/directive-context' );
+ } );
+
+ test.beforeEach( async ( { interactivityUtils: utils, page } ) => {
+ await page.goto( utils.getLink( 'test/directive-context' ) );
+ } );
+
+ test.afterAll( async ( { interactivityUtils: utils } ) => {
+ await utils.deactivatePlugins();
+ await utils.deleteAllPosts();
+ } );
+
+ test( 'is correctly initialized', async ( { page } ) => {
+ const parentContext = await parseContent(
+ page.getByTestId( 'parent context' )
+ );
+
+ expect( parentContext ).toMatchObject( {
+ prop1: 'parent',
+ prop2: 'parent',
+ obj: { prop4: 'parent', prop5: 'parent' },
+ array: [ 1, 2, 3 ],
+ } );
+ } );
+
+ test( 'is correctly extended', async ( { page } ) => {
+ const childContext = await parseContent(
+ page.getByTestId( 'child context' )
+ );
+
+ expect( childContext ).toMatchObject( {
+ prop1: 'parent',
+ prop2: 'child',
+ prop3: 'child',
+ obj: { prop4: 'parent', prop5: 'child', prop6: 'child' },
+ array: [ 4, 5, 6 ],
+ } );
+ } );
+
+ test( 'changes in inherited properties are reflected (child)', async ( {
+ page,
+ } ) => {
+ await page.getByTestId( 'child prop1' ).click();
+ await page.getByTestId( 'child obj.prop4' ).click();
+
+ const childContext = await parseContent(
+ page.getByTestId( 'child context' )
+ );
+
+ expect( childContext.prop1 ).toBe( 'modifiedFromChild' );
+ expect( childContext.obj.prop4 ).toBe( 'modifiedFromChild' );
+
+ const parentContext = await parseContent(
+ page.getByTestId( 'parent context' )
+ );
+
+ expect( parentContext.prop1 ).toBe( 'modifiedFromChild' );
+ expect( parentContext.obj.prop4 ).toBe( 'modifiedFromChild' );
+ } );
+
+ test( 'changes in inherited properties are reflected (parent)', async ( {
+ page,
+ } ) => {
+ await page.getByTestId( 'parent prop1' ).click();
+ await page.getByTestId( 'parent obj.prop4' ).click();
+
+ const childContext = await parseContent(
+ page.getByTestId( 'child context' )
+ );
+
+ expect( childContext.prop1 ).toBe( 'modifiedFromParent' );
+ expect( childContext.obj.prop4 ).toBe( 'modifiedFromParent' );
+
+ const parentContext = await parseContent(
+ page.getByTestId( 'parent context' )
+ );
+
+ expect( parentContext.prop1 ).toBe( 'modifiedFromParent' );
+ expect( parentContext.obj.prop4 ).toBe( 'modifiedFromParent' );
+ } );
+
+ test( 'changes in shadowed properties do not leak (child)', async ( {
+ page,
+ } ) => {
+ await page.getByTestId( 'child prop2' ).click();
+ await page.getByTestId( 'child obj.prop5' ).click();
+
+ const childContext = await parseContent(
+ page.getByTestId( 'child context' )
+ );
+
+ expect( childContext.prop2 ).toBe( 'modifiedFromChild' );
+ expect( childContext.obj.prop5 ).toBe( 'modifiedFromChild' );
+
+ const parentContext = await parseContent(
+ page.getByTestId( 'parent context' )
+ );
+
+ expect( parentContext.prop2 ).toBe( 'parent' );
+ expect( parentContext.obj.prop5 ).toBe( 'parent' );
+ } );
+
+ test( 'changes in shadowed properties do not leak (parent)', async ( {
+ page,
+ } ) => {
+ await page.getByTestId( 'parent prop2' ).click();
+ await page.getByTestId( 'parent obj.prop5' ).click();
+
+ const childContext = await parseContent(
+ page.getByTestId( 'child context' )
+ );
+
+ expect( childContext.prop2 ).toBe( 'child' );
+ expect( childContext.obj.prop5 ).toBe( 'child' );
+
+ const parentContext = await parseContent(
+ page.getByTestId( 'parent context' )
+ );
+
+ expect( parentContext.prop2 ).toBe( 'modifiedFromParent' );
+ expect( parentContext.obj.prop5 ).toBe( 'modifiedFromParent' );
+ } );
+
+ test( 'Array properties are shadowed', async ( { page } ) => {
+ const parentContext = await parseContent(
+ page.getByTestId( 'parent context' )
+ );
+
+ const childContext = await parseContent(
+ page.getByTestId( 'child context' )
+ );
+
+ expect( parentContext.array ).toMatchObject( [ 1, 2, 3 ] );
+ expect( childContext.array ).toMatchObject( [ 4, 5, 6 ] );
+ } );
+
+ test( 'can be accessed in other directives on the same element', async ( {
+ page,
+ } ) => {
+ const element = page.getByTestId( 'context & other directives' );
+ await expect( element ).toHaveText( 'Text 1' );
+ await expect( element ).toHaveAttribute( 'value', 'Text 1' );
+ await element.click();
+ await expect( element ).toHaveText( 'Text 2' );
+ await expect( element ).toHaveAttribute( 'value', 'Text 2' );
+ await element.click();
+ await expect( element ).toHaveText( 'Text 1' );
+ await expect( element ).toHaveAttribute( 'value', 'Text 1' );
+ } );
+} );
diff --git a/test/e2e/specs/interactivity/directives-show.spec.ts b/test/e2e/specs/interactivity/directives-show.spec.ts
new file mode 100644
index 00000000000000..7e25332ae143c5
--- /dev/null
+++ b/test/e2e/specs/interactivity/directives-show.spec.ts
@@ -0,0 +1,59 @@
+/**
+ * Internal dependencies
+ */
+import { test, expect } from './fixtures';
+
+test.describe( 'data-wp-show', () => {
+ test.beforeAll( async ( { interactivityUtils: utils } ) => {
+ await utils.activatePlugins();
+ await utils.addPostWithBlock( 'test/directive-show' );
+ } );
+ test.beforeEach( async ( { interactivityUtils: utils, page } ) => {
+ await page.goto( utils.getLink( 'test/directive-show' ) );
+ } );
+ test.afterAll( async ( { interactivityUtils: utils } ) => {
+ await utils.deactivatePlugins();
+ await utils.deleteAllPosts();
+ } );
+
+ test( 'show if callback returns truthy value', async ( { page } ) => {
+ const el = page.getByTestId( 'show if callback returns truthy value' );
+ await expect( el ).toBeVisible();
+ } );
+
+ test( 'do not show if callback returns falsy value', async ( { page } ) => {
+ const el = page.getByTestId(
+ 'do not show if callback returns false value'
+ );
+ await expect( el ).toBeHidden();
+ } );
+
+ test( 'hide when toggling truthy value to falsy', async ( { page } ) => {
+ const el = page.getByTestId( 'show if callback returns truthy value' );
+ await expect( el ).toBeVisible();
+ await page.getByTestId( 'toggle trueValue' ).click();
+ await expect( el ).toBeHidden();
+ await page.getByTestId( 'toggle trueValue' ).click();
+ await expect( el ).toBeVisible();
+ } );
+
+ test( 'show when toggling false value to truthy', async ( { page } ) => {
+ const el = page.getByTestId(
+ 'do not show if callback returns false value'
+ );
+ await expect( el ).toBeHidden();
+ await page.getByTestId( 'toggle falseValue' ).click();
+ await expect( el ).toBeVisible();
+ await page.getByTestId( 'toggle falseValue' ).click();
+ await expect( el ).toBeHidden();
+ } );
+
+ test( 'can use context values', async ( { page } ) => {
+ const el = page.getByTestId( 'can use context values' );
+ await expect( el ).toBeHidden();
+ await page.getByTestId( 'toggle context false value' ).click();
+ await expect( el ).toBeVisible();
+ await page.getByTestId( 'toggle context false value' ).click();
+ await expect( el ).toBeHidden();
+ } );
+} );
diff --git a/test/e2e/specs/interactivity/directives-text.spec.ts b/test/e2e/specs/interactivity/directives-text.spec.ts
new file mode 100644
index 00000000000000..8e83be26de15ca
--- /dev/null
+++ b/test/e2e/specs/interactivity/directives-text.spec.ts
@@ -0,0 +1,36 @@
+/**
+ * Internal dependencies
+ */
+import { test, expect } from './fixtures';
+
+test.describe( 'data-wp-text', () => {
+ test.beforeAll( async ( { interactivityUtils: utils } ) => {
+ await utils.activatePlugins();
+ await utils.addPostWithBlock( 'test/directive-text' );
+ } );
+ test.beforeEach( async ( { interactivityUtils: utils, page } ) => {
+ await page.goto( utils.getLink( 'test/directive-text' ) );
+ } );
+ test.afterAll( async ( { interactivityUtils: utils } ) => {
+ await utils.deactivatePlugins();
+ await utils.deleteAllPosts();
+ } );
+
+ test( 'show proper text reading from state', async ( { page } ) => {
+ const el = page.getByTestId( 'show state text' );
+ await expect( el ).toHaveText( 'Text 1' );
+ await page.getByTestId( 'toggle state text' ).click();
+ await expect( el ).toHaveText( 'Text 2' );
+ await page.getByTestId( 'toggle state text' ).click();
+ await expect( el ).toHaveText( 'Text 1' );
+ } );
+
+ test( 'show proper text reading from context', async ( { page } ) => {
+ const el = page.getByTestId( 'show context text' );
+ await expect( el ).toHaveText( 'Text 1' );
+ await page.getByTestId( 'toggle context text' ).click();
+ await expect( el ).toHaveText( 'Text 2' );
+ await page.getByTestId( 'toggle context text' ).click();
+ await expect( el ).toHaveText( 'Text 1' );
+ } );
+} );
diff --git a/test/e2e/specs/interactivity/fixtures/index.ts b/test/e2e/specs/interactivity/fixtures/index.ts
new file mode 100644
index 00000000000000..607221ffb1ec43
--- /dev/null
+++ b/test/e2e/specs/interactivity/fixtures/index.ts
@@ -0,0 +1,25 @@
+/**
+ * WordPress dependencies
+ */
+import { test as base } from '@wordpress/e2e-test-utils-playwright';
+export { expect } from '@wordpress/e2e-test-utils-playwright';
+
+/**
+ * Internal dependencies
+ */
+import InteractivityUtils from './interactivity-utils';
+
+type Fixtures = {
+ interactivityUtils: InteractivityUtils;
+};
+
+export const test = base.extend< Fixtures >( {
+ interactivityUtils: [
+ async ( { requestUtils }, use ) => {
+ await use( new InteractivityUtils( { requestUtils } ) );
+ },
+ // @ts-ignore: The required type is 'test', but can be 'worker' too. See
+ // https://playwright.dev/docs/test-fixtures#worker-scoped-fixtures
+ { scope: 'worker' },
+ ],
+} );
diff --git a/test/e2e/specs/interactivity/fixtures/interactivity-utils.ts b/test/e2e/specs/interactivity/fixtures/interactivity-utils.ts
new file mode 100644
index 00000000000000..9d83c93650d403
--- /dev/null
+++ b/test/e2e/specs/interactivity/fixtures/interactivity-utils.ts
@@ -0,0 +1,60 @@
+/**
+ * WordPress dependencies
+ */
+import type { RequestUtils } from '@wordpress/e2e-test-utils-playwright';
+
+export default class InteractivityUtils {
+ links: Map< string, string >;
+ requestUtils: RequestUtils;
+
+ constructor( { requestUtils }: { requestUtils: RequestUtils } ) {
+ this.links = new Map();
+ this.requestUtils = requestUtils;
+ }
+
+ getLink( blockName: string ) {
+ const link = this.links.get( blockName );
+ if ( ! link ) {
+ throw new Error(
+ `No link found for post with block '${ blockName }'`
+ );
+ }
+
+ // Add an extra param to disable directives SSR. This is required at
+ // this moment, as SSR for directives is not stabilized yet and we need
+ // to ensure hydration works, even when the SSR'ed HTML is not correct.
+ const url = new URL( link );
+ url.searchParams.append( 'disable_directives_ssr', 'true' );
+ return url.href;
+ }
+
+ async addPostWithBlock( blockName: string ) {
+ const payload = {
+ content: ``,
+ status: 'publish' as 'publish',
+ date_gmt: '2023-01-01T00:00:00',
+ };
+
+ const { link } = await this.requestUtils.createPost( payload );
+ this.links.set( blockName, link );
+ }
+
+ async deleteAllPosts() {
+ await this.requestUtils.deleteAllPosts();
+ this.links.clear();
+ }
+
+ async activatePlugins() {
+ await this.requestUtils.activateTheme( 'emptytheme' );
+ await this.requestUtils.activatePlugin(
+ 'gutenberg-test-interactive-blocks'
+ );
+ }
+
+ async deactivatePlugins() {
+ await this.requestUtils.activateTheme( 'twentytwentyone' );
+ await this.requestUtils.deactivatePlugin(
+ 'gutenberg-test-interactive-blocks'
+ );
+ }
+}
diff --git a/test/e2e/specs/interactivity/negation-operator.spec.ts b/test/e2e/specs/interactivity/negation-operator.spec.ts
new file mode 100644
index 00000000000000..da5670a0e57824
--- /dev/null
+++ b/test/e2e/specs/interactivity/negation-operator.spec.ts
@@ -0,0 +1,44 @@
+/**
+ * Internal dependencies
+ */
+import { test, expect } from './fixtures';
+
+test.describe( 'negation-operator', () => {
+ test.beforeAll( async ( { interactivityUtils: utils } ) => {
+ await utils.activatePlugins();
+ await utils.addPostWithBlock( 'test/negation-operator' );
+ } );
+
+ test.beforeEach( async ( { interactivityUtils: utils, page } ) => {
+ await page.goto( utils.getLink( 'test/negation-operator' ) );
+ } );
+
+ test.afterAll( async ( { interactivityUtils: utils } ) => {
+ await utils.deactivatePlugins();
+ await utils.deleteAllPosts();
+ } );
+
+ test( 'add hidden attribute when !state.active', async ( { page } ) => {
+ const el = page.getByTestId(
+ 'add hidden attribute if state is not active'
+ );
+
+ await expect( el ).toHaveAttribute( 'hidden', '' );
+ await page.getByTestId( 'toggle active value' ).click();
+ await expect( el ).not.toHaveAttribute( 'hidden', '' );
+ await page.getByTestId( 'toggle active value' ).click();
+ await expect( el ).toHaveAttribute( 'hidden', '' );
+ } );
+
+ test( 'add hidden attribute when !selectors.active', async ( { page } ) => {
+ const el = page.getByTestId(
+ 'add hidden attribute if selector is not active'
+ );
+
+ await expect( el ).toHaveAttribute( 'hidden', '' );
+ await page.getByTestId( 'toggle active value' ).click();
+ await expect( el ).not.toHaveAttribute( 'hidden', '' );
+ await page.getByTestId( 'toggle active value' ).click();
+ await expect( el ).toHaveAttribute( 'hidden', '' );
+ } );
+} );
diff --git a/test/e2e/specs/interactivity/store-tag.spec.ts b/test/e2e/specs/interactivity/store-tag.spec.ts
new file mode 100644
index 00000000000000..80f9acfad93358
--- /dev/null
+++ b/test/e2e/specs/interactivity/store-tag.spec.ts
@@ -0,0 +1,86 @@
+/**
+ * Internal dependencies
+ */
+import { test, expect } from './fixtures';
+
+test.describe( 'store tag', () => {
+ test.beforeAll( async ( { interactivityUtils: utils } ) => {
+ await utils.activatePlugins();
+ await utils.addPostWithBlock( 'test/store-tag {"condition":"ok"}' );
+ await utils.addPostWithBlock(
+ 'test/store-tag {"condition":"missing"}'
+ );
+ await utils.addPostWithBlock(
+ 'test/store-tag {"condition":"corrupted-json"}'
+ );
+ await utils.addPostWithBlock(
+ 'test/store-tag {"condition":"invalid-state"}'
+ );
+ } );
+
+ test.afterAll( async ( { interactivityUtils: utils } ) => {
+ await utils.deactivatePlugins();
+ await utils.deleteAllPosts();
+ } );
+
+ test( 'hydrates when it is well defined', async ( {
+ interactivityUtils: utils,
+ page,
+ } ) => {
+ const block = 'test/store-tag {"condition":"ok"}';
+ await page.goto( utils.getLink( block ) );
+
+ const value = page.getByTestId( 'counter value' );
+ const double = page.getByTestId( 'counter double' );
+ const clicks = page.getByTestId( 'counter clicks' );
+
+ await expect( value ).toHaveText( '3' );
+ await expect( double ).toHaveText( '6' );
+ await expect( clicks ).toHaveText( '0' );
+
+ await page.getByTestId( 'counter button' ).click();
+
+ await expect( value ).toHaveText( '4' );
+ await expect( double ).toHaveText( '8' );
+ await expect( clicks ).toHaveText( '1' );
+ } );
+
+ test( 'does not break the page when missing', async ( {
+ interactivityUtils: utils,
+ page,
+ } ) => {
+ const block = 'test/store-tag {"condition":"missing"}';
+ await page.goto( utils.getLink( block ) );
+
+ const clicks = page.getByTestId( 'counter clicks' );
+ await expect( clicks ).toHaveText( '0' );
+ await page.getByTestId( 'counter button' ).click();
+ await expect( clicks ).toHaveText( '1' );
+ } );
+
+ test( 'does not break the page when corrupted', async ( {
+ interactivityUtils: utils,
+ page,
+ } ) => {
+ const block = 'test/store-tag {"condition":"corrupted-json"}';
+ await page.goto( utils.getLink( block ) );
+
+ const clicks = page.getByTestId( 'counter clicks' );
+ await expect( clicks ).toHaveText( '0' );
+ await page.getByTestId( 'counter button' ).click();
+ await expect( clicks ).toHaveText( '1' );
+ } );
+
+ test( 'does not break the page when it contains an invalid state', async ( {
+ interactivityUtils: utils,
+ page,
+ } ) => {
+ const block = 'test/store-tag {"condition":"invalid-state"}';
+ await page.goto( utils.getLink( block ) );
+
+ const clicks = page.getByTestId( 'counter clicks' );
+ await expect( clicks ).toHaveText( '0' );
+ await page.getByTestId( 'counter button' ).click();
+ await expect( clicks ).toHaveText( '1' );
+ } );
+} );
diff --git a/test/e2e/specs/interactivity/tovdom-islands.spec.ts b/test/e2e/specs/interactivity/tovdom-islands.spec.ts
new file mode 100644
index 00000000000000..fcc7c6081077a6
--- /dev/null
+++ b/test/e2e/specs/interactivity/tovdom-islands.spec.ts
@@ -0,0 +1,58 @@
+/**
+ * Internal dependencies
+ */
+import { test, expect } from './fixtures';
+
+test.describe( 'toVdom - islands', () => {
+ test.beforeAll( async ( { interactivityUtils: utils } ) => {
+ await utils.activatePlugins();
+ await utils.addPostWithBlock( 'test/tovdom-islands' );
+ } );
+ test.beforeEach( async ( { interactivityUtils: utils, page } ) => {
+ await page.goto( utils.getLink( 'test/tovdom-islands' ) );
+ } );
+ test.afterAll( async ( { interactivityUtils: utils } ) => {
+ await utils.deactivatePlugins();
+ await utils.deleteAllPosts();
+ } );
+
+ test( 'directives that are not inside islands should not be hydrated', async ( {
+ page,
+ } ) => {
+ const el = page.getByTestId( 'not inside an island' );
+ await expect( el ).toBeVisible();
+ } );
+
+ test( 'directives that are inside islands should be hydrated', async ( {
+ page,
+ } ) => {
+ const el = page.getByTestId( 'inside an island' );
+ await expect( el ).toBeHidden();
+ } );
+
+ test( 'directives that are inside inner blocks of isolated islands should not be hydrated', async ( {
+ page,
+ } ) => {
+ const el = page.getByTestId(
+ 'inside an inner block of an isolated island'
+ );
+ await expect( el ).toBeVisible();
+ } );
+
+ test( 'directives inside islands should not be hydrated twice', async ( {
+ page,
+ } ) => {
+ const el = page.getByTestId( 'island inside another island' );
+ const templates = el.locator( 'template' );
+ expect( await templates.count() ).toEqual( 1 );
+ } );
+
+ test( 'islands inside inner blocks of isolated islands should be hydrated', async ( {
+ page,
+ } ) => {
+ const el = page.getByTestId(
+ 'island inside inner block of isolated island'
+ );
+ await expect( el ).toBeHidden();
+ } );
+} );
diff --git a/test/e2e/specs/interactivity/tovdom.spec.ts b/test/e2e/specs/interactivity/tovdom.spec.ts
new file mode 100644
index 00000000000000..cf08fb115db4fd
--- /dev/null
+++ b/test/e2e/specs/interactivity/tovdom.spec.ts
@@ -0,0 +1,55 @@
+/**
+ * Internal dependencies
+ */
+import { test, expect } from './fixtures';
+
+test.describe( 'toVdom', () => {
+ test.beforeAll( async ( { interactivityUtils: utils } ) => {
+ await utils.activatePlugins();
+ await utils.addPostWithBlock( 'test/tovdom' );
+ } );
+ test.beforeEach( async ( { interactivityUtils: utils, page } ) => {
+ await page.goto( utils.getLink( 'test/tovdom' ) );
+ } );
+ test.afterAll( async ( { interactivityUtils: utils } ) => {
+ await utils.deactivatePlugins();
+ await utils.deleteAllPosts();
+ } );
+
+ test( 'it should delete comments', async ( { page } ) => {
+ const el = page.getByTestId( 'it should delete comments' );
+ const c = await el.innerHTML();
+ expect( c ).not.toContain( '##1##' );
+ expect( c ).not.toContain( '##2##' );
+ const el2 = page.getByTestId(
+ 'it should keep this node between comments'
+ );
+ await expect( el2 ).toBeVisible();
+ } );
+
+ test( 'it should delete processing instructions', async ( { page } ) => {
+ const el = page.getByTestId(
+ 'it should delete processing instructions'
+ );
+ const c = await el.innerHTML();
+ expect( c ).not.toContain( '##1##' );
+ expect( c ).not.toContain( '##2##' );
+ const el2 = page.getByTestId(
+ 'it should keep this node between processing instructions'
+ );
+ await expect( el2 ).toBeVisible();
+ } );
+
+ test( 'it should replace CDATA with text nodes', async ( { page } ) => {
+ const el = page.getByTestId(
+ 'it should replace CDATA with text nodes'
+ );
+ const c = await el.innerHTML();
+ expect( c ).toContain( '##1##' );
+ expect( c ).toContain( '##2##' );
+ const el2 = page.getByTestId(
+ 'it should keep this node between CDATA'
+ );
+ await expect( el2 ).toBeVisible();
+ } );
+} );