From 7c1930621561f4c52e1298f4c55cfc8ddedb3acd Mon Sep 17 00:00:00 2001 From: David Date: Tue, 26 Aug 2025 13:20:20 +0200 Subject: [PATCH 01/12] Serialize accessed derived state props --- .../class-wp-interactivity-api.php | 40 ++++++++++++++++++- 1 file changed, 38 insertions(+), 2 deletions(-) diff --git a/src/wp-includes/interactivity-api/class-wp-interactivity-api.php b/src/wp-includes/interactivity-api/class-wp-interactivity-api.php index fdde5d429aa6b..630af8c9afc07 100644 --- a/src/wp-includes/interactivity-api/class-wp-interactivity-api.php +++ b/src/wp-includes/interactivity-api/class-wp-interactivity-api.php @@ -58,6 +58,18 @@ final class WP_Interactivity_API { */ private $config_data = array(); + /** + * Keeps track of all derived state props accessed during server-side rendering. + * + * This data is serialized and sent to the client as part of the interactivity + * data, and is handled later in the client to support derived state props that + * are lazily hydrated. + * + * @since 6.9.0 + * @var array + */ + private $derived_state_props_accessed = array(); + /** * Flag that indicates whether the `data-wp-router-region` directive has * been found in the HTML and processed. @@ -236,12 +248,17 @@ public function filter_script_module_interactivity_router_data( array $data ): a * interactivity stores and the configuration will be available using a `getConfig` utility. * * @since 6.7.0 + * @since 6.9.0 Serializes derived state props accessed during directive processing. * * @param array $data Data to filter. * @return array Data for the Interactivity API script module. */ public function filter_script_module_interactivity_data( array $data ): array { - if ( empty( $this->state_data ) && empty( $this->config_data ) ) { + if ( + empty( $this->state_data ) && + empty( $this->config_data ) && + empty( $this->derived_state_props_accessed ) + ) { return $data; } @@ -265,6 +282,16 @@ public function filter_script_module_interactivity_data( array $data ): array { $data['state'] = $state; } + $derived_props = array(); + foreach ( $this->derived_state_props_accessed as $key => $value ) { + if ( ! empty( $value ) ) { + $derived_props[ $key ] = $value; + } + } + if ( ! empty( $derived_props ) ) { + $data['derivedStatePropsAccessed'] = $derived_props; + } + return $data; } @@ -598,7 +625,7 @@ private function evaluate( $directive_value ) { // Extracts the value from the store using the reference path. $path_segments = explode( '.', $path ); $current = $store; - foreach ( $path_segments as $path_segment ) { + foreach ( $path_segments as $index => $path_segment ) { /* * Special case for numeric arrays and strings. Add length * property mimicking JavaScript behavior. @@ -647,6 +674,15 @@ private function evaluate( $directive_value ) { array_push( $this->namespace_stack, $ns ); try { $current = $current(); + + // Tracks derived state properties that are accessed during rendering. + $this->derived_state_props_accessed[ $ns ] = $this->derived_state_props_accessed[ $ns ] ?? array(); + + // Builds path for the current property and add it to tracking if not already present. + $current_path = implode( '.', array_slice( $path_segments, 0, $index + 1 ) ); + if ( ! in_array( $current_path, $this->derived_state_props_accessed[ $ns ], true ) ) { + $this->derived_state_props_accessed[ $ns ][] = $current_path; + } } catch ( Throwable $e ) { _doing_it_wrong( __METHOD__, From 5cbb32444312f1e8c56d38fd1a5428cf161ba62d Mon Sep 17 00:00:00 2001 From: David Date: Wed, 27 Aug 2025 12:27:49 +0200 Subject: [PATCH 02/12] Add tests for accessed derived state props serialization --- .../interactivity-api/wpInteractivityAPI.php | 93 +++++++++++++++++++ 1 file changed, 93 insertions(+) diff --git a/tests/phpunit/tests/interactivity-api/wpInteractivityAPI.php b/tests/phpunit/tests/interactivity-api/wpInteractivityAPI.php index 1aa9a7238e104..2754de55708d1 100644 --- a/tests/phpunit/tests/interactivity-api/wpInteractivityAPI.php +++ b/tests/phpunit/tests/interactivity-api/wpInteractivityAPI.php @@ -25,6 +25,7 @@ class Tests_Interactivity_API_WpInteractivityAPI extends WP_UnitTestCase { public function set_up() { parent::set_up(); $this->interactivity = new WP_Interactivity_API(); + wp_default_script_modules(); } public function charset_iso_8859_1() { @@ -311,6 +312,98 @@ function () { $this->assertSame( array( 'state' => array( 'myPlugin' => array( 'emptyArray' => array() ) ) ), $filter->get_args()[0][0] ); } + /** + * Tests that derived state props invoked during directive evaluation are + * serialized correctly. + * + * @ticket XXX + */ + public function test_invoked_derived_state_props_are_serialized() { + $returns_whatever = function () { + return 'whatever'; + }; + + $returns_array = function () { + return array( 'prop' => 'whatever' ); + }; + + $filter = $this->get_script_data_filter_result( + function () use ( $returns_whatever, $returns_array ) { + $this->interactivity->state( 'pluginWithInvokedDerivedState', array( + 'derivedProp' => $returns_whatever, + 'nested' => array ( + 'derivedProp' => $returns_whatever, + 'derivedPropReturnsArray' => $returns_array, + ), + ) ); + + $this->interactivity->state( 'pluginWithInvokedDerivedStateReturningArray', array( + 'derivedProp' => $returns_whatever, + 'nested' => array ( + 'derivedProp' => $returns_whatever, + 'derivedPropReturnsArray' => $returns_array, + ), + ) ); + + $this->interactivity->state( 'pluginWithoutInvokedDerivedState', array( + 'derivedProp' => $returns_whatever, + 'nested' => array ( + 'derivedProp' => $returns_whatever, + ), + ) ); + + $this->set_internal_context_stack( array() ); + + // Multiple evaluations should be serialized only once. + $this->set_internal_namespace_stack( 'pluginWithInvokedDerivedState' ); + $this->evaluate( 'state.derivedProp' ); + $this->evaluate( 'state.derivedProp' ); + $this->evaluate( 'state.nested.derivedProp' ); + $this->evaluate( 'state.nested.derivedProp' ); + + // Only the path part that points to a derived state prop should be serialized. + $this->set_internal_namespace_stack( 'pluginWithInvokedDerivedStateReturningArray' ); + $this->evaluate( 'state.nested.derivedProp.prop' ); + } + ); + + $this->assertSame( + array( + 'state' => array( + 'pluginWithInvokedDerivedState' => array( + 'derivedProp' => $returns_whatever, + 'nested' => array ( + 'derivedProp' => $returns_whatever, + 'derivedPropReturnsArray' => $returns_array, + ), + ), + 'pluginWithInvokedDerivedStateReturningArray' => array( + 'derivedProp' => $returns_whatever, + 'nested' => array ( + 'derivedProp' => $returns_whatever, + 'derivedPropReturnsArray' => $returns_array, + ), + ), + 'pluginWithoutInvokedDerivedState' => array( + 'derivedProp' => $returns_whatever, + 'nested' => array ( + 'derivedProp' => $returns_whatever, + ), + ), + ), + 'derivedStatePropsAccessed' => array( + 'pluginWithInvokedDerivedState' => array( + 'state.derivedProp', + 'state.nested.derivedProp', + ), + 'pluginWithInvokedDerivedStateReturningArray' => array( + 'state.nested.derivedProp', + ), + ) + ), + $filter->get_args()[0][0] + ); + } /** * Tests that empty config objects are pruned from printed data. From 063a1f5eec38476dce4641570066e07993a75234 Mon Sep 17 00:00:00 2001 From: David Date: Wed, 27 Aug 2025 12:32:28 +0200 Subject: [PATCH 03/12] Make `data-wp-each-child` to include namespace and path --- .../interactivity-api/class-wp-interactivity-api.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/wp-includes/interactivity-api/class-wp-interactivity-api.php b/src/wp-includes/interactivity-api/class-wp-interactivity-api.php index 630af8c9afc07..6d7e214eddcbc 100644 --- a/src/wp-includes/interactivity-api/class-wp-interactivity-api.php +++ b/src/wp-includes/interactivity-api/class-wp-interactivity-api.php @@ -1241,8 +1241,8 @@ private function data_wp_each_processor( WP_Interactivity_API_Directives_Process } // Extracts the namespace from the directive attribute value. - $namespace_value = end( $this->namespace_stack ); - list( $namespace_value ) = is_string( $attribute_value ) && ! empty( $attribute_value ) + $namespace_value = end( $this->namespace_stack ); + list( $namespace_value, $path ) = is_string( $attribute_value ) && ! empty( $attribute_value ) ? $this->extract_directive_value( $attribute_value, $namespace_value ) : array( $namespace_value, null ); @@ -1267,7 +1267,7 @@ private function data_wp_each_processor( WP_Interactivity_API_Directives_Process // Adds the `data-wp-each-child` to each top-level tag. $i = new WP_Interactivity_API_Directives_Processor( $processed_item ); while ( $i->next_tag() ) { - $i->set_attribute( 'data-wp-each-child', true ); + $i->set_attribute( 'data-wp-each-child', $namespace_value . '::' . $path ); $i->next_balanced_tag_closer_tag(); } $processed_content .= $i->get_updated_html(); From fff193c715cf7108592332e95497207e748834e2 Mon Sep 17 00:00:00 2001 From: David Date: Thu, 28 Aug 2025 11:45:20 +0200 Subject: [PATCH 04/12] Fix linting errors --- .../interactivity-api/wpInteractivityAPI.php | 63 +++++++++++-------- 1 file changed, 36 insertions(+), 27 deletions(-) diff --git a/tests/phpunit/tests/interactivity-api/wpInteractivityAPI.php b/tests/phpunit/tests/interactivity-api/wpInteractivityAPI.php index 2754de55708d1..b68d602610bac 100644 --- a/tests/phpunit/tests/interactivity-api/wpInteractivityAPI.php +++ b/tests/phpunit/tests/interactivity-api/wpInteractivityAPI.php @@ -319,7 +319,7 @@ function () { * @ticket XXX */ public function test_invoked_derived_state_props_are_serialized() { - $returns_whatever = function () { + $returns_whatever = function () { return 'whatever'; }; @@ -329,31 +329,40 @@ public function test_invoked_derived_state_props_are_serialized() { $filter = $this->get_script_data_filter_result( function () use ( $returns_whatever, $returns_array ) { - $this->interactivity->state( 'pluginWithInvokedDerivedState', array( - 'derivedProp' => $returns_whatever, - 'nested' => array ( - 'derivedProp' => $returns_whatever, - 'derivedPropReturnsArray' => $returns_array, - ), - ) ); + $this->interactivity->state( + 'pluginWithInvokedDerivedState', + array( + 'derivedProp' => $returns_whatever, + 'nested' => array( + 'derivedProp' => $returns_whatever, + 'derivedPropReturnsArray' => $returns_array, + ), + ) + ); - $this->interactivity->state( 'pluginWithInvokedDerivedStateReturningArray', array( - 'derivedProp' => $returns_whatever, - 'nested' => array ( - 'derivedProp' => $returns_whatever, - 'derivedPropReturnsArray' => $returns_array, - ), - ) ); + $this->interactivity->state( + 'pluginWithInvokedDerivedStateReturningArray', + array( + 'derivedProp' => $returns_whatever, + 'nested' => array( + 'derivedProp' => $returns_whatever, + 'derivedPropReturnsArray' => $returns_array, + ), + ) + ); - $this->interactivity->state( 'pluginWithoutInvokedDerivedState', array( - 'derivedProp' => $returns_whatever, - 'nested' => array ( + $this->interactivity->state( + 'pluginWithoutInvokedDerivedState', + array( 'derivedProp' => $returns_whatever, - ), - ) ); + 'nested' => array( + 'derivedProp' => $returns_whatever, + ), + ) + ); $this->set_internal_context_stack( array() ); - + // Multiple evaluations should be serialized only once. $this->set_internal_namespace_stack( 'pluginWithInvokedDerivedState' ); $this->evaluate( 'state.derivedProp' ); @@ -370,23 +379,23 @@ function () use ( $returns_whatever, $returns_array ) { $this->assertSame( array( 'state' => array( - 'pluginWithInvokedDerivedState' => array( + 'pluginWithInvokedDerivedState' => array( 'derivedProp' => $returns_whatever, - 'nested' => array ( + 'nested' => array( 'derivedProp' => $returns_whatever, 'derivedPropReturnsArray' => $returns_array, ), ), 'pluginWithInvokedDerivedStateReturningArray' => array( 'derivedProp' => $returns_whatever, - 'nested' => array ( + 'nested' => array( 'derivedProp' => $returns_whatever, 'derivedPropReturnsArray' => $returns_array, ), ), - 'pluginWithoutInvokedDerivedState' => array( + 'pluginWithoutInvokedDerivedState' => array( 'derivedProp' => $returns_whatever, - 'nested' => array ( + 'nested' => array( 'derivedProp' => $returns_whatever, ), ), @@ -399,7 +408,7 @@ function () use ( $returns_whatever, $returns_array ) { 'pluginWithInvokedDerivedStateReturningArray' => array( 'state.nested.derivedProp', ), - ) + ), ), $filter->get_args()[0][0] ); From 901a358d8913d9f37b5d61c7a88da2951a627ff8 Mon Sep 17 00:00:00 2001 From: David Date: Fri, 5 Sep 2025 18:39:54 +0200 Subject: [PATCH 05/12] Update wp-each tests --- .../wpInteractivityAPI-wp-each.php | 60 +++++++++---------- 1 file changed, 30 insertions(+), 30 deletions(-) diff --git a/tests/phpunit/tests/interactivity-api/wpInteractivityAPI-wp-each.php b/tests/phpunit/tests/interactivity-api/wpInteractivityAPI-wp-each.php index 0446fa461df14..42d05b18c9287 100644 --- a/tests/phpunit/tests/interactivity-api/wpInteractivityAPI-wp-each.php +++ b/tests/phpunit/tests/interactivity-api/wpInteractivityAPI-wp-each.php @@ -88,8 +88,8 @@ public function test_wp_each_simple_tags() { '' . - '1' . - '2' . + '1' . + '2' . '
Text
'; $new = $this->interactivity->process_directives( $original ); $this->assertSame( $expected, $new ); @@ -140,8 +140,8 @@ public function test_wp_each_merges_context_correctly() { '' . - '1' . - '2' . + '1' . + '2' . '
New text
' . ''; $new = $this->interactivity->process_directives( $original ); @@ -168,8 +168,8 @@ public function test_wp_each_gets_arrays_from_context() { '' . - '1' . - '2' . + '1' . + '2' . '
Text
' . ''; $new = $this->interactivity->process_directives( $original ); @@ -196,8 +196,8 @@ public function test_wp_each_default_namespace() { '' . - '1' . - '2' . + '1' . + '2' . '
Text
' . ''; $new = $this->interactivity->process_directives( $original ); @@ -223,10 +223,10 @@ public function test_wp_each_multiple_tags_per_item() { '' . '' . '' . - '1' . - '1' . - '2' . - '2' . + '1' . + '1' . + '2' . + '2' . '
Text
'; $new = $this->interactivity->process_directives( $original ); $this->assertSame( $expected, $new ); @@ -251,10 +251,10 @@ public function test_wp_each_void_tags() { '' . '' . '' . - '' . - '' . - '' . - '' . + '' . + '' . + '' . + '' . '
Text
'; $new = $this->interactivity->process_directives( $original ); $this->assertSame( $expected, $new ); @@ -280,10 +280,10 @@ public function test_wp_each_void_and_non_void_tags() { '' . '' . '' . - '' . - '1' . - '' . - '2' . + '' . + '1' . + '' . + '2' . '
Text
'; $new = $this->interactivity->process_directives( $original ); $this->assertSame( $expected, $new ); @@ -310,10 +310,10 @@ public function test_wp_each_nested_tags() { 'id: ' . '' . '' . - '
' . + '
' . 'id: 1' . '
' . - '
' . + '
' . 'id: 2' . '
' . '
Text
'; @@ -355,10 +355,10 @@ public function test_wp_each_nested_item_properties() { '' . '' . '' . - '1' . - 'one' . - '2' . - 'two' . + '1' . + 'one' . + '2' . + 'two' . '
Text
'; $new = $this->interactivity->process_directives( $original ); $this->assertSame( $expected, $new ); @@ -381,8 +381,8 @@ public function test_wp_each_different_item_names() { '' . - '1' . - '2' . + '1' . + '2' . '
Text
'; $new = $this->interactivity->process_directives( $original ); $this->assertSame( $expected, $new ); @@ -406,8 +406,8 @@ public function test_wp_each_different_item_names_transforms_camelcase() { '' . - '1' . - '2' . + '1' . + '2' . '
Text
'; $new = $this->interactivity->process_directives( $original ); $this->assertSame( $expected, $new ); From a04465145964745d370a0199ff9c1ab8e6b5c739 Mon Sep 17 00:00:00 2001 From: David Date: Fri, 5 Sep 2025 21:24:00 +0200 Subject: [PATCH 06/12] Fix data-wp-each-child value assignment --- .../class-wp-interactivity-api.php | 15 ++++- .../wpInteractivityAPI-wp-each.php | 56 +++++++++---------- 2 files changed, 41 insertions(+), 30 deletions(-) diff --git a/src/wp-includes/interactivity-api/class-wp-interactivity-api.php b/src/wp-includes/interactivity-api/class-wp-interactivity-api.php index 6d7e214eddcbc..b16147236ee20 100644 --- a/src/wp-includes/interactivity-api/class-wp-interactivity-api.php +++ b/src/wp-includes/interactivity-api/class-wp-interactivity-api.php @@ -1196,6 +1196,7 @@ private function data_wp_router_region_processor( WP_Interactivity_API_Directive * `template` tag. * * @since 6.5.0 + * @since 6.9.0 Include the list path in the rendered `data-wp-each-child` directives. * * @param WP_Interactivity_API_Directives_Processor $p The directives processor instance. * @param string $mode Whether the processing is entering or exiting the tag. @@ -1264,10 +1265,20 @@ private function data_wp_each_processor( WP_Interactivity_API_Directives_Process return; } - // Adds the `data-wp-each-child` to each top-level tag. + /* + * Adds the `data-wp-each-child` directive to each top-level tag + * rendered by this `data-wp-each` directive. The value is the + * `data-wp-each` directive's namespace and path. + * + * Nested `data-wp-each` directives could render + * `data-wp-each-child` elements at the top level as well, and + * they should be ignored. + */ $i = new WP_Interactivity_API_Directives_Processor( $processed_item ); while ( $i->next_tag() ) { - $i->set_attribute( 'data-wp-each-child', $namespace_value . '::' . $path ); + if ( ! $i->get_attribute( 'data-wp-each-child' ) ) { + $i->set_attribute( 'data-wp-each-child', $namespace_value . '::' . $path ); + } $i->next_balanced_tag_closer_tag(); } $processed_content .= $i->get_updated_html(); diff --git a/tests/phpunit/tests/interactivity-api/wpInteractivityAPI-wp-each.php b/tests/phpunit/tests/interactivity-api/wpInteractivityAPI-wp-each.php index 42d05b18c9287..d3de768e21cae 100644 --- a/tests/phpunit/tests/interactivity-api/wpInteractivityAPI-wp-each.php +++ b/tests/phpunit/tests/interactivity-api/wpInteractivityAPI-wp-each.php @@ -473,18 +473,18 @@ public function test_wp_each_nested_template_tags() { '' . '' . '' . - '1' . - '' . - '' . - '