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( '', $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. + * + * @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', $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', $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', $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' ) + ); } /**