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..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
@@ -718,6 +718,7 @@ class WP_HTML_Tag_Processor {
*
* @since 6.2.0
* @var WP_HTML_Attribute_Token[]
+ * @phpstan-var array
*/
private $attributes = array();
@@ -2957,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 (
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..3b2979ddacacf 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',
@@ -99,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;
@@ -764,21 +783,26 @@ 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,
+ * 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 ) ) {
+ // 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;
}
- // 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,
@@ -794,7 +818,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,
);
}
@@ -846,6 +870,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 ) ) {
@@ -878,17 +903,46 @@ 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;
+ }
+ /*
+ * 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,
@@ -1002,6 +1056,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() )
@@ -1017,16 +1080,19 @@ 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.
*/
- 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 ) {
if ( empty( $entry['suffix'] ) || null !== $entry['unique_id'] ) {
- continue;
+ continue;
}
// Skip if the suffix is an event handler.
@@ -1045,6 +1111,97 @@ private function data_wp_bind_processor( WP_Interactivity_API_Directives_Process
$result = $this->evaluate( $entry );
+ /*
+ * 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
+ * `$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 ) ) {
+ try {
+ $encoded = wp_json_encode( $result );
+ } catch ( Throwable $e ) {
+ $encoded = false;
+ }
+ if ( false !== $encoded ) {
+ $result = json_decode( $encoded );
+ }
+ }
+
+ /*
+ * 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
+ * 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 ) ) {
+ _doing_it_wrong(
+ __METHOD__,
+ sprintf(
+ /* translators: %s: The attribute name. */
+ __( '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'
+ );
+ $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`.
+ *
+ * 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 ) {
+ /*
+ * 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. Ensure the state/context property or the derived state closure resolves to a string.' ),
+ esc_html( $entry['suffix'] )
+ ),
+ '7.1.0'
+ );
+ $result = null;
+ } else {
+ $result = $encoded;
+ }
+ }
+ }
+
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 02fc0d09293f7..bcab823b1e0c2 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 ): array {
$new_html = $this->interactivity->process_directives( $html );
$p = new WP_HTML_Tag_Processor( $new_html );
$p->next_tag();
@@ -93,6 +93,50 @@ public function test_wp_bind_sets_number_value() {
$this->assertSame( '100', $p->get_attribute( 'width' ) );
}
+ /**
+ * Tests that a float value is formatted as a string when set as an attribute
+ * via `data-wp-bind`.
+ *
+ * @ticket 65740
+ *
+ * @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 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.
*
@@ -444,4 +488,505 @@ 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 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. 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.'
+ );
+ }
+
+ /**
+ * 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',
+ ),
+ /*
+ * 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',
+ ),
+ );
+ }
+
+ /**
+ * 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.'
+ );
+ }
+
+ /**
+ * 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. 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.'
+ );
+
+ // 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.' );
+ }
+
+ /**
+ * 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. 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.'
+ );
+
+ $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( 'Text
', $new_html );
+
+ // False removes it.
+ $html = 'Text
';
+ 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.
+ *
+ * @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.
+ *
+ * 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.
+ */
+ 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() {
+ /**
+ * Returns the string representation.
+ *
+ * @return string String representation.
+ */
+ 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(
+ '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;
+ }
+
+ /**
+ * 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
+ *
+ * @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 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, 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%1$s>', $tag_name, $attribute );
+ list($p, $new_html) = $this->process_directives( $html );
+ $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 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
+ ),
+ ),
+ $this->caught_doing_it_wrong,
+ 'Expected _doing_it_wrong() to have been called once with the non-scalar value message.'
+ );
+ }
+
+ /**
+ * 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
+ *
+ * @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 string $existing_value Pre-existing value for the bound attribute.
+ */
+ 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 = sprintf( '<%1$s %2$s="%3$s" data-wp-bind--%2$s="myPlugin::state.nonScalar">Text%1$s>', $tag_name, $attribute, $existing_value );
+ list($p, $new_html) = $this->process_directives( $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%1$s>', $tag_name, $attribute ), $new_html );
+ $this->assertSame(
+ array(
+ 'WP_Interactivity_API::data_wp_bind_processor' => sprintf(
+ '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
+ ),
+ ),
+ $this->caught_doing_it_wrong,
+ 'Expected _doing_it_wrong() to have been called once with the non-scalar value message.'
+ );
+ }
}
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' )
+ );
}
/**