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' . - '' . - '' . - '