From 90853455b768b829611a14bbd53e33d8a7b734b9 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Mon, 27 Jul 2026 16:27:58 -0700 Subject: [PATCH 01/33] Add non-empty-string as type for array key on WP_HTML_Tag_Processor::$attributes --- src/wp-includes/html-api/class-wp-html-tag-processor.php | 1 + 1 file changed, 1 insertion(+) diff --git a/src/wp-includes/html-api/class-wp-html-tag-processor.php b/src/wp-includes/html-api/class-wp-html-tag-processor.php index ace3e14bea565..43090eb6e15e2 100644 --- a/src/wp-includes/html-api/class-wp-html-tag-processor.php +++ b/src/wp-includes/html-api/class-wp-html-tag-processor.php @@ -718,6 +718,7 @@ class WP_HTML_Tag_Processor { * * @since 6.2.0 * @var WP_HTML_Attribute_Token[] + * @phpstan-var array */ private $attributes = array(); From 95b14c66c16db459fc881c5c73c61da776e2e3eb Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Mon, 27 Jul 2026 16:28:40 -0700 Subject: [PATCH 02/33] Document returns WP_HTML_Tag_Processor::get_attribute_names_with_prefix() returns list|null --- src/wp-includes/html-api/class-wp-html-tag-processor.php | 1 + 1 file changed, 1 insertion(+) diff --git a/src/wp-includes/html-api/class-wp-html-tag-processor.php b/src/wp-includes/html-api/class-wp-html-tag-processor.php index 43090eb6e15e2..48d2de84c86ba 100644 --- a/src/wp-includes/html-api/class-wp-html-tag-processor.php +++ b/src/wp-includes/html-api/class-wp-html-tag-processor.php @@ -2958,6 +2958,7 @@ private function get_decoded_attribute_value( WP_HTML_Attribute_Token $attribute * * @param string $prefix Prefix of requested attribute names. * @return array|null List of attribute names, or `null` when no tag opener is matched. + * @phpstan-return list|null */ public function get_attribute_names_with_prefix( $prefix ): ?array { if ( From 1638ca3c44b7a02b7a1cf3a12233e088b1b1dcbd Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Mon, 27 Jul 2026 16:29:03 -0700 Subject: [PATCH 03/33] Address static analysis about methods detected as being unused --- .../class-wp-interactivity-api.php | 14 ++++++++++++-- 1 file changed, 12 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 a04d62e54924c..f79fa0883f6bb 100644 --- a/src/wp-includes/interactivity-api/class-wp-interactivity-api.php +++ b/src/wp-includes/interactivity-api/class-wp-interactivity-api.php @@ -17,9 +17,19 @@ final class WP_Interactivity_API { * Holds the mapping of directive attribute names to their processor methods. * * @since 6.5.0 - * @var array + * @var array + * @phpstan-var array{ + * 'data-wp-interactive': 'data_wp_interactive_processor', + * 'data-wp-router-region': 'data_wp_router_region_processor', + * 'data-wp-context': 'data_wp_context_processor', + * 'data-wp-bind': 'data_wp_bind_processor', + * 'data-wp-class': 'data_wp_class_processor', + * 'data-wp-style': 'data_wp_style_processor', + * 'data-wp-text': 'data_wp_text_processor', + * 'data-wp-each': 'data_wp_each_processor', + * } */ - private static $directive_processors = array( + private static array $directive_processors = array( 'data-wp-interactive' => 'data_wp_interactive_processor', 'data-wp-router-region' => 'data_wp_router_region_processor', 'data-wp-context' => 'data_wp_context_processor', From 39cb3d10bea88896af09f4c6abf46c56b462392a Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Mon, 27 Jul 2026 16:29:55 -0700 Subject: [PATCH 04/33] Harden return value type for WP_Interactivity_API::parse_directive_name() --- .../class-wp-interactivity-api.php | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 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 f79fa0883f6bb..6f2bf23b49911 100644 --- a/src/wp-includes/interactivity-api/class-wp-interactivity-api.php +++ b/src/wp-includes/interactivity-api/class-wp-interactivity-api.php @@ -775,20 +775,25 @@ private function evaluate( $entry ) { * * @param string $directive_name The directive attribute name. * @return array An array containing the directive prefix, optional suffix, and optional unique ID. + * @phpstan-return array{ + * prefix: non-empty-string, + * suffix: string|null, + * unique_id: string|null, + * }|null */ private function parse_directive_name( string $directive_name ): ?array { // Remove the first 8 characters (assumes "data-wp-" prefix) - $name = substr( $directive_name, 8 ); + $name = (string) substr( $directive_name, 8 ); // Check for invalid characters (anything not a-z, 0-9, -, or _) - if ( preg_match( '/[^a-z0-9\-_]/i', $name ) ) { + if ( '' === $name || preg_match( '/[^a-z0-9\-_]/i', $name ) ) { return null; } - // Find the first occurrence of '--' to separate the prefix + // Find the first occurrence of '--' to separate the prefix, as long as it is not at the beginning of the string. $suffix_index = strpos( $name, '--' ); - if ( false === $suffix_index ) { + if ( false === $suffix_index || 0 === $suffix_index ) { return array( 'prefix' => $name, 'suffix' => null, @@ -804,7 +809,7 @@ private function parse_directive_name( string $directive_name ): ?array { return array( 'prefix' => $prefix, 'suffix' => null, - 'unique_id' => '---' !== $remaining ? substr( $remaining, 3 ) : null, + 'unique_id' => '---' !== $remaining ? (string) substr( $remaining, 3 ) : null, ); } From dc7cdaeccb1350fd98aa7aee8c329bbff69ccf7a Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Mon, 27 Jul 2026 16:30:56 -0700 Subject: [PATCH 05/33] Document array shape return for WP_Interactivity_API::extract_directive_value() --- src/wp-includes/interactivity-api/class-wp-interactivity-api.php | 1 + 1 file changed, 1 insertion(+) 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 6f2bf23b49911..d1eb6df9f47f3 100644 --- a/src/wp-includes/interactivity-api/class-wp-interactivity-api.php +++ b/src/wp-includes/interactivity-api/class-wp-interactivity-api.php @@ -861,6 +861,7 @@ private function parse_directive_name( string $directive_name ): ?array { * @param string|null $default_namespace Optional. The default namespace if none is explicitly defined. * @return array An array containing the namespace in the first item and the JSON, the reference path, or null on the * second item. + * @phpstan-return array{ 0: string|null, 1: mixed } */ private function extract_directive_value( $directive_value, $default_namespace = null ): array { if ( empty( $directive_value ) || is_bool( $directive_value ) ) { From 359638afd99a3f335d5c3b6d9e042f868f7f2b78 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Mon, 27 Jul 2026 16:31:36 -0700 Subject: [PATCH 06/33] Fix type issues with WP_Interactivity_API::get_directive_entries() --- .../class-wp-interactivity-api.php | 27 ++++++++++++++++--- 1 file changed, 23 insertions(+), 4 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 d1eb6df9f47f3..1c90990ff7cd4 100644 --- a/src/wp-includes/interactivity-api/class-wp-interactivity-api.php +++ b/src/wp-includes/interactivity-api/class-wp-interactivity-api.php @@ -894,17 +894,36 @@ private function extract_directive_value( $directive_value, $default_namespace = * @param WP_Interactivity_API_Directives_Processor $p The directives processor instance. * @param string $prefix The directive prefix to filter by. * @return array An array of entries containing the directive namespace, value, suffix, and unique ID. + * @phpstan-return list */ - private function get_directive_entries( WP_Interactivity_API_Directives_Processor $p, string $prefix ) { + private function get_directive_entries( WP_Interactivity_API_Directives_Processor $p, string $prefix ): array { $directive_attributes = $p->get_attribute_names_with_prefix( 'data-wp-' . $prefix ); - $entries = array(); + if ( null === $directive_attributes ) { + return array(); + } + + $entries = array(); foreach ( $directive_attributes as $attribute_name ) { - [ 'prefix' => $attr_prefix, 'suffix' => $suffix, 'unique_id' => $unique_id] = $this->parse_directive_name( $attribute_name ); + $parsed_directive = $this->parse_directive_name( $attribute_name ); + if ( null === $parsed_directive ) { + continue; + } + + [ 'prefix' => $attr_prefix, 'suffix' => $suffix, 'unique_id' => $unique_id] = $parsed_directive; // Ensure it is the desired directive. if ( $prefix !== $attr_prefix ) { continue; } - list( $namespace, $value ) = $this->extract_directive_value( $p->get_attribute( $attribute_name ), end( $this->namespace_stack ) ); + $attribute_value = $p->get_attribute( $attribute_name ); + if ( null === $attribute_value ) { + continue; + } + list( $namespace, $value ) = $this->extract_directive_value( $attribute_value, array_last( $this->namespace_stack ?? array() ) ); $entries[] = array( 'namespace' => $namespace, 'value' => $value, From 7caf72d9c46a5a4009041fda599b01657e9a27ee Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Mon, 27 Jul 2026 16:31:56 -0700 Subject: [PATCH 07/33] Add void return type to data_wp_bind_processor method --- .../interactivity-api/class-wp-interactivity-api.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 1c90990ff7cd4..9813cd54cf2a1 100644 --- a/src/wp-includes/interactivity-api/class-wp-interactivity-api.php +++ b/src/wp-includes/interactivity-api/class-wp-interactivity-api.php @@ -1056,7 +1056,7 @@ private function data_wp_context_processor( WP_Interactivity_API_Directives_Proc * @param WP_Interactivity_API_Directives_Processor $p The directives processor instance. * @param string $mode Whether the processing is entering or exiting the tag. */ - private function data_wp_bind_processor( WP_Interactivity_API_Directives_Processor $p, string $mode ) { + private function data_wp_bind_processor( WP_Interactivity_API_Directives_Processor $p, string $mode ): void { if ( 'enter' === $mode ) { $entries = $this->get_directive_entries( $p, 'bind' ); foreach ( $entries as $entry ) { From 2e90b5df86052e2a0dcc2927eb401992b9861c72 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Mon, 27 Jul 2026 16:32:37 -0700 Subject: [PATCH 08/33] Prevent attempting to store non-scalar value in HTML attribute --- .../class-wp-interactivity-api.php | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) 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 9813cd54cf2a1..28ebc920ca369 100644 --- a/src/wp-includes/interactivity-api/class-wp-interactivity-api.php +++ b/src/wp-includes/interactivity-api/class-wp-interactivity-api.php @@ -1080,6 +1080,27 @@ private function data_wp_bind_processor( WP_Interactivity_API_Directives_Process $result = $this->evaluate( $entry ); + /* + * Only scalar values can be stored in an attribute value. Strings and booleans are passed in as-is. + * Numbers are cast to strings. Everything else is rejected as a usage error. + */ + if ( null !== $result ) { + if ( ! is_scalar( $result ) ) { + _doing_it_wrong( + __METHOD__, + sprintf( + /* translators: %s: The attribute name. */ + __( 'Attempted to store non-scalar value the "%s" attribute.' ), + esc_html( $entry['suffix'] ) + ), + '7.1.0' + ); + $result = null; + } elseif ( is_int( $result ) || is_float( $result ) ) { + $result = (string) $result; + } + } + if ( null !== $result && ( From ba5e81e9c2177c3ea50d0380f20b74e94f661be4 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Mon, 27 Jul 2026 16:56:42 -0700 Subject: [PATCH 09/33] Add tests for non-scalar value rejection in data-wp-bind Covers the new guard in data_wp_bind_processor() which rejects non-scalar evaluation results instead of passing them along to set_attribute(), where they previously caused a TypeError. A data provider exercises lists, associative arrays, empty arrays, plain objects, and a stringable object, asserting for each that no attribute is set, that a pre-existing attribute is removed, and that _doing_it_wrong() is triggered. The stringable object is included deliberately to document that the check is is_scalar(), not "castable to string". Also adds coverage for casting a float value to a string, and types the return of the test's process_directives() helper so that the tag processor is no longer inferred as mixed throughout the file. Co-Authored-By: Claude Opus 5 (1M context) --- .../wpInteractivityAPI-wp-bind.php | 87 ++++++++++++++++++- 1 file changed, 85 insertions(+), 2 deletions(-) diff --git a/tests/phpunit/tests/interactivity-api/wpInteractivityAPI-wp-bind.php b/tests/phpunit/tests/interactivity-api/wpInteractivityAPI-wp-bind.php index 02fc0d09293f7..8fe1457aff4d2 100644 --- a/tests/phpunit/tests/interactivity-api/wpInteractivityAPI-wp-bind.php +++ b/tests/phpunit/tests/interactivity-api/wpInteractivityAPI-wp-bind.php @@ -45,9 +45,9 @@ public function set_up() { * Invokes the `process_directives` method of WP_Interactivity_API class. * * @param string $html The HTML that needs to be processed. - * @return array An array containing an instance of the WP_HTML_Tag_Processor and the processed HTML. + * @return array{ 0: WP_HTML_Tag_Processor, 1: string } An array containing an instance of the WP_HTML_Tag_Processor and the processed HTML. */ - private function process_directives( $html ) { + private function process_directives( string $html ) { $new_html = $this->interactivity->process_directives( $html ); $p = new WP_HTML_Tag_Processor( $new_html ); $p->next_tag(); @@ -93,6 +93,21 @@ public function test_wp_bind_sets_number_value() { $this->assertSame( '100', $p->get_attribute( 'width' ) ); } + /** + * Tests that a float value is cast to a string when set as an attribute via + * `data-wp-bind`. + * + * @covers ::process_directives + */ + public function test_wp_bind_sets_float_value() { + $this->interactivity->state( 'myPlugin', array( 'ratio' => 1.5 ) ); + + $html = '
Text
'; + list($p, $new_html) = $this->process_directives( $html ); + $this->assertSame( '1.5', $p->get_attribute( 'data-ratio' ) ); + $this->assertSame( '
Text
', $new_html ); + } + /** * Tests that true strings are set properly as attribute values. * @@ -444,4 +459,72 @@ public function test_wp_bind_ignores_unique_id_but_processes_valid_binds() { list($p) = $this->process_directives( $html ); $this->assertSame( 'some-id', $p->get_attribute( 'id' ) ); } + + /** + * Data provider for values which cannot be stored in an attribute value. + * + * @return array Data provider. + */ + public function data_non_scalar_values(): array { + return array( + 'list' => array( 'value' => array( 'a', 'b' ) ), + 'associative array' => array( 'value' => array( 'a' => 'b' ) ), + 'empty array' => array( 'value' => array() ), + 'object' => array( 'value' => new stdClass() ), + 'stringable object' => array( + 'value' => new class() { + /** + * Returns the string representation. + * + * @return string String representation. + */ + public function __toString() { + return 'stringified'; + } + }, + ), + ); + } + + /** + * Tests that `data-wp-bind` rejects non-scalar values instead of passing + * them along to WP_HTML_Tag_Processor::set_attribute(). + * + * @covers ::process_directives + * + * @dataProvider data_non_scalar_values + * + * @expectedIncorrectUsage WP_Interactivity_API::data_wp_bind_processor + * + * @param mixed $value Non-scalar value to bind. + */ + public function test_wp_bind_rejects_non_scalar_value( $value ) { + $this->interactivity->state( 'myPlugin', array( 'nonScalar' => $value ) ); + + $html = '
Text
'; + list($p, $new_html) = $this->process_directives( $html ); + $this->assertNull( $p->get_attribute( 'id' ), 'Expected no attribute to have been set for a non-scalar value.' ); + $this->assertSame( $html, $new_html, 'Expected the markup to be left unchanged.' ); + } + + /** + * Tests that `data-wp-bind` removes a pre-existing attribute when the + * evaluated value is non-scalar. + * + * @covers ::process_directives + * + * @dataProvider data_non_scalar_values + * + * @expectedIncorrectUsage WP_Interactivity_API::data_wp_bind_processor + * + * @param mixed $value Non-scalar value to bind. + */ + public function test_wp_bind_removes_existing_attribute_for_non_scalar_value( $value ) { + $this->interactivity->state( 'myPlugin', array( 'nonScalar' => $value ) ); + + $html = '
Text
'; + list($p, $new_html) = $this->process_directives( $html ); + $this->assertNull( $p->get_attribute( 'id' ), 'Expected the pre-existing attribute to have been removed.' ); + $this->assertEqualHTML( '
Text
', $new_html ); + } } From 44cfc282db214253ac7d9397ffed8a1d7cdadf55 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Mon, 27 Jul 2026 17:15:51 -0700 Subject: [PATCH 10/33] Cover both attribute escaping paths and assert the notice message The data provider for non-scalar values now yields the cross product of each value with a single bound attribute, so that the two escaping paths in WP_HTML_Tag_Processor::set_attribute() are exercised independently: ordinary attributes such as id go through strtr(), while URI attributes such as href go through esc_url(). Binding both attributes in the same markup meant the ordinary attribute failed first and masked the URI one. Each test also asserts the exact message recorded for _doing_it_wrong(), rather than only that it was triggered for the method. Co-Authored-By: Claude Opus 5 (1M context) --- .../wpInteractivityAPI-wp-bind.php | 101 +++++++++++++----- 1 file changed, 75 insertions(+), 26 deletions(-) diff --git a/tests/phpunit/tests/interactivity-api/wpInteractivityAPI-wp-bind.php b/tests/phpunit/tests/interactivity-api/wpInteractivityAPI-wp-bind.php index 8fe1457aff4d2..ec7989dd31055 100644 --- a/tests/phpunit/tests/interactivity-api/wpInteractivityAPI-wp-bind.php +++ b/tests/phpunit/tests/interactivity-api/wpInteractivityAPI-wp-bind.php @@ -463,27 +463,51 @@ public function test_wp_bind_ignores_unique_id_but_processes_valid_binds() { /** * Data provider for values which cannot be stored in an attribute value. * - * @return array Data provider. + * Each value is paired with a single attribute so that the two escaping + * paths in WP_HTML_Tag_Processor::set_attribute() are exercised + * independently: ordinary attributes are escaped with strtr() whereas the + * URI attributes listed by wp_kses_uri_attributes() go through esc_url(). + * + * @return array Data provider. */ public function data_non_scalar_values(): array { - return array( - 'list' => array( 'value' => array( 'a', 'b' ) ), - 'associative array' => array( 'value' => array( 'a' => 'b' ) ), - 'empty array' => array( 'value' => array() ), - 'object' => array( 'value' => new stdClass() ), - 'stringable object' => array( - 'value' => new class() { - /** - * Returns the string representation. - * - * @return string String representation. - */ - public function __toString() { - return 'stringified'; - } - }, + $values = array( + 'list' => array( 'a', 'b' ), + 'associative array' => array( 'a' => 'b' ), + 'empty array' => array(), + 'object' => new stdClass(), + 'stringable object' => new class() { + /** + * Returns the string representation. + * + * @return string String representation. + */ + public function __toString() { + return 'stringified'; + } + }, + ); + + $attributes = array( + 'ordinary attribute' => array( + 'tag_name' => 'div', + 'attribute' => 'id', + 'existing_value' => 'other-id', + ), + 'URI attribute' => array( + 'tag_name' => 'a', + 'attribute' => 'href', + 'existing_value' => 'https://example.com/', ), ); + + $data = array(); + foreach ( $values as $value_label => $value ) { + foreach ( $attributes as $attribute_label => $attribute ) { + $data[ "$value_label in $attribute_label" ] = array( 'value' => $value ) + $attribute; + } + } + return $data; } /** @@ -496,15 +520,27 @@ public function __toString() { * * @expectedIncorrectUsage WP_Interactivity_API::data_wp_bind_processor * - * @param mixed $value Non-scalar value to bind. + * @param mixed $value Non-scalar value to bind. + * @param string $tag_name Tag name to bind the value on. + * @param string $attribute Attribute name to bind the value to. */ - public function test_wp_bind_rejects_non_scalar_value( $value ) { + public function test_wp_bind_rejects_non_scalar_value( $value, string $tag_name, string $attribute ) { $this->interactivity->state( 'myPlugin', array( 'nonScalar' => $value ) ); - $html = '
Text
'; + $html = sprintf( '<%1$s data-wp-bind--%2$s="myPlugin::state.nonScalar">Text', $tag_name, $attribute ); list($p, $new_html) = $this->process_directives( $html ); - $this->assertNull( $p->get_attribute( 'id' ), 'Expected no attribute to have been set for a non-scalar value.' ); + $this->assertNull( $p->get_attribute( $attribute ), "Expected no $attribute attribute to have been set for a non-scalar value." ); $this->assertSame( $html, $new_html, 'Expected the markup to be left unchanged.' ); + $this->assertSame( + array( + 'WP_Interactivity_API::data_wp_bind_processor' => sprintf( + 'Attempted to store non-scalar value the "%s" attribute. (This message was added in version 7.1.0.)', + $attribute + ), + ), + $this->caught_doing_it_wrong, + 'Expected _doing_it_wrong() to have been called once with the non-scalar value message.' + ); } /** @@ -517,14 +553,27 @@ public function test_wp_bind_rejects_non_scalar_value( $value ) { * * @expectedIncorrectUsage WP_Interactivity_API::data_wp_bind_processor * - * @param mixed $value Non-scalar value to bind. + * @param mixed $value Non-scalar value to bind. + * @param string $tag_name Tag name to bind the value on. + * @param string $attribute Attribute name to bind the value to. + * @param string $existing_value Pre-existing value for the bound attribute. */ - public function test_wp_bind_removes_existing_attribute_for_non_scalar_value( $value ) { + public function test_wp_bind_removes_existing_attribute_for_non_scalar_value( $value, string $tag_name, string $attribute, string $existing_value ) { $this->interactivity->state( 'myPlugin', array( 'nonScalar' => $value ) ); - $html = '
Text
'; + $html = sprintf( '<%1$s %2$s="%3$s" data-wp-bind--%2$s="myPlugin::state.nonScalar">Text', $tag_name, $attribute, $existing_value ); list($p, $new_html) = $this->process_directives( $html ); - $this->assertNull( $p->get_attribute( 'id' ), 'Expected the pre-existing attribute to have been removed.' ); - $this->assertEqualHTML( '
Text
', $new_html ); + $this->assertNull( $p->get_attribute( $attribute ), "Expected the pre-existing $attribute attribute to have been removed." ); + $this->assertEqualHTML( sprintf( '<%1$s data-wp-bind--%2$s="myPlugin::state.nonScalar">Text', $tag_name, $attribute ), $new_html ); + $this->assertSame( + array( + 'WP_Interactivity_API::data_wp_bind_processor' => sprintf( + 'Attempted to store non-scalar value the "%s" attribute. (This message was added in version 7.1.0.)', + $attribute + ), + ), + $this->caught_doing_it_wrong, + 'Expected _doing_it_wrong() to have been called once with the non-scalar value message.' + ); } } From 56026f103717324cfbd0bcb88423e4b105613e85 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Mon, 27 Jul 2026 17:40:31 -0700 Subject: [PATCH 11/33] Explain how to resolve the non-scalar attribute value notice The message was missing a word and did not say what to do about it. It now names binding as the operation and points at the fix, which is to cast the value where it is stored rather than at the point of binding. Also documents why an object is rejected even when it defines __toString(): PHP would coerce it for the string parameters of the escaping functions, but the same reference is serialized as JSON for the client, where the string representation is unavailable, so the server and client would disagree once the directive is evaluated during hydration. Co-Authored-By: Claude Opus 5 (1M context) --- .../interactivity-api/class-wp-interactivity-api.php | 7 ++++++- .../tests/interactivity-api/wpInteractivityAPI-wp-bind.php | 4 ++-- 2 files changed, 8 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 28ebc920ca369..28869840e2acc 100644 --- a/src/wp-includes/interactivity-api/class-wp-interactivity-api.php +++ b/src/wp-includes/interactivity-api/class-wp-interactivity-api.php @@ -1083,6 +1083,11 @@ private function data_wp_bind_processor( WP_Interactivity_API_Directives_Process /* * Only scalar values can be stored in an attribute value. Strings and booleans are passed in as-is. * Numbers are cast to strings. Everything else is rejected as a usage error. + * + * Objects are rejected even when they define `__toString()`, which PHP would otherwise coerce for + * the string parameters of the escaping functions. The same reference is serialized as JSON for the + * client, where the string representation is not available, so the two would disagree once the + * directive is evaluated during hydration. */ if ( null !== $result ) { if ( ! is_scalar( $result ) ) { @@ -1090,7 +1095,7 @@ private function data_wp_bind_processor( WP_Interactivity_API_Directives_Process __METHOD__, sprintf( /* translators: %s: The attribute name. */ - __( 'Attempted to store non-scalar value the "%s" attribute.' ), + __( 'Attempted to bind a non-scalar value to the "%s" attribute. Cast the value to a string before storing it in state or context so that the server and client agree.' ), esc_html( $entry['suffix'] ) ), '7.1.0' diff --git a/tests/phpunit/tests/interactivity-api/wpInteractivityAPI-wp-bind.php b/tests/phpunit/tests/interactivity-api/wpInteractivityAPI-wp-bind.php index ec7989dd31055..ade4b46b447b8 100644 --- a/tests/phpunit/tests/interactivity-api/wpInteractivityAPI-wp-bind.php +++ b/tests/phpunit/tests/interactivity-api/wpInteractivityAPI-wp-bind.php @@ -534,7 +534,7 @@ public function test_wp_bind_rejects_non_scalar_value( $value, string $tag_name, $this->assertSame( array( 'WP_Interactivity_API::data_wp_bind_processor' => sprintf( - 'Attempted to store non-scalar value the "%s" attribute. (This message was added in version 7.1.0.)', + 'Attempted to bind a non-scalar value to the "%s" attribute. Cast the value to a string before storing it in state or context so that the server and client agree. (This message was added in version 7.1.0.)', $attribute ), ), @@ -568,7 +568,7 @@ public function test_wp_bind_removes_existing_attribute_for_non_scalar_value( $v $this->assertSame( array( 'WP_Interactivity_API::data_wp_bind_processor' => sprintf( - 'Attempted to store non-scalar value the "%s" attribute. (This message was added in version 7.1.0.)', + 'Attempted to bind a non-scalar value to the "%s" attribute. Cast the value to a string before storing it in state or context so that the server and client agree. (This message was added in version 7.1.0.)', $attribute ), ), From abe8baf03186c5e0392b5844d5eb103a28ef9440 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Mon, 27 Jul 2026 18:05:21 -0700 Subject: [PATCH 12/33] Add ticket annotations to the non-scalar attribute value tests Co-Authored-By: Claude Opus 5 (1M context) --- .../tests/interactivity-api/wpInteractivityAPI-wp-bind.php | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tests/phpunit/tests/interactivity-api/wpInteractivityAPI-wp-bind.php b/tests/phpunit/tests/interactivity-api/wpInteractivityAPI-wp-bind.php index ade4b46b447b8..ce92e94681228 100644 --- a/tests/phpunit/tests/interactivity-api/wpInteractivityAPI-wp-bind.php +++ b/tests/phpunit/tests/interactivity-api/wpInteractivityAPI-wp-bind.php @@ -97,6 +97,8 @@ public function test_wp_bind_sets_number_value() { * Tests that a float value is cast to a string when set as an attribute via * `data-wp-bind`. * + * @ticket 65740 + * * @covers ::process_directives */ public function test_wp_bind_sets_float_value() { @@ -514,6 +516,8 @@ public function __toString() { * Tests that `data-wp-bind` rejects non-scalar values instead of passing * them along to WP_HTML_Tag_Processor::set_attribute(). * + * @ticket 65740 + * * @covers ::process_directives * * @dataProvider data_non_scalar_values @@ -547,6 +551,8 @@ public function test_wp_bind_rejects_non_scalar_value( $value, string $tag_name, * Tests that `data-wp-bind` removes a pre-existing attribute when the * evaluated value is non-scalar. * + * @ticket 65740 + * * @covers ::process_directives * * @dataProvider data_non_scalar_values From e6d704c1a0fa5446038faf6be3009ee1a69ee78f Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Mon, 27 Jul 2026 18:35:45 -0700 Subject: [PATCH 13/33] Bind a JsonSerializable value as whatever it serializes to The client store is built by JSON-encoding the state, which calls jsonSerialize(). Resolving the bound value through the same method means the attribute rendered on the server and the value the client hydrates with cannot disagree, so such an object no longer needs rejecting. This composes with the handling already in place: a serialized number goes through the numeric cast, a boolean keeps the boolean attribute semantics, and an array or object still reports incorrect usage. The resolution happens before the null check so that serializing to null removes the attribute rather than reporting incorrect usage. Two cases are fixed. An object implementing only JsonSerializable, whose serialized form the client was already receiving correctly, previously raised a TypeError in the escaping functions because PHP cannot coerce it to string. An object implementing both JsonSerializable and __toString with matching forms had worked before the non-scalar check was added, and works again. An object defining only __toString() is still rejected, now for the right reason: __toString() is never consulted, so the value the client receives is what decides. Co-Authored-By: Claude Opus 5 (1M context) --- .../class-wp-interactivity-api.php | 14 +- .../wpInteractivityAPI-wp-bind.php | 138 +++++++++++++++++- 2 files changed, 144 insertions(+), 8 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 28869840e2acc..e3602ca7c9527 100644 --- a/src/wp-includes/interactivity-api/class-wp-interactivity-api.php +++ b/src/wp-includes/interactivity-api/class-wp-interactivity-api.php @@ -1080,13 +1080,21 @@ private function data_wp_bind_processor( WP_Interactivity_API_Directives_Process $result = $this->evaluate( $entry ); + /* + * A JsonSerializable object is resolved to whatever it serializes to, since that is the value the + * client receives for this same reference when the store is hydrated. + */ + if ( $result instanceof JsonSerializable ) { + $result = $result->jsonSerialize(); + } + /* * Only scalar values can be stored in an attribute value. Strings and booleans are passed in as-is. * Numbers are cast to strings. Everything else is rejected as a usage error. * - * Objects are rejected even when they define `__toString()`, which PHP would otherwise coerce for - * the string parameters of the escaping functions. The same reference is serialized as JSON for the - * client, where the string representation is not available, so the two would disagree once the + * An object which is not JsonSerializable is rejected even when it defines `__toString()`, which PHP + * would otherwise coerce for the string parameters of the escaping functions. Its string + * representation is not what gets serialized for the client, so the two would disagree once the * directive is evaluated during hydration. */ if ( null !== $result ) { diff --git a/tests/phpunit/tests/interactivity-api/wpInteractivityAPI-wp-bind.php b/tests/phpunit/tests/interactivity-api/wpInteractivityAPI-wp-bind.php index ce92e94681228..2671c41e2c230 100644 --- a/tests/phpunit/tests/interactivity-api/wpInteractivityAPI-wp-bind.php +++ b/tests/phpunit/tests/interactivity-api/wpInteractivityAPI-wp-bind.php @@ -462,6 +462,113 @@ public function test_wp_bind_ignores_unique_id_but_processes_valid_binds() { $this->assertSame( 'some-id', $p->get_attribute( 'id' ) ); } + /** + * Data provider for values a bound object may serialize to. + * + * @return array Data provider. + */ + public function data_json_serializable_values(): array { + return array( + 'string' => array( + 'value' => 'serialized-form', + 'expected' => 'serialized-form', + ), + 'integer' => array( + 'value' => 42, + 'expected' => '42', + ), + 'float' => array( + 'value' => 1.5, + 'expected' => '1.5', + ), + ); + } + + /** + * Tests that an object is bound as whatever it serializes to for the client. + * + * @ticket 65740 + * + * @covers ::process_directives + * + * @dataProvider data_json_serializable_values + * + * @param mixed $value Value the object serializes to. + * @param string $expected Expected attribute value. + */ + public function test_wp_bind_sets_json_serializable_value( $value, string $expected ) { + $this->interactivity->state( 'myPlugin', array( 'serializable' => $this->get_json_serializable( $value ) ) ); + + $html = '
Text
'; + list($p) = $this->process_directives( $html ); + $this->assertSame( $expected, $p->get_attribute( 'id' ) ); + } + + /** + * Tests that the bound attribute value matches what the client is sent. + * + * This is what makes serializable objects safe to bind: the value rendered + * into the attribute is the same one the client store is hydrated with, so + * evaluating the directive again in the browser is a no-op. + * + * @ticket 65740 + * + * @covers ::process_directives + */ + public function test_wp_bind_json_serializable_value_matches_the_client_store() { + $this->interactivity->state( 'myPlugin', array( 'serializable' => $this->get_json_serializable( 'serialized-form' ) ) ); + + $html = '
Text
'; + list($p) = $this->process_directives( $html ); + + $data = $this->interactivity->filter_script_module_interactivity_data( array() ); + $encoded = wp_json_encode( $data['state'] ); + $this->assertIsString( $encoded, 'Expected the client state to be encodable as JSON.' ); + + $this->assertSame( 'serialized-form', $p->get_attribute( 'id' ) ); + $this->assertStringContainsString( + '"serializable":"serialized-form"', + $encoded, + 'Expected the rendered attribute value to match the value sent to the client.' + ); + } + + /** + * Creates an object which serializes to the given value for the client. + * + * @param mixed $value Value the object serializes to. + * @return JsonSerializable Object serializing to `$value`. + */ + private function get_json_serializable( $value ): JsonSerializable { + return new class( $value ) implements JsonSerializable { + /** + * Value the object serializes to. + * + * @var mixed + */ + private $value; + + /** + * Constructor. + * + * @param mixed $value Value the object serializes to. + */ + public function __construct( $value ) { + $this->value = $value; + } + + /** + * Returns the value for JSON serialization. + * + * @return mixed Value the client receives. + */ + #[\ReturnTypeWillChange] + public function jsonSerialize() { + return $this->value; + } + }; + } + /** * Data provider for values which cannot be stored in an attribute value. * @@ -474,11 +581,11 @@ public function test_wp_bind_ignores_unique_id_but_processes_valid_binds() { */ public function data_non_scalar_values(): array { $values = array( - 'list' => array( 'a', 'b' ), - 'associative array' => array( 'a' => 'b' ), - 'empty array' => array(), - 'object' => new stdClass(), - 'stringable object' => new class() { + 'list' => array( 'a', 'b' ), + 'associative array' => array( 'a' => 'b' ), + 'empty array' => array(), + 'object' => new stdClass(), + 'stringable object' => new class() { /** * Returns the string representation. * @@ -488,6 +595,27 @@ public function __toString() { return 'stringified'; } }, + 'stringable object serializing to an array' => new class() implements JsonSerializable { + /** + * Returns the string representation. + * + * @return string String representation. + */ + public function __toString() { + return 'stringified'; + } + + /** + * Returns the value for JSON serialization. + * + * @return array Value the client receives. + */ + #[\ReturnTypeWillChange] + public function jsonSerialize() { + return array( 'not' => 'the string representation' ); + } + }, + 'object serializing to an array' => $this->get_json_serializable( array( 'a', 'b' ) ), ); $attributes = array( From 1cc42757bf431c97695c060a01afa572f83a4120 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Mon, 27 Jul 2026 19:03:29 -0700 Subject: [PATCH 14/33] Clarify the non-scalar value data provider and its consumers The provider docblock claimed the two escaping paths in set_attribute() were exercised, when the point of these tests is that neither is reached. It now says that, and why each value is paired with one attribute at a time: so that a regression in one path cannot be masked by the other failing first. The provider also supplies a pre-existing attribute value which only one of its two consumers needs. Declare it in both signatures rather than relying on PHPUnit dropping the extra argument, which is an implementation detail rather than a guarantee, and unset it where it does not apply. Co-Authored-By: Copilot <175728472+Copilot@users.noreply.github.com> Co-Authored-By: Claude Opus 5 (1M context) --- .../wpInteractivityAPI-wp-bind.php | 20 +++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/tests/phpunit/tests/interactivity-api/wpInteractivityAPI-wp-bind.php b/tests/phpunit/tests/interactivity-api/wpInteractivityAPI-wp-bind.php index 2671c41e2c230..c19ccd9254e08 100644 --- a/tests/phpunit/tests/interactivity-api/wpInteractivityAPI-wp-bind.php +++ b/tests/phpunit/tests/interactivity-api/wpInteractivityAPI-wp-bind.php @@ -572,10 +572,11 @@ public function jsonSerialize() { /** * Data provider for values which cannot be stored in an attribute value. * - * Each value is paired with a single attribute so that the two escaping - * paths in WP_HTML_Tag_Processor::set_attribute() are exercised - * independently: ordinary attributes are escaped with strtr() whereas the - * URI attributes listed by wp_kses_uri_attributes() go through esc_url(). + * WP_HTML_Tag_Processor::set_attribute() escapes an ordinary attribute with + * strtr() and one of the URI attributes listed by wp_kses_uri_attributes() + * with esc_url(). Neither should be reached with a non-scalar value, so each + * value is paired with one attribute at a time: a regression in one of those + * paths then cannot be masked by the other failing first. * * @return array Data provider. */ @@ -652,11 +653,14 @@ public function jsonSerialize() { * * @expectedIncorrectUsage WP_Interactivity_API::data_wp_bind_processor * - * @param mixed $value Non-scalar value to bind. - * @param string $tag_name Tag name to bind the value on. - * @param string $attribute Attribute name to bind the value to. + * @param mixed $value Non-scalar value to bind. + * @param string $tag_name Tag name to bind the value on. + * @param string $attribute Attribute name to bind the value to. + * @param string $existing_value Pre-existing value for the bound attribute. Unused, as the attribute is absent here. */ - public function test_wp_bind_rejects_non_scalar_value( $value, string $tag_name, string $attribute ) { + public function test_wp_bind_rejects_non_scalar_value( $value, string $tag_name, string $attribute, string $existing_value ) { + unset( $existing_value ); // The bound attribute is absent here, so there is no pre-existing value to remove. + $this->interactivity->state( 'myPlugin', array( 'nonScalar' => $value ) ); $html = sprintf( '<%1$s data-wp-bind--%2$s="myPlugin::state.nonScalar">Text', $tag_name, $attribute ); From 921d90d3eb99f978aab8083ccd48c437f5f2f58d Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Mon, 27 Jul 2026 19:12:05 -0700 Subject: [PATCH 15/33] Add missing space in array literal Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../interactivity-api/class-wp-interactivity-api.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 e3602ca7c9527..a2fa0337fb6ad 100644 --- a/src/wp-includes/interactivity-api/class-wp-interactivity-api.php +++ b/src/wp-includes/interactivity-api/class-wp-interactivity-api.php @@ -914,7 +914,7 @@ private function get_directive_entries( WP_Interactivity_API_Directives_Processo continue; } - [ 'prefix' => $attr_prefix, 'suffix' => $suffix, 'unique_id' => $unique_id] = $parsed_directive; + [ 'prefix' => $attr_prefix, 'suffix' => $suffix, 'unique_id' => $unique_id ] = $parsed_directive; // Ensure it is the desired directive. if ( $prefix !== $attr_prefix ) { continue; From ae99fceb5444d6b0ce0ac4656c4217329147a506 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Mon, 27 Jul 2026 19:21:07 -0700 Subject: [PATCH 16/33] Resolve a bound object through the JSON encoder Calling JsonSerializable::jsonSerialize() resolved only one level, whereas the encoder which builds the client store keeps going. An object which serialized to another serializable object was therefore reported as a usage error and left unbound on the server, while the client received the scalar it eventually serialized to. That is the disagreement this handling exists to prevent. Encoding and decoding instead makes the resolution identical to the client's by construction, rather than reimplementing part of it. It also applies to any object, not only a serializable one: what the client receives is what decides whether the value can be bound. This changes no outcome for an object which is not JsonSerializable, since only that interface lets an object encode to something other than an object. When the encoding fails, the object is left in place so that it falls through to the usage error rather than silently dropping the attribute. A self-referencing object is bounded by the encoder's depth limit. Co-Authored-By: Copilot <175728472+Copilot@users.noreply.github.com> Co-Authored-By: Claude Opus 5 (1M context) --- .../class-wp-interactivity-api.php | 18 ++++++++++++------ .../wpInteractivityAPI-wp-bind.php | 8 ++++++++ 2 files changed, 20 insertions(+), 6 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 a2fa0337fb6ad..c3daaf5840921 100644 --- a/src/wp-includes/interactivity-api/class-wp-interactivity-api.php +++ b/src/wp-includes/interactivity-api/class-wp-interactivity-api.php @@ -1081,19 +1081,25 @@ private function data_wp_bind_processor( WP_Interactivity_API_Directives_Process $result = $this->evaluate( $entry ); /* - * A JsonSerializable object is resolved to whatever it serializes to, since that is the value the - * client receives for this same reference when the store is hydrated. + * An object is resolved to whatever it serializes to, since that is the value the client receives + * for this same reference when the store is hydrated. Round-tripping through the JSON encoder + * rather than calling JsonSerializable::jsonSerialize() directly keeps this resolution identical to + * the client's, including for an object which serializes to another serializable object. When the + * encoding fails the object is left in place, to be reported as a usage error below. */ - if ( $result instanceof JsonSerializable ) { - $result = $result->jsonSerialize(); + if ( is_object( $result ) ) { + $encoded = wp_json_encode( $result ); + if ( false !== $encoded ) { + $result = json_decode( $encoded ); + } } /* * Only scalar values can be stored in an attribute value. Strings and booleans are passed in as-is. * Numbers are cast to strings. Everything else is rejected as a usage error. * - * An object which is not JsonSerializable is rejected even when it defines `__toString()`, which PHP - * would otherwise coerce for the string parameters of the escaping functions. Its string + * An object which does not serialize to a scalar is rejected even when it defines `__toString()`, + * which PHP would otherwise coerce for the string parameters of the escaping functions. Its string * representation is not what gets serialized for the client, so the two would disagree once the * directive is evaluated during hydration. */ diff --git a/tests/phpunit/tests/interactivity-api/wpInteractivityAPI-wp-bind.php b/tests/phpunit/tests/interactivity-api/wpInteractivityAPI-wp-bind.php index c19ccd9254e08..f6db532ecb02f 100644 --- a/tests/phpunit/tests/interactivity-api/wpInteractivityAPI-wp-bind.php +++ b/tests/phpunit/tests/interactivity-api/wpInteractivityAPI-wp-bind.php @@ -481,6 +481,14 @@ public function data_json_serializable_values(): array { 'value' => 1.5, 'expected' => '1.5', ), + /* + * The JSON encoder keeps resolving a serializable object which serializes to another one, so the + * value bound on the server has to follow it all the way down to match what the client receives. + */ + 'nested' => array( + 'value' => $this->get_json_serializable( 'serialized-form' ), + 'expected' => 'serialized-form', + ), ); } From 77ec31f3d0a171a113bf28db8f14a61960826277 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Mon, 27 Jul 2026 19:26:09 -0700 Subject: [PATCH 17/33] Document that WP_Interactivity_API::parse_directive_name() can return null Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../interactivity-api/class-wp-interactivity-api.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 c3daaf5840921..8106806ad1b95 100644 --- a/src/wp-includes/interactivity-api/class-wp-interactivity-api.php +++ b/src/wp-includes/interactivity-api/class-wp-interactivity-api.php @@ -774,7 +774,7 @@ private function evaluate( $entry ) { * @since 6.9.0 * * @param string $directive_name The directive attribute name. - * @return array An array containing the directive prefix, optional suffix, and optional unique ID. + * @return array|null An array containing the directive prefix, optional suffix, and optional unique ID, or null if the directive name cannot be parsed. * @phpstan-return array{ * prefix: non-empty-string, * suffix: string|null, From b2923805555d592aa294f8c2ff001d5bd2437a93 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Mon, 27 Jul 2026 19:55:09 -0700 Subject: [PATCH 18/33] Format a bound number with the JSON encoder Casting a float to string is locale-dependent before PHP 8.0, so with LC_NUMERIC set to a locale using a comma decimal separator the server rendered 1,5 into the attribute while the client, which reads the number from the JSON-encoded store, hydrated 1.5. The cast also rounds to `precision` where the encoder uses `serialize_precision`, so a value such as 1/3 reached the attribute with 14 digits and the client with 16. Encoding the number the same way the client store is built makes the two agree by construction, whatever those settings are, and matches the object handling directly above. Integers were never affected, as only floats consult the locale. Encoding fails only for INF and NAN, which fall back to the cast so their attribute values are unchanged. Neither can be sent to the client anyway: a state containing one fails to encode entirely. Using the encoder here, rather than making the cast locale-safe, was my own approach. Dennis Snell raised the underlying locale concern in review. Claude Code wrote the implementation and the regression test, and verified the behavior against PHP 7.4. Co-Authored-By: Claude Opus 5 (1M context) --- .../class-wp-interactivity-api.php | 10 ++++++- .../wpInteractivityAPI-wp-bind.php | 27 +++++++++++++++++++ 2 files changed, 36 insertions(+), 1 deletion(-) 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 8106806ad1b95..0194f84b0c1ec 100644 --- a/src/wp-includes/interactivity-api/class-wp-interactivity-api.php +++ b/src/wp-includes/interactivity-api/class-wp-interactivity-api.php @@ -1116,7 +1116,15 @@ private function data_wp_bind_processor( WP_Interactivity_API_Directives_Process ); $result = null; } elseif ( is_int( $result ) || is_float( $result ) ) { - $result = (string) $result; + /* + * A number is formatted by the JSON encoder rather than cast to string, so that the + * attribute value matches the number the client receives for this same reference. Casting + * a float is locale-dependent before PHP 8.0, and rounds to `precision` rather than to the + * encoder's `serialize_precision`. Encoding only fails for INF and NAN, which cannot be + * sent to the client at all, and which fall back to the cast as before. + */ + $encoded = wp_json_encode( $result ); + $result = false === $encoded ? (string) $result : $encoded; } } diff --git a/tests/phpunit/tests/interactivity-api/wpInteractivityAPI-wp-bind.php b/tests/phpunit/tests/interactivity-api/wpInteractivityAPI-wp-bind.php index f6db532ecb02f..ec2b478d4bb4f 100644 --- a/tests/phpunit/tests/interactivity-api/wpInteractivityAPI-wp-bind.php +++ b/tests/phpunit/tests/interactivity-api/wpInteractivityAPI-wp-bind.php @@ -110,6 +110,33 @@ public function test_wp_bind_sets_float_value() { $this->assertSame( '
Text
', $new_html ); } + /** + * Tests that a float value is not formatted with the locale's decimal separator. + * + * Casting a float to string is locale-dependent before PHP 8.0, whereas the + * client receives the number from the JSON-encoded store, which never is. + * + * @ticket 65740 + * + * @covers ::process_directives + */ + public function test_wp_bind_sets_float_value_independently_of_the_locale() { + $previous_locale = setlocale( LC_NUMERIC, '0' ); // Passing "0" queries the current setting without changing it. + if ( false === setlocale( LC_NUMERIC, 'de_DE.UTF-8', 'de_DE', 'de_DE@euro', 'German' ) ) { + $this->markTestSkipped( 'No locale with a comma decimal separator is available.' ); + } + + try { + $this->interactivity->state( 'myPlugin', array( 'ratio' => 1.5 ) ); + + $html = '
Text
'; + list($p) = $this->process_directives( $html ); + $this->assertSame( '1.5', $p->get_attribute( 'data-ratio' ) ); + } finally { + setlocale( LC_NUMERIC, false === $previous_locale ? 'C' : $previous_locale ); + } + } + /** * Tests that true strings are set properly as attribute values. * From 1791d032fced94c8799653af04869cbd467b7d8d Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Mon, 27 Jul 2026 20:11:47 -0700 Subject: [PATCH 19/33] Reject binding INF and NAN These are scalars, so they passed the check for a value which can be sent to the client, but JSON can represent neither. A store holding one fails to encode in its entirety, and print_script_module_data() casts that failure to a string, so the client is served an empty script tag in place of all of its state: every binding on the page loses its value, not only this one. The attribute meanwhile received INF or NAN, which means nothing as an attribute value and does not match the client either, where the same numbers render as Infinity and NaN. They are now rejected along with the values which cannot cross to the client at all, with their own message, since describing them as non-scalar would be wrong. Rejecting the binding only draws attention to the problem; removing the value from the state is what resolves it. Detecting them with is_finite() rather than a failed encoding states the intent directly, and leaves the encoding in the branch below unable to fail. Co-Authored-By: Claude Opus 5 (1M context) --- .../class-wp-interactivity-api.php | 29 +++++++++--- .../wpInteractivityAPI-wp-bind.php | 45 +++++++++++++++++++ 2 files changed, 69 insertions(+), 5 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 0194f84b0c1ec..3788efc40027b 100644 --- a/src/wp-includes/interactivity-api/class-wp-interactivity-api.php +++ b/src/wp-includes/interactivity-api/class-wp-interactivity-api.php @@ -1095,8 +1095,9 @@ private function data_wp_bind_processor( WP_Interactivity_API_Directives_Process } /* - * Only scalar values can be stored in an attribute value. Strings and booleans are passed in as-is. - * Numbers are cast to strings. Everything else is rejected as a usage error. + * Only a value which can be sent to the client may be stored in an attribute value. Strings and + * booleans are passed in as-is, numbers are formatted, and everything else is rejected as a usage + * error. * * An object which does not serialize to a scalar is rejected even when it defines `__toString()`, * which PHP would otherwise coerce for the string parameters of the escaping functions. Its string @@ -1115,16 +1116,34 @@ private function data_wp_bind_processor( WP_Interactivity_API_Directives_Process '7.1.0' ); $result = null; + } elseif ( is_float( $result ) && ! is_finite( $result ) ) { + /* + * INF and NAN are scalars, but JSON can represent neither, so a store holding one fails to + * encode in its entirety and the client is sent an empty script tag in place of all of its + * state. The binding is rejected to draw attention to that, though only removing the value + * from the state resolves it. + */ + _doing_it_wrong( + __METHOD__, + sprintf( + /* translators: %s: The attribute name. */ + __( 'Attempted to bind INF or NAN to the "%s" attribute. JSON can represent neither, so the client is sent no state at all. Cast the value to a string before storing it in state or context.' ), + esc_html( $entry['suffix'] ) + ), + '7.1.0' + ); + $result = null; } elseif ( is_int( $result ) || is_float( $result ) ) { /* * A number is formatted by the JSON encoder rather than cast to string, so that the * attribute value matches the number the client receives for this same reference. Casting * a float is locale-dependent before PHP 8.0, and rounds to `precision` rather than to the - * encoder's `serialize_precision`. Encoding only fails for INF and NAN, which cannot be - * sent to the client at all, and which fall back to the cast as before. + * encoder's `serialize_precision`. */ $encoded = wp_json_encode( $result ); - $result = false === $encoded ? (string) $result : $encoded; + if ( is_string( $encoded ) ) { + $result = $encoded; + } } } diff --git a/tests/phpunit/tests/interactivity-api/wpInteractivityAPI-wp-bind.php b/tests/phpunit/tests/interactivity-api/wpInteractivityAPI-wp-bind.php index ec2b478d4bb4f..cd0dd117a931e 100644 --- a/tests/phpunit/tests/interactivity-api/wpInteractivityAPI-wp-bind.php +++ b/tests/phpunit/tests/interactivity-api/wpInteractivityAPI-wp-bind.php @@ -489,6 +489,51 @@ public function test_wp_bind_ignores_unique_id_but_processes_valid_binds() { $this->assertSame( 'some-id', $p->get_attribute( 'id' ) ); } + /** + * Data provider for float values which JSON cannot represent. + * + * @return array Data provider. + */ + public function data_non_finite_values(): array { + return array( + 'INF' => array( 'value' => INF ), + '-INF' => array( 'value' => -INF ), + 'NAN' => array( 'value' => NAN ), + ); + } + + /** + * Tests that `data-wp-bind` rejects INF and NAN. + * + * These are scalars, but a store holding one fails to encode in its + * entirety, so the client is sent no state at all rather than a value which + * merely disagrees with the server. + * + * @ticket 65740 + * + * @covers ::process_directives + * + * @dataProvider data_non_finite_values + * + * @expectedIncorrectUsage WP_Interactivity_API::data_wp_bind_processor + * + * @param float $value Non-finite value to bind. + */ + public function test_wp_bind_rejects_non_finite_value( $value ) { + $this->interactivity->state( 'myPlugin', array( 'nonFinite' => $value ) ); + + $html = '
Text
'; + list($p) = $this->process_directives( $html ); + $this->assertNull( $p->get_attribute( 'data-ratio' ), 'Expected no attribute to have been set for a value JSON cannot represent.' ); + $this->assertSame( + array( + 'WP_Interactivity_API::data_wp_bind_processor' => 'Attempted to bind INF or NAN to the "data-ratio" attribute. JSON can represent neither, so the client is sent no state at all. Cast the value to a string before storing it in state or context. (This message was added in version 7.1.0.)', + ), + $this->caught_doing_it_wrong, + 'Expected _doing_it_wrong() to have been called once with the non-finite value message.' + ); + } + /** * Data provider for values a bound object may serialize to. * From d15a50eecbb24eda3cdc8749a073d651b32bfa23 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Mon, 27 Jul 2026 20:25:41 -0700 Subject: [PATCH 20/33] Detect INF and NAN by the encoding failing Testing is_finite() separately left the number formatting able to fall through without assigning, so the value reaching set_attribute() was still int|float as far as static analysis could tell, and the call reported a type error. Folding the rejection into the encoding resolves it to a string on every path. It is also the better test. Whether the encoder can represent the value is the criterion which decides whether it can be bound at all, so asking it directly is more honest than predicting which numbers it will refuse. Note that the type error was on an unchanged line, which is why the diff of the previous commit was reported clean. Co-Authored-By: Claude Opus 5 (1M context) --- .../class-wp-interactivity-api.php | 36 +++++++++---------- 1 file changed, 18 insertions(+), 18 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 3788efc40027b..f09b35f0c49d3 100644 --- a/src/wp-includes/interactivity-api/class-wp-interactivity-api.php +++ b/src/wp-includes/interactivity-api/class-wp-interactivity-api.php @@ -1116,23 +1116,6 @@ private function data_wp_bind_processor( WP_Interactivity_API_Directives_Process '7.1.0' ); $result = null; - } elseif ( is_float( $result ) && ! is_finite( $result ) ) { - /* - * INF and NAN are scalars, but JSON can represent neither, so a store holding one fails to - * encode in its entirety and the client is sent an empty script tag in place of all of its - * state. The binding is rejected to draw attention to that, though only removing the value - * from the state resolves it. - */ - _doing_it_wrong( - __METHOD__, - sprintf( - /* translators: %s: The attribute name. */ - __( 'Attempted to bind INF or NAN to the "%s" attribute. JSON can represent neither, so the client is sent no state at all. Cast the value to a string before storing it in state or context.' ), - esc_html( $entry['suffix'] ) - ), - '7.1.0' - ); - $result = null; } elseif ( is_int( $result ) || is_float( $result ) ) { /* * A number is formatted by the JSON encoder rather than cast to string, so that the @@ -1141,7 +1124,24 @@ private function data_wp_bind_processor( WP_Interactivity_API_Directives_Process * encoder's `serialize_precision`. */ $encoded = wp_json_encode( $result ); - if ( is_string( $encoded ) ) { + if ( false === $encoded ) { + /* + * The encoder only rejects INF and NAN, of which JSON can represent neither. A store + * holding one fails to encode in its entirety, so the client is sent an empty script + * tag in place of all of its state. The binding is rejected to draw attention to that, + * though only removing the value from the state resolves it. + */ + _doing_it_wrong( + __METHOD__, + sprintf( + /* translators: %s: The attribute name. */ + __( 'Attempted to bind INF or NAN to the "%s" attribute. JSON can represent neither, so the client is sent no state at all. Cast the value to a string before storing it in state or context.' ), + esc_html( $entry['suffix'] ) + ), + '7.1.0' + ); + $result = null; + } else { $result = $encoded; } } From 41486fb6bd43ddff446496fde44586e4521f7fc4 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Mon, 27 Jul 2026 21:00:16 -0700 Subject: [PATCH 21/33] Fix indent on continue statement --- .../interactivity-api/class-wp-interactivity-api.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 f09b35f0c49d3..c91da547ae989 100644 --- a/src/wp-includes/interactivity-api/class-wp-interactivity-api.php +++ b/src/wp-includes/interactivity-api/class-wp-interactivity-api.php @@ -1061,7 +1061,7 @@ private function data_wp_bind_processor( WP_Interactivity_API_Directives_Process $entries = $this->get_directive_entries( $p, 'bind' ); foreach ( $entries as $entry ) { if ( empty( $entry['suffix'] ) || null !== $entry['unique_id'] ) { - continue; + continue; } // Skip if the suffix is an event handler. From 33b39fc88dd3e65d8a34cda221f1d6aab15dfe70 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Mon, 27 Jul 2026 21:09:53 -0700 Subject: [PATCH 22/33] Catch a throwing serialization of a bound object MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolving a bound object through the JSON encoder calls JsonSerializable::jsonSerialize(), which is arbitrary code and may throw. Nothing caught it, so the exception escaped process_directives() and took down the render — the very thing checking the value is meant to prevent. It also left $context_stack and $namespace_stack unrestored, since _process_directives() only restores them on the unbalanced-tags path, so every later process_directives() call on the same instance was corrupted. A throw is now treated as an encoding failure: the object is left in place and reported by the existing non-scalar usage error, which gives the right advice for it. Co-Authored-By: Claude Opus 5 (1M context) --- .../class-wp-interactivity-api.php | 11 +++- .../wpInteractivityAPI-wp-bind.php | 55 +++++++++++++++++++ 2 files changed, 65 insertions(+), 1 deletion(-) 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 c91da547ae989..e6d01433dd13a 100644 --- a/src/wp-includes/interactivity-api/class-wp-interactivity-api.php +++ b/src/wp-includes/interactivity-api/class-wp-interactivity-api.php @@ -1086,9 +1086,18 @@ private function data_wp_bind_processor( WP_Interactivity_API_Directives_Process * rather than calling JsonSerializable::jsonSerialize() directly keeps this resolution identical to * the client's, including for an object which serializes to another serializable object. When the * encoding fails the object is left in place, to be reported as a usage error below. + * + * A throwing JsonSerializable::jsonSerialize() is caught for the same reason the value is checked + * at all: a binding must not be able to abort the render. An exception escaping here would leave + * `$context_stack` and `$namespace_stack` unrestored for every later `process_directives()` call + * on this instance, so the object is treated as one which failed to encode. */ if ( is_object( $result ) ) { - $encoded = wp_json_encode( $result ); + try { + $encoded = wp_json_encode( $result ); + } catch ( Throwable $e ) { + $encoded = false; + } if ( false !== $encoded ) { $result = json_decode( $encoded ); } diff --git a/tests/phpunit/tests/interactivity-api/wpInteractivityAPI-wp-bind.php b/tests/phpunit/tests/interactivity-api/wpInteractivityAPI-wp-bind.php index cd0dd117a931e..f5569f2bda45f 100644 --- a/tests/phpunit/tests/interactivity-api/wpInteractivityAPI-wp-bind.php +++ b/tests/phpunit/tests/interactivity-api/wpInteractivityAPI-wp-bind.php @@ -613,6 +613,61 @@ public function test_wp_bind_json_serializable_value_matches_the_client_store() ); } + /** + * Tests that a bound object which cannot be serialized does not abort the render. + * + * `JsonSerializable::jsonSerialize()` is arbitrary code, so resolving an object + * through the JSON encoder can throw. A binding must not be able to take down the + * page, which is the whole point of checking the value at all. An exception + * escaping the directive processor would also leave the context and namespace + * stacks unrestored, breaking every later `process_directives()` call on the same + * instance. + * + * @ticket 65740 + * + * @covers ::process_directives + * + * @expectedIncorrectUsage WP_Interactivity_API::data_wp_bind_processor + */ + public function test_wp_bind_rejects_object_which_fails_to_serialize() { + $unserializable = new class() implements JsonSerializable { + /** + * Fails to produce a value for the client. + * + * @return mixed Never returns. + * @throws RuntimeException Always. + */ + #[\ReturnTypeWillChange] + public function jsonSerialize() { + throw new RuntimeException( 'This object cannot be serialized.' ); + } + }; + + $this->interactivity->state( + 'myPlugin', + array( + 'unserializable' => $unserializable, + 'id' => 'some-id', + ) + ); + + $html = '
Text
'; + list($p) = $this->process_directives( $html ); + $this->assertNull( $p->get_attribute( 'id' ), 'Expected no attribute to have been set for an object which cannot be serialized.' ); + $this->assertSame( + array( + 'WP_Interactivity_API::data_wp_bind_processor' => 'Attempted to bind a non-scalar value to the "id" attribute. Cast the value to a string before storing it in state or context so that the server and client agree. (This message was added in version 7.1.0.)', + ), + $this->caught_doing_it_wrong, + 'Expected _doing_it_wrong() to have been called once with the non-scalar value message.' + ); + + // The stacks are restored for the next render only if no exception escaped. + $html = '
Text
'; + list($p) = $this->process_directives( $html ); + $this->assertSame( 'some-id', $p->get_attribute( 'id' ), 'Expected a later render on the same instance to be unaffected.' ); + } + /** * Creates an object which serializes to the given value for the client. * From 0b83febd29bb1e4cb64661ced89f6d19b78d7f5d Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Mon, 27 Jul 2026 21:39:35 -0700 Subject: [PATCH 23/33] Skip a context directive which has no namespace Introduced in 359638afd9. Replacing end() with array_last() in get_directive_entries() changed the default namespace on an empty namespace stack from false to null, since that is what the two return for an empty array. The context processor uses the namespace as an array key, and where false coerced to the key 0, null is deprecated as an array offset in PHP 8.5, which the test suite converts to an error. That failed the five Tests_WP_Interactivity_API_WP_Context tests covering a context directive with no namespace, on every PHP 8.5 job. Such a context is skipped rather than keyed on an empty string. There is nothing to store it under, and the key 0 it was previously stored under could not be addressed by any reference either. No notice is reported here, as evaluate() already reports the missing namespace when the value is used. Note that PHP 8.5.0RC2 only deprecates the offset form, so this reproduces locally by writing the same key with $context[ $entry['namespace'] ] rather than in an array literal. The each processor uses the namespace as a key too, but is unreachable with a null one: evaluate() returns null first and the empty() guard above it returns early. Co-Authored-By: Claude Opus 5 (1M context) --- .../interactivity-api/class-wp-interactivity-api.php | 9 +++++++++ 1 file changed, 9 insertions(+) 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 e6d01433dd13a..cadf63760dfdf 100644 --- a/src/wp-includes/interactivity-api/class-wp-interactivity-api.php +++ b/src/wp-includes/interactivity-api/class-wp-interactivity-api.php @@ -1037,6 +1037,15 @@ private function data_wp_context_processor( WP_Interactivity_API_Directives_Proc continue; } + /* + * A context with no namespace has nothing to be stored under, so the inherited context is left as it + * is. Using the namespace as an array key regardless would coerce null to an empty string, which PHP + * 8.5 deprecates, and would store the context where no reference can address it anyway. + */ + if ( null === $entry['namespace'] ) { + continue; + } + $context = array_replace_recursive( $context, array( $entry['namespace'] => is_array( $entry['value'] ) ? $entry['value'] : array() ) From 906481ef0824154f19e28181d70489736ba05b70 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Mon, 27 Jul 2026 21:58:50 -0700 Subject: [PATCH 24/33] Cover the directive name parsing branches which changed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit parse_directive_name() gained two conditions when it was given an honest return type: a name which is nothing but the "data-wp-" prefix is now rejected instead of yielding an empty prefix, and a leading "--" is kept as part of the prefix instead of being read as a suffix separator. Both were untested, as was the pre-existing rejection of a name containing characters a directive name cannot contain. Neither changes what a directive does, since an empty prefix names no registered directive either way. That is worth stating rather than assuming: the client's parseDirectiveName() still returns an empty prefix for both, so the two implementations now differ in shape while agreeing on the outcome. The comments record which is which. Also covers get_directive_entries() skipping a name it cannot parse. This one passes against trunk too — destructuring null happened to produce a prefix which matched nothing — so it locks in behavior which is now deliberate rather than incidental. Co-Authored-By: Claude Opus 5 (1M context) --- .../interactivity-api/wpInteractivityAPI.php | 47 +++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/tests/phpunit/tests/interactivity-api/wpInteractivityAPI.php b/tests/phpunit/tests/interactivity-api/wpInteractivityAPI.php index 20b249bd8c44e..d9c7900160291 100644 --- a/tests/phpunit/tests/interactivity-api/wpInteractivityAPI.php +++ b/tests/phpunit/tests/interactivity-api/wpInteractivityAPI.php @@ -845,6 +845,7 @@ public function test_extract_directive_value_invalid_json() { * name. * * @ticket 64106 + * @ticket 64898 * * @covers ::parse_directive_name */ @@ -919,12 +920,38 @@ public function test_parse_directive_name() { $this->assertSame( 'test', $result['prefix'] ); $this->assertNull( $result['suffix'] ); $this->assertSame( 'unique-id--wrong-suffix', $result['unique_id'] ); + + // Should reject a name containing characters a directive name cannot contain. + $this->assertNull( $parse_directive_name->invoke( $this->interactivity, 'data-wp-test.suffix' ) ); + + /* + * Should reject a name which is nothing but the prefix, rather than returning an empty + * prefix. The client's `parseDirectiveName` returns `{ prefix: '' }` here instead, but + * neither an empty prefix nor null matches a registered directive, so the outcome is the + * same on both sides: the attribute is ignored. + */ + $this->assertNull( $parse_directive_name->invoke( $this->interactivity, 'data-wp-' ) ); + + /* + * Should treat a leading "--" as part of the prefix rather than as a suffix separator, + * which would otherwise leave the prefix empty. As above, the client splits this into + * `{ prefix: '', suffix: 'foo' }`, and as above neither result names a directive. + */ + $this->assertSame( + array( + 'prefix' => '--foo', + 'suffix' => null, + 'unique_id' => null, + ), + $parse_directive_name->invoke( $this->interactivity, 'data-wp---foo' ) + ); } /** * Tests the ability to get the valid entries of a specific directive in an HTML element. * * @ticket 64106 + * @ticket 64898 * * @covers ::get_directive_entries */ @@ -1139,6 +1166,26 @@ function ( $d ) { $results ) ); + + /* + * Should skip an attribute whose directive name cannot be parsed. Such a name is still + * matched by the prefix search, so it reaches here and has to be filtered out rather than + * destructured. + */ + $html = '
'; + $p = new WP_Interactivity_API_Directives_Processor( $html ); + $p->next_tag(); + $this->assertSame( + array( + array( + 'namespace' => 'myPlugin', + 'value' => 'kept', + 'suffix' => 'valid', + 'unique_id' => null, + ), + ), + $get_directive_entries->invoke( $this->interactivity, $p, 'test' ) + ); } /** From 91a712a10aee3533e0cee1b98e4297146d7fb9ea Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Mon, 27 Jul 2026 22:16:48 -0700 Subject: [PATCH 25/33] Account for a namespace stack entry which is false data_wp_interactive_processor() pushes an entry for every directive it enters, because one is popped for every closing tag regardless of what the directive contained. Where the directive defines no usable namespace and no enclosing one is in effect to inherit, what it pushes is end() of an empty stack, which is false. The property was documented as holding strings, so get_directive_entries() passed that false straight through as the default namespace, in breach of the string|null it declares for the entries it returns. For a context directive it then arrived as an array key, where false coerces to 0 and so slipped past the check for a namespace which is absent. Only a string names a store, so anything else is now normalized to null before the value leaves this method, and the property says what it really holds. With the type corrected, the analysis reports the mismatch this addresses rather than trusting the annotation and passing over it. Note that the two readers which take end() of this stack as an array key, in state() and get_context(), depend on false coercing to 0. Pushing null instead of false would read better but is deprecated as an array offset in PHP 8.5, so both of those want fixing first. Co-Authored-By: Copilot <175728472+Copilot@users.noreply.github.com> Co-Authored-By: Claude Opus 5 (1M context) --- .../class-wp-interactivity-api.php | 23 +++++++++++++++++-- 1 file changed, 21 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 cadf63760dfdf..17e8488753c76 100644 --- a/src/wp-includes/interactivity-api/class-wp-interactivity-api.php +++ b/src/wp-includes/interactivity-api/class-wp-interactivity-api.php @@ -109,8 +109,17 @@ final class WP_Interactivity_API { * * This is only available during directive processing, otherwise it is `null`. * + * An entry is the namespace the directive defined. It is `false` instead when + * the directive did not define a usable one — the attribute was empty, or its + * JSON held no `namespace`, or the namespace did not match the accepted + * characters — and no enclosing `data-wp-interactive` was in effect to inherit + * from. An entry is pushed either way, because one is popped for every closing + * tag regardless of what the directive contained, so `false` is what stands in + * for "no namespace here" and keeps the stack balanced. + * * @since 6.6.0 - * @var array|null + * @var array|null + * @phpstan-var list|null */ private $namespace_stack = null; @@ -923,7 +932,17 @@ private function get_directive_entries( WP_Interactivity_API_Directives_Processo if ( null === $attribute_value ) { continue; } - list( $namespace, $value ) = $this->extract_directive_value( $attribute_value, array_last( $this->namespace_stack ?? array() ) ); + /* + * The namespace stack can hold false, which data_wp_interactive_processor() pushes for a + * `data-wp-interactive` whose namespace is invalid and which has no enclosing one to inherit. Only a + * string names a store, so anything else counts as no default namespace at all. + */ + $default_namespace = array_last( $this->namespace_stack ?? array() ); + if ( ! is_string( $default_namespace ) ) { + $default_namespace = null; + } + + list( $namespace, $value ) = $this->extract_directive_value( $attribute_value, $default_namespace ); $entries[] = array( 'namespace' => $namespace, 'value' => $value, From 6324a02d2e8ad0fa01642f3c0049d3b7b6dfda3f Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Mon, 27 Jul 2026 22:26:10 -0700 Subject: [PATCH 26/33] Cover what a resolved object composes with Resolving a bound object before the value checks means those checks never learn an object was involved, so the existing handling of what it resolves to applies unchanged. That composition was asserted in review but not in code: - An object serializing to null removes the attribute quietly, as a null value does, and reports nothing. Null is a value the client can be sent. - An object serializing to a boolean keeps the boolean attribute semantics, including the string form Preact writes for `data-` and `aria-` attributes. Also pins the number formatting against the store rather than against a literal, which is the invariant it exists for, and asserts it through the same encoder both sides use so the assertion holds whatever `serialize_precision` is set to. The last one documents a path worth knowing about. An object serializing to INF does not fail to encode the way a bare INF does: wp_json_encode() retries through _wp_json_sanity_check(), which rebuilds the object from its public properties and so discards jsonSerialize() entirely. The value resolves to an empty object and is reported as non-scalar rather than as non-finite. The store is rebuilt the same way, so the client is sent that same empty object and the two still agree. Co-Authored-By: Claude Opus 5 (1M context) --- .../wpInteractivityAPI-wp-bind.php | 140 ++++++++++++++++++ 1 file changed, 140 insertions(+) diff --git a/tests/phpunit/tests/interactivity-api/wpInteractivityAPI-wp-bind.php b/tests/phpunit/tests/interactivity-api/wpInteractivityAPI-wp-bind.php index f5569f2bda45f..7096e4cf1dfec 100644 --- a/tests/phpunit/tests/interactivity-api/wpInteractivityAPI-wp-bind.php +++ b/tests/phpunit/tests/interactivity-api/wpInteractivityAPI-wp-bind.php @@ -668,6 +668,146 @@ public function jsonSerialize() { $this->assertSame( 'some-id', $p->get_attribute( 'id' ), 'Expected a later render on the same instance to be unaffected.' ); } + /** + * Tests that an object serializing to a value JSON cannot represent is rejected. + * + * The encoding does not fail here the way it does for a bare INF. When + * `json_encode()` rejects the value, `wp_json_encode()` retries with a plain + * object rebuilt from the public properties, which discards `jsonSerialize()` + * entirely and encodes to `{}`. The object therefore resolves to something + * non-scalar and is reported as such, rather than with the non-finite message. + * + * The store is rebuilt the same way, so the client is sent `{}` for this + * reference. The two still agree that there is no usable value here. + * + * @ticket 65740 + * + * @covers ::process_directives + * + * @expectedIncorrectUsage WP_Interactivity_API::data_wp_bind_processor + */ + public function test_wp_bind_rejects_object_serializing_to_a_non_finite_value() { + $this->interactivity->state( 'myPlugin', array( 'nonFinite' => $this->get_json_serializable( INF ) ) ); + + $html = '
Text
'; + list($p) = $this->process_directives( $html ); + $this->assertNull( $p->get_attribute( 'id' ), 'Expected no attribute to have been set.' ); + $this->assertSame( + array( + 'WP_Interactivity_API::data_wp_bind_processor' => 'Attempted to bind a non-scalar value to the "id" attribute. Cast the value to a string before storing it in state or context so that the server and client agree. (This message was added in version 7.1.0.)', + ), + $this->caught_doing_it_wrong, + 'Expected the non-scalar message, since the object resolves to an empty object rather than failing to encode.' + ); + + $data = $this->interactivity->filter_script_module_interactivity_data( array() ); + $encoded = wp_json_encode( $data['state'] ); + $this->assertIsString( $encoded, 'Expected the client state to still be encodable as JSON.' ); + $this->assertStringContainsString( + '"nonFinite":{}', + $encoded, + 'Expected the client to be sent the same empty object the server resolved.' + ); + } + + /** + * Tests that an object serializing to null removes the attribute quietly. + * + * The object is resolved before the null check, so it reaches it as the null + * the client will receive, and is treated the same as a null value would be. + * There is nothing to report: null is a value the client can be sent. + * + * @ticket 65740 + * + * @covers ::process_directives + */ + public function test_wp_bind_removes_attribute_for_object_serializing_to_null() { + $this->interactivity->state( 'myPlugin', array( 'nothing' => $this->get_json_serializable( null ) ) ); + + $html = '
Text
'; + list($p, $new_html) = $this->process_directives( $html ); + $this->assertNull( $p->get_attribute( 'id' ), 'Expected the pre-existing attribute to have been removed.' ); + $this->assertEqualHTML( '
Text
', $new_html ); + $this->assertSame( + array(), + $this->caught_doing_it_wrong, + 'Expected an object serializing to null to be treated as a null value, without reporting a usage error.' + ); + } + + /** + * Tests that an object serializing to a boolean keeps the boolean attribute + * semantics of the value it serializes to. + * + * Resolving the object first means the checks below it do not have to know an + * object was ever involved, so the existing handling composes. This asserts + * that it does. + * + * @ticket 65740 + * + * @covers ::process_directives + */ + public function test_wp_bind_applies_boolean_semantics_to_object_serializing_to_a_boolean() { + $this->interactivity->state( + 'myPlugin', + array( + 'yes' => $this->get_json_serializable( true ), + 'no' => $this->get_json_serializable( false ), + ) + ); + + // True sets a bare boolean attribute. + $html = '
Text
'; + list($p, $new_html) = $this->process_directives( $html ); + $this->assertTrue( $p->get_attribute( 'hidden' ) ); + $this->assertSame( '', $new_html ); + + // False removes it. + $html = ''; + list($p, $new_html) = $this->process_directives( $html ); + $this->assertNull( $p->get_attribute( 'hidden' ) ); + $this->assertEqualHTML( '
Text
', $new_html ); + + // On a `data-` or `aria-` attribute it becomes the string Preact would write. + $html = '
Text
'; + list($p) = $this->process_directives( $html ); + $this->assertSame( 'true', $p->get_attribute( 'data-open' ) ); + + $html = '
Text
'; + list($p) = $this->process_directives( $html ); + $this->assertSame( 'false', $p->get_attribute( 'aria-hidden' ) ); + } + + /** + * Tests that a bound number is written the same way the client store writes it. + * + * This is the invariant the number formatting exists for. A cast would round to + * `precision` where the store uses `serialize_precision`, so both are rendered + * by the same encoder instead of being compared after the fact. + * + * @ticket 65740 + * + * @covers ::process_directives + */ + public function test_wp_bind_number_value_matches_the_client_store() { + $this->interactivity->state( 'myPlugin', array( 'ratio' => 1 / 3 ) ); + + $html = '
Text
'; + list($p) = $this->process_directives( $html ); + + $data = $this->interactivity->filter_script_module_interactivity_data( array() ); + $encoded = wp_json_encode( $data['state'] ); + $this->assertIsString( $encoded, 'Expected the client state to be encodable as JSON.' ); + + $expected = wp_json_encode( 1 / 3 ); + $this->assertSame( $expected, $p->get_attribute( 'data-ratio' ) ); + $this->assertStringContainsString( + '"ratio":' . $expected, + $encoded, + 'Expected the rendered attribute value to match the number sent to the client.' + ); + } + /** * Creates an object which serializes to the given value for the client. * From 3e8285749a578628cd1101b6acda700e1da25249 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Mon, 27 Jul 2026 22:43:30 -0700 Subject: [PATCH 27/33] Say what the bind processor actually does now Documentation and a return type, no behavior change. data_wp_bind_processor() gains an @since entry for the object resolution, the number formatting, and the rejection of a value which cannot be sent to the client. Two comments claimed more than they deliver: - Resolving an object says the object is left in place when the encoding fails, which is true but rare. wp_json_encode() retries through _wp_json_sanity_check(), which rebuilds the object from its public properties and ignores jsonSerialize() entirely, so an object whose serialized form JSON cannot represent resolves to whatever that rebuild encodes to rather than failing. - Formatting a number says the attribute matches what the client receives, which holds for the locale and precision cases it was written for but not for exponent notation, negative zero, or an integer beyond the range JavaScript represents exactly. Casting diverged on all three too, so the comment now says which cases are closed rather than implying all of them are. Also names the float test for what it asserts, since not casting is the point of it, and declares the array return type on the test helper. Co-Authored-By: Claude Opus 5 (1M context) --- .../class-wp-interactivity-api.php | 15 ++++++++++++++- .../wpInteractivityAPI-wp-bind.php | 6 +++--- 2 files changed, 17 insertions(+), 4 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 17e8488753c76..8c487c63dc20d 100644 --- a/src/wp-includes/interactivity-api/class-wp-interactivity-api.php +++ b/src/wp-includes/interactivity-api/class-wp-interactivity-api.php @@ -1080,6 +1080,9 @@ private function data_wp_context_processor( WP_Interactivity_API_Directives_Proc * associated reference. * * @since 6.5.0 + * @since 7.1.0 An object is resolved to whatever it serializes to for the client, a number is formatted by the + * JSON encoder, and a value which cannot be sent to the client is rejected rather than passed to + * WP_HTML_Tag_Processor::set_attribute(). * * @param WP_Interactivity_API_Directives_Processor $p The directives processor instance. * @param string $mode Whether the processing is entering or exiting the tag. @@ -1113,7 +1116,11 @@ private function data_wp_bind_processor( WP_Interactivity_API_Directives_Process * for this same reference when the store is hydrated. Round-tripping through the JSON encoder * rather than calling JsonSerializable::jsonSerialize() directly keeps this resolution identical to * the client's, including for an object which serializes to another serializable object. When the - * encoding fails the object is left in place, to be reported as a usage error below. + * encoding fails the object is left in place, to be reported as a usage error below. Note that it + * rarely does fail: wp_json_encode() retries through _wp_json_sanity_check(), which rebuilds the + * object from its public properties and so ignores jsonSerialize() altogether. An object whose + * serialized form JSON cannot represent therefore resolves to whatever that rebuild encodes to, + * which is what the client is sent for it as well. * * A throwing JsonSerializable::jsonSerialize() is caught for the same reason the value is checked * at all: a binding must not be able to abort the render. An exception escaping here would leave @@ -1159,6 +1166,12 @@ private function data_wp_bind_processor( WP_Interactivity_API_Directives_Process * attribute value matches the number the client receives for this same reference. Casting * a float is locale-dependent before PHP 8.0, and rounds to `precision` rather than to the * encoder's `serialize_precision`. + * + * This closes the cases which differ in practice, not every one. A float written in + * exponent notation still disagrees, since PHP encodes 1e25 as `1.0e+25` where JavaScript + * renders it as `1e+25`, as does negative zero, and an integer above the range JavaScript + * can represent exactly is rounded once it reaches the client. Casting diverged on all + * three as well, so none is a regression. */ $encoded = wp_json_encode( $result ); if ( false === $encoded ) { diff --git a/tests/phpunit/tests/interactivity-api/wpInteractivityAPI-wp-bind.php b/tests/phpunit/tests/interactivity-api/wpInteractivityAPI-wp-bind.php index 7096e4cf1dfec..304c86dd2d366 100644 --- a/tests/phpunit/tests/interactivity-api/wpInteractivityAPI-wp-bind.php +++ b/tests/phpunit/tests/interactivity-api/wpInteractivityAPI-wp-bind.php @@ -47,7 +47,7 @@ public function set_up() { * @param string $html The HTML that needs to be processed. * @return array{ 0: WP_HTML_Tag_Processor, 1: string } An array containing an instance of the WP_HTML_Tag_Processor and the processed HTML. */ - private function process_directives( string $html ) { + private function process_directives( string $html ): array { $new_html = $this->interactivity->process_directives( $html ); $p = new WP_HTML_Tag_Processor( $new_html ); $p->next_tag(); @@ -94,8 +94,8 @@ public function test_wp_bind_sets_number_value() { } /** - * Tests that a float value is cast to a string when set as an attribute via - * `data-wp-bind`. + * Tests that a float value is formatted as a string when set as an attribute + * via `data-wp-bind`. * * @ticket 65740 * From a7a46b6baaf18e3dd5a6966bdb8c44fe3f146190 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Tue, 28 Jul 2026 09:44:04 -0700 Subject: [PATCH 28/33] Use simpler check for non-empty valid name --- .../interactivity-api/class-wp-interactivity-api.php | 4 ++-- 1 file changed, 2 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 8c487c63dc20d..0508834f77054 100644 --- a/src/wp-includes/interactivity-api/class-wp-interactivity-api.php +++ b/src/wp-includes/interactivity-api/class-wp-interactivity-api.php @@ -794,8 +794,8 @@ private function parse_directive_name( string $directive_name ): ?array { // Remove the first 8 characters (assumes "data-wp-" prefix) $name = (string) substr( $directive_name, 8 ); - // Check for invalid characters (anything not a-z, 0-9, -, or _) - if ( '' === $name || preg_match( '/[^a-z0-9\-_]/i', $name ) ) { + // Ensure the name only contains valid characters (anything a-z, 0-9, -, or _). + if ( 1 !== preg_match( '/^[a-z0-9\-_]+$/i', $name ) ) { return null; } From b1cb90550ee6e09ae307608ce0cac850766dccae Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Tue, 28 Jul 2026 10:03:55 -0700 Subject: [PATCH 29/33] Remove case-insensitive flag in favor of explicit character range in pattern --- .../interactivity-api/class-wp-interactivity-api.php | 4 ++-- 1 file changed, 2 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 0508834f77054..2cb1a6f07fa85 100644 --- a/src/wp-includes/interactivity-api/class-wp-interactivity-api.php +++ b/src/wp-includes/interactivity-api/class-wp-interactivity-api.php @@ -794,8 +794,8 @@ private function parse_directive_name( string $directive_name ): ?array { // Remove the first 8 characters (assumes "data-wp-" prefix) $name = (string) substr( $directive_name, 8 ); - // Ensure the name only contains valid characters (anything a-z, 0-9, -, or _). - if ( 1 !== preg_match( '/^[a-z0-9\-_]+$/i', $name ) ) { + // Ensure the name only contains valid characters (anything a-z, A-Z, 0-9, -, or _). + if ( 1 !== preg_match( '/^[a-zA-Z0-9\-_]+$/', $name ) ) { return null; } From dc521cb1f2fda29147d28579515b06e81f239397 Mon Sep 17 00:00:00 2001 From: luisherranz Date: Wed, 29 Jul 2026 11:35:20 +0200 Subject: [PATCH 30/33] Clarify binding requirements for non-scalar values --- .../class-wp-interactivity-api.php | 39 +++++++++++-------- .../wpInteractivityAPI-wp-bind.php | 10 ++--- 2 files changed, 27 insertions(+), 22 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 2cb1a6f07fa85..3b2979ddacacf 100644 --- a/src/wp-includes/interactivity-api/class-wp-interactivity-api.php +++ b/src/wp-includes/interactivity-api/class-wp-interactivity-api.php @@ -1112,15 +1112,18 @@ private function data_wp_bind_processor( WP_Interactivity_API_Directives_Process $result = $this->evaluate( $entry ); /* - * An object is resolved to whatever it serializes to, since that is the value the client receives - * for this same reference when the store is hydrated. Round-tripping through the JSON encoder - * rather than calling JsonSerializable::jsonSerialize() directly keeps this resolution identical to - * the client's, including for an object which serializes to another serializable object. When the - * encoding fails the object is left in place, to be reported as a usage error below. Note that it - * rarely does fail: wp_json_encode() retries through _wp_json_sanity_check(), which rebuilds the - * object from its public properties and so ignores jsonSerialize() altogether. An object whose - * serialized form JSON cannot represent therefore resolves to whatever that rebuild encodes to, - * which is what the client is sent for it as well. + * An object is resolved to whatever it serializes to. When the reference points to a value stored + * in state or context, that is the value the client receives for it when the store is hydrated. + * A derived state closure is never serialized, so there the client value comes from the derived + * state's client-side implementation instead; the resolution is still applied so that both origins + * behave the same. Round-tripping through the JSON encoder rather than calling + * JsonSerializable::jsonSerialize() directly keeps this resolution identical to the client's, + * including for an object which serializes to another serializable object. When the encoding fails + * the object is left in place, to be reported as a usage error below. Note that it rarely does + * fail: wp_json_encode() retries through _wp_json_sanity_check(), which rebuilds the object from + * its public properties and so ignores jsonSerialize() altogether. An object whose serialized form + * JSON cannot represent therefore resolves to whatever that rebuild encodes to, which is what the + * client is sent for it as well. * * A throwing JsonSerializable::jsonSerialize() is caught for the same reason the value is checked * at all: a binding must not be able to abort the render. An exception escaping here would leave @@ -1145,8 +1148,9 @@ private function data_wp_bind_processor( WP_Interactivity_API_Directives_Process * * An object which does not serialize to a scalar is rejected even when it defines `__toString()`, * which PHP would otherwise coerce for the string parameters of the escaping functions. Its string - * representation is not what gets serialized for the client, so the two would disagree once the - * directive is evaluated during hydration. + * representation is not what the client evaluates this reference to, whether that is the form + * serialized into the store or the return value of a derived state's client-side implementation, + * so the two could disagree once the directive is evaluated during hydration. */ if ( null !== $result ) { if ( ! is_scalar( $result ) ) { @@ -1154,7 +1158,7 @@ private function data_wp_bind_processor( WP_Interactivity_API_Directives_Process __METHOD__, sprintf( /* translators: %s: The attribute name. */ - __( 'Attempted to bind a non-scalar value to the "%s" attribute. Cast the value to a string before storing it in state or context so that the server and client agree.' ), + __( 'Attempted to bind a non-scalar value to the "%s" attribute. Ensure the state/context property or the derived state closure resolves to a string.' ), esc_html( $entry['suffix'] ) ), '7.1.0' @@ -1176,16 +1180,17 @@ private function data_wp_bind_processor( WP_Interactivity_API_Directives_Process $encoded = wp_json_encode( $result ); if ( false === $encoded ) { /* - * The encoder only rejects INF and NAN, of which JSON can represent neither. A store - * holding one fails to encode in its entirety, so the client is sent an empty script - * tag in place of all of its state. The binding is rejected to draw attention to that, - * though only removing the value from the state resolves it. + * The encoder only rejects INF and NAN, of which JSON can represent neither. When such + * a value is stored in state, the store itself also fails to encode in its entirety, + * and the client is sent an empty script tag in place of all of its state; only + * removing the value from the state resolves that. A derived state closure returning + * one never reaches the store, so there only the binding itself is affected. */ _doing_it_wrong( __METHOD__, sprintf( /* translators: %s: The attribute name. */ - __( 'Attempted to bind INF or NAN to the "%s" attribute. JSON can represent neither, so the client is sent no state at all. Cast the value to a string before storing it in state or context.' ), + __( 'Attempted to bind INF or NAN to the "%s" attribute. Ensure the state/context property or the derived state closure resolves to a string.' ), esc_html( $entry['suffix'] ) ), '7.1.0' diff --git a/tests/phpunit/tests/interactivity-api/wpInteractivityAPI-wp-bind.php b/tests/phpunit/tests/interactivity-api/wpInteractivityAPI-wp-bind.php index 304c86dd2d366..bcab823b1e0c2 100644 --- a/tests/phpunit/tests/interactivity-api/wpInteractivityAPI-wp-bind.php +++ b/tests/phpunit/tests/interactivity-api/wpInteractivityAPI-wp-bind.php @@ -527,7 +527,7 @@ public function test_wp_bind_rejects_non_finite_value( $value ) { $this->assertNull( $p->get_attribute( 'data-ratio' ), 'Expected no attribute to have been set for a value JSON cannot represent.' ); $this->assertSame( array( - 'WP_Interactivity_API::data_wp_bind_processor' => 'Attempted to bind INF or NAN to the "data-ratio" attribute. JSON can represent neither, so the client is sent no state at all. Cast the value to a string before storing it in state or context. (This message was added in version 7.1.0.)', + 'WP_Interactivity_API::data_wp_bind_processor' => 'Attempted to bind INF or NAN to the "data-ratio" attribute. Ensure the state/context property or the derived state closure resolves to a string. (This message was added in version 7.1.0.)', ), $this->caught_doing_it_wrong, 'Expected _doing_it_wrong() to have been called once with the non-finite value message.' @@ -656,7 +656,7 @@ public function jsonSerialize() { $this->assertNull( $p->get_attribute( 'id' ), 'Expected no attribute to have been set for an object which cannot be serialized.' ); $this->assertSame( array( - 'WP_Interactivity_API::data_wp_bind_processor' => 'Attempted to bind a non-scalar value to the "id" attribute. Cast the value to a string before storing it in state or context so that the server and client agree. (This message was added in version 7.1.0.)', + 'WP_Interactivity_API::data_wp_bind_processor' => 'Attempted to bind a non-scalar value to the "id" attribute. Ensure the state/context property or the derived state closure resolves to a string. (This message was added in version 7.1.0.)', ), $this->caught_doing_it_wrong, 'Expected _doing_it_wrong() to have been called once with the non-scalar value message.' @@ -694,7 +694,7 @@ public function test_wp_bind_rejects_object_serializing_to_a_non_finite_value() $this->assertNull( $p->get_attribute( 'id' ), 'Expected no attribute to have been set.' ); $this->assertSame( array( - 'WP_Interactivity_API::data_wp_bind_processor' => 'Attempted to bind a non-scalar value to the "id" attribute. Cast the value to a string before storing it in state or context so that the server and client agree. (This message was added in version 7.1.0.)', + 'WP_Interactivity_API::data_wp_bind_processor' => 'Attempted to bind a non-scalar value to the "id" attribute. Ensure the state/context property or the derived state closure resolves to a string. (This message was added in version 7.1.0.)', ), $this->caught_doing_it_wrong, 'Expected the non-scalar message, since the object resolves to an empty object rather than failing to encode.' @@ -945,7 +945,7 @@ public function test_wp_bind_rejects_non_scalar_value( $value, string $tag_name, $this->assertSame( array( 'WP_Interactivity_API::data_wp_bind_processor' => sprintf( - 'Attempted to bind a non-scalar value to the "%s" attribute. Cast the value to a string before storing it in state or context so that the server and client agree. (This message was added in version 7.1.0.)', + 'Attempted to bind a non-scalar value to the "%s" attribute. Ensure the state/context property or the derived state closure resolves to a string. (This message was added in version 7.1.0.)', $attribute ), ), @@ -981,7 +981,7 @@ public function test_wp_bind_removes_existing_attribute_for_non_scalar_value( $v $this->assertSame( array( 'WP_Interactivity_API::data_wp_bind_processor' => sprintf( - 'Attempted to bind a non-scalar value to the "%s" attribute. Cast the value to a string before storing it in state or context so that the server and client agree. (This message was added in version 7.1.0.)', + 'Attempted to bind a non-scalar value to the "%s" attribute. Ensure the state/context property or the derived state closure resolves to a string. (This message was added in version 7.1.0.)', $attribute ), ), From 1c747f3c49714d12d5d8e07416330d9f64acb87f Mon Sep 17 00:00:00 2001 From: Dennis Snell Date: Wed, 29 Jul 2026 15:43:36 -0500 Subject: [PATCH 31/33] Link test reference instead of referring to it textually. --- .../interactivity-api/class-wp-interactivity-api.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 3b2979ddacacf..5631e478e8c65 100644 --- a/src/wp-includes/interactivity-api/class-wp-interactivity-api.php +++ b/src/wp-includes/interactivity-api/class-wp-interactivity-api.php @@ -778,7 +778,7 @@ private function evaluate( $entry ) { * This function has an equivalent version for the client side. * See `parseDirectiveName` in https://github.com/WordPress/gutenberg/blob/trunk/packages/interactivity/src/vdom.ts.: * - * See examples in the function unit tests `test_parse_directive_name`. + * @see Tests_Interactivity_API_WpInteractivityAPI::test_parse_directive_name() for examples in the test inputs. * * @since 6.9.0 * From d9de0bdc68268ec6299e22c5497d7b6e4e24b577 Mon Sep 17 00:00:00 2001 From: Dennis Snell Date: Wed, 29 Jul 2026 15:51:13 -0500 Subject: [PATCH 32/33] Clarify dash-check and avoid allocation. --- .../interactivity-api/class-wp-interactivity-api.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 5631e478e8c65..70af7e270f311 100644 --- a/src/wp-includes/interactivity-api/class-wp-interactivity-api.php +++ b/src/wp-includes/interactivity-api/class-wp-interactivity-api.php @@ -814,7 +814,7 @@ private function parse_directive_name( string $directive_name ): ?array { $remaining = substr( $name, $suffix_index ); // If remaining starts with '---' but not '----', it's a unique_id - if ( '---' === substr( $remaining, 0, 3 ) && '-' !== ( $remaining[3] ?? '' ) ) { + if ( 3 === strspn( $remaining, '-' ) ) { return array( 'prefix' => $prefix, 'suffix' => null, From 5453e8f85e20675f8517a726fc80a933bd38d683 Mon Sep 17 00:00:00 2001 From: Dennis Snell Date: Wed, 29 Jul 2026 16:23:48 -0500 Subject: [PATCH 33/33] Docblock formatting. --- .../interactivity-api/class-wp-interactivity-api.php | 4 ++-- 1 file changed, 2 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 70af7e270f311..37260ac8a729c 100644 --- a/src/wp-includes/interactivity-api/class-wp-interactivity-api.php +++ b/src/wp-includes/interactivity-api/class-wp-interactivity-api.php @@ -1084,8 +1084,8 @@ private function data_wp_context_processor( WP_Interactivity_API_Directives_Proc * JSON encoder, and a value which cannot be sent to the client is rejected rather than passed to * WP_HTML_Tag_Processor::set_attribute(). * - * @param WP_Interactivity_API_Directives_Processor $p The directives processor instance. - * @param string $mode Whether the processing is entering or exiting the tag. + * @param WP_Interactivity_API_Directives_Processor $p The directives processor instance. + * @param string $mode Whether the processing is entering or exiting the tag. */ private function data_wp_bind_processor( WP_Interactivity_API_Directives_Processor $p, string $mode ): void { if ( 'enter' === $mode ) {