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..2832efced6f61 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 closures 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_closures = 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_closures )
+ ) {
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_closures as $key => $value ) {
+ if ( ! empty( $value ) ) {
+ $derived_props[ $key ] = $value;
+ }
+ }
+ if ( ! empty( $derived_props ) ) {
+ $data['derivedStateClosures'] = $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,20 @@ private function evaluate( $directive_value ) {
array_push( $this->namespace_stack, $ns );
try {
$current = $current();
+
+ /*
+ * Tracks derived state properties that are accessed during
+ * rendering.
+ *
+ * @since 6.9.0
+ */
+ $this->derived_state_closures[ $ns ] = $this->derived_state_closures[ $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_closures[ $ns ], true ) ) {
+ $this->derived_state_closures[ $ns ][] = $current_path;
+ }
} catch ( Throwable $e ) {
_doing_it_wrong(
__METHOD__,
@@ -1160,6 +1201,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.
@@ -1205,8 +1247,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 );
@@ -1228,10 +1270,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 overwritten.
+ *
+ * @since 6.9.0
+ */
$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();
diff --git a/tests/phpunit/tests/interactivity-api/wpInteractivityAPI-wp-each.php b/tests/phpunit/tests/interactivity-api/wpInteractivityAPI-wp-each.php
index 0446fa461df14..f0952dfabea89 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 );
@@ -473,18 +473,18 @@ public function test_wp_each_nested_template_tags() {
'
' .
'' .
'' .
- '
1' .
- '
' .
+ '1' .
+ '' .
'' .
'' .
- '3' .
- '4' .
- '2' .
- '' .
+ '3' .
+ '4' .
+ '2' .
+ '' .
'' .
'' .
- '3' .
- '4' .
+ '3' .
+ '4' .
'Text
';
$new = $this->interactivity->process_directives( $original );
$this->assertSame( $expected, $new );
@@ -515,22 +515,22 @@ public function test_wp_each_directly_nested_template_tags() {
'' .
'' .
'' .
- '
' .
+ '' .
'' .
'' .
'' .
- '1' .
- '3' .
- '1' .
- '4' .
- '' .
+ '1' .
+ '3' .
+ '1' .
+ '4' .
+ '' .
'' .
'' .
'' .
- '2' .
- '3' .
- '2' .
- '4' .
+ '2' .
+ '3' .
+ '2' .
+ '4' .
'Text
';
$new = $this->interactivity->process_directives( $original );
$this->assertSame( $expected, $new );
@@ -559,16 +559,16 @@ public function test_wp_each_nested_template_tags_using_previous_item_as_list()
'' .
'' .
'' .
- '
' .
+ '' .
'' .
'' .
- '1' .
- '2' .
- '' .
+ '1' .
+ '2' .
+ '' .
'' .
'' .
- '3' .
- '4' .
+ '3' .
+ '4' .
'Text
';
$new = $this->interactivity->process_directives( $original );
$this->assertSame( $expected, $new );
@@ -671,15 +671,15 @@ public function test_wp_each_doesnt_process_with_manual_server_directive_process
'' .
'' .
'' .
- '1' .
- '2' .
+ '1' .
+ '2' .
'Text
';
$expected = '' .
'' .
'' .
'' .
- '1' .
- '2' .
+ '1' .
+ '2' .
'Text
';
$new = $this->interactivity->process_directives( $original );
$this->assertSame( $expected, $new );
diff --git a/tests/phpunit/tests/interactivity-api/wpInteractivityAPI.php b/tests/phpunit/tests/interactivity-api/wpInteractivityAPI.php
index 1aa9a7238e104..da305bfcc8779 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,107 @@ 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 63898
+ */
+ 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,
+ ),
+ ),
+ ),
+ 'derivedStateClosures' => 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.