Changeset 62944
- Timestamp:
- 07/30/2026 05:04:25 PM (8 days ago)
- Location:
- trunk
- Files:
-
- 4 edited
-
src/wp-includes/html-api/class-wp-html-tag-processor.php (modified) (2 diffs)
-
src/wp-includes/interactivity-api/class-wp-interactivity-api.php (modified) (11 diffs)
-
tests/phpunit/tests/interactivity-api/wpInteractivityAPI-wp-bind.php (modified) (3 diffs)
-
tests/phpunit/tests/interactivity-api/wpInteractivityAPI.php (modified) (5 diffs)
Legend:
- Unmodified
- Added
- Removed
-
trunk/src/wp-includes/html-api/class-wp-html-tag-processor.php
r62715 r62944 719 719 * @since 6.2.0 720 720 * @var WP_HTML_Attribute_Token[] 721 * @phpstan-var array<non-empty-string, WP_HTML_Attribute_Token> 721 722 */ 722 723 private $attributes = array(); … … 2958 2959 * @param string $prefix Prefix of requested attribute names. 2959 2960 * @return array|null List of attribute names, or `null` when no tag opener is matched. 2961 * @phpstan-return list<non-empty-string>|null 2960 2962 */ 2961 2963 public function get_attribute_names_with_prefix( $prefix ): ?array { -
trunk/src/wp-includes/interactivity-api/class-wp-interactivity-api.php
r62503 r62944 18 18 * 19 19 * @since 6.5.0 20 * @var array 21 */ 22 private static $directive_processors = array( 20 * @var array<string, string> 21 * @phpstan-var array{ 22 * 'data-wp-interactive': 'data_wp_interactive_processor', 23 * 'data-wp-router-region': 'data_wp_router_region_processor', 24 * 'data-wp-context': 'data_wp_context_processor', 25 * 'data-wp-bind': 'data_wp_bind_processor', 26 * 'data-wp-class': 'data_wp_class_processor', 27 * 'data-wp-style': 'data_wp_style_processor', 28 * 'data-wp-text': 'data_wp_text_processor', 29 * 'data-wp-each': 'data_wp_each_processor', 30 * } 31 */ 32 private static array $directive_processors = array( 23 33 'data-wp-interactive' => 'data_wp_interactive_processor', 24 34 'data-wp-router-region' => 'data_wp_router_region_processor', … … 100 110 * This is only available during directive processing, otherwise it is `null`. 101 111 * 112 * An entry is the namespace the directive defined. It is `false` instead when 113 * the directive did not define a usable one — the attribute was empty, or its 114 * JSON held no `namespace`, or the namespace did not match the accepted 115 * characters — and no enclosing `data-wp-interactive` was in effect to inherit 116 * from. An entry is pushed either way, because one is popped for every closing 117 * tag regardless of what the directive contained, so `false` is what stands in 118 * for "no namespace here" and keeps the stack balanced. 119 * 102 120 * @since 6.6.0 103 * @var array<string>|null 121 * @var array<string|false>|null 122 * @phpstan-var list<string|false>|null 104 123 */ 105 124 private $namespace_stack = null; … … 753 772 /** 754 773 * Parse the directive name to extract the following parts: 755 * - Prefix: The main directive name without "data-wp-". 774 * - Prefix: The main directive name without "data-wp-". It cannot begin with a hyphen. 756 775 * - Suffix: An optional suffix used during directive processing, extracted after the first double hyphen "--". 757 776 * - Unique ID: An optional unique identifier, extracted after the first triple hyphen "---". 758 777 * 759 778 * This function has an equivalent version for the client side. 760 * See `parseDirectiveName` in https://github.com/WordPress/gutenberg/blob/trunk/packages/interactivity/src/vdom.ts.: 761 * 762 * See examples in the function unit tests `test_parse_directive_name`. 779 * See `parseDirectiveName` in https://github.com/WordPress/gutenberg/blob/trunk/packages/interactivity/src/vdom.ts: 780 * 781 * An empty suffix or unique ID is normalized to null, but the string "0" is preserved. The 782 * client's `|| null` discards only the empty string, since every non-empty string is truthy in 783 * JavaScript. Do not use empty() for these checks: it would discard "0" and diverge from the 784 * client. 785 * 786 * @see Tests_Interactivity_API_WpInteractivityAPI::test_parse_directive_name() for examples in the test inputs. 763 787 * 764 788 * @since 6.9.0 765 789 * 766 790 * @param string $directive_name The directive attribute name. 767 * @return array An array containing the directive prefix, optional suffix, and optional unique ID. 791 * @return array|null An array containing the directive prefix, optional suffix, and optional unique ID, or null if the directive name cannot be parsed. 792 * @phpstan-return array{ 793 * prefix: non-empty-string, 794 * suffix: non-empty-string|null, 795 * unique_id: non-empty-string|null, 796 * }|null 768 797 */ 769 798 private function parse_directive_name( string $directive_name ): ?array { 770 799 // Remove the first 8 characters (assumes "data-wp-" prefix) 771 $name = substr( $directive_name, 8 );772 773 // Check for invalid characters (anything not a-z, 0-9, -, or _)774 if ( preg_match( '/[^a-z0-9\-_]/i', $name ) ) {800 $name = (string) substr( $directive_name, 8 ); 801 802 // Ensure the name only contains valid characters (anything a-z, A-Z, 0-9, -, or _). 803 if ( 1 !== preg_match( '/^[a-zA-Z0-9\-_]+$/', $name ) ) { 775 804 return null; 776 805 } 777 806 778 // Find the first occurrence of '--' to separate the prefix 807 // Find the first occurrence of '--' to separate the prefix. 779 808 $suffix_index = strpos( $name, '--' ); 780 809 810 /* 811 * A prefix cannot begin with a hyphen, so a name which does is not a directive at all. This 812 * covers both a lone leading hyphen, as in "data-wp--bind", and a leading double hyphen, as 813 * in "data-wp---foo", where treating the hyphens as a suffix separator would instead leave 814 * the prefix empty. It also covers "data-wp----unique-id", where only a unique ID is supplied 815 * without any prefix or suffix. 816 */ 817 if ( 0 === $suffix_index || '-' === $name[0] ) { 818 return null; 819 } 820 821 // Without a '--' the whole name is the prefix. (This naturally also means there is no unique ID after '---'.) 781 822 if ( false === $suffix_index ) { 782 823 return array( … … 791 832 792 833 // If remaining starts with '---' but not '----', it's a unique_id 793 if ( '---' === substr( $remaining, 0, 3 ) && '-' !== ( $remaining[3] ?? '' ) ) { 834 if ( 3 === strspn( $remaining, '-' ) ) { 835 $unique_id = (string) substr( $remaining, 3 ); 794 836 return array( 795 837 'prefix' => $prefix, 796 838 'suffix' => null, 797 'unique_id' => ' ---' !== $remaining ? substr( $remaining, 3 ) : null,839 'unique_id' => '' === $unique_id ? null : $unique_id, 798 840 ); 799 841 } 800 842 801 843 // Otherwise, remove the first two dashes for a potential suffix 802 $suffix = substr( $remaining, 2 );844 $suffix = (string) substr( $remaining, 2 ); 803 845 804 846 // Look for '---' in the suffix for a unique_id … … 806 848 807 849 if ( false !== $unique_id_index && '-' !== ( $suffix[ $unique_id_index + 3 ] ?? '' ) ) { 808 $unique_id = substr( $suffix, $unique_id_index + 3 );809 $suffix = substr( $suffix, 0, $unique_id_index );850 $unique_id = (string) substr( $suffix, $unique_id_index + 3 ); 851 $suffix = (string) substr( $suffix, 0, $unique_id_index ); 810 852 return array( 811 853 'prefix' => $prefix, 812 'suffix' => empty( $suffix )? null : $suffix,813 'unique_id' => empty( $unique_id )? null : $unique_id,854 'suffix' => '' === $suffix ? null : $suffix, 855 'unique_id' => '' === $unique_id ? null : $unique_id, 814 856 ); 815 857 } … … 817 859 return array( 818 860 'prefix' => $prefix, 819 'suffix' => empty( $suffix )? null : $suffix,861 'suffix' => '' === $suffix ? null : $suffix, 820 862 'unique_id' => null, 821 863 ); … … 847 889 * @return array An array containing the namespace in the first item and the JSON, the reference path, or null on the 848 890 * second item. 891 * @phpstan-return array{ 0: string|null, 1: mixed } 849 892 */ 850 893 private function extract_directive_value( $directive_value, $default_namespace = null ): array { … … 879 922 * @param string $prefix The directive prefix to filter by. 880 923 * @return array An array of entries containing the directive namespace, value, suffix, and unique ID. 881 */ 882 private function get_directive_entries( WP_Interactivity_API_Directives_Processor $p, string $prefix ) { 924 * @phpstan-return list<array{ 925 * namespace: string|null, 926 * value: mixed, 927 * suffix: string|null, 928 * unique_id: string|null, 929 * }> 930 */ 931 private function get_directive_entries( WP_Interactivity_API_Directives_Processor $p, string $prefix ): array { 883 932 $directive_attributes = $p->get_attribute_names_with_prefix( 'data-wp-' . $prefix ); 884 $entries = array(); 933 if ( null === $directive_attributes ) { 934 return array(); 935 } 936 937 $entries = array(); 885 938 foreach ( $directive_attributes as $attribute_name ) { 886 [ 'prefix' => $attr_prefix, 'suffix' => $suffix, 'unique_id' => $unique_id] = $this->parse_directive_name( $attribute_name ); 939 $parsed_directive = $this->parse_directive_name( $attribute_name ); 940 if ( null === $parsed_directive ) { 941 continue; 942 } 943 944 [ 'prefix' => $attr_prefix, 'suffix' => $suffix, 'unique_id' => $unique_id ] = $parsed_directive; 887 945 // Ensure it is the desired directive. 888 946 if ( $prefix !== $attr_prefix ) { 889 947 continue; 890 948 } 891 list( $namespace, $value ) = $this->extract_directive_value( $p->get_attribute( $attribute_name ), end( $this->namespace_stack ) ); 949 $attribute_value = $p->get_attribute( $attribute_name ); 950 if ( null === $attribute_value ) { 951 continue; 952 } 953 /* 954 * The namespace stack can hold false, which data_wp_interactive_processor() pushes for a 955 * `data-wp-interactive` whose namespace is invalid and which has no enclosing one to inherit. Only a 956 * string names a store, so anything else counts as no default namespace at all. 957 */ 958 $default_namespace = array_last( $this->namespace_stack ?? array() ); 959 if ( ! is_string( $default_namespace ) ) { 960 $default_namespace = null; 961 } 962 963 list( $namespace, $value ) = $this->extract_directive_value( $attribute_value, $default_namespace ); 892 964 $entries[] = array( 893 965 'namespace' => $namespace, … … 1003 1075 } 1004 1076 1077 /* 1078 * A context with no namespace has nothing to be stored under, so the inherited context is left as it 1079 * is. Using the namespace as an array key regardless would coerce null to an empty string, which PHP 1080 * 8.5 deprecates, and would store the context where no reference can address it anyway. 1081 */ 1082 if ( null === $entry['namespace'] ) { 1083 continue; 1084 } 1085 1005 1086 $context = array_replace_recursive( 1006 1087 $context, … … 1018 1099 * 1019 1100 * @since 6.5.0 1020 * 1021 * @param WP_Interactivity_API_Directives_Processor $p The directives processor instance. 1022 * @param string $mode Whether the processing is entering or exiting the tag. 1023 */ 1024 private function data_wp_bind_processor( WP_Interactivity_API_Directives_Processor $p, string $mode ) { 1101 * @since 7.1.0 An object is resolved to whatever it serializes to for the client, a number is formatted by the 1102 * JSON encoder, and a value which cannot be sent to the client is rejected rather than passed to 1103 * WP_HTML_Tag_Processor::set_attribute(). 1104 * 1105 * @param WP_Interactivity_API_Directives_Processor $p The directives processor instance. 1106 * @param string $mode Whether the processing is entering or exiting the tag. 1107 */ 1108 private function data_wp_bind_processor( WP_Interactivity_API_Directives_Processor $p, string $mode ): void { 1025 1109 if ( 'enter' === $mode ) { 1026 1110 $entries = $this->get_directive_entries( $p, 'bind' ); 1027 1111 foreach ( $entries as $entry ) { 1028 1112 if ( empty( $entry['suffix'] ) || null !== $entry['unique_id'] ) { 1029 continue;1113 continue; 1030 1114 } 1031 1115 … … 1045 1129 1046 1130 $result = $this->evaluate( $entry ); 1131 1132 /* 1133 * An object is resolved to whatever it serializes to. When the reference points to a value stored 1134 * in state or context, that is the value the client receives for it when the store is hydrated. 1135 * A derived state closure is never serialized, so there the client value comes from the derived 1136 * state's client-side implementation instead; the resolution is still applied so that both origins 1137 * behave the same. Round-tripping through the JSON encoder rather than calling 1138 * JsonSerializable::jsonSerialize() directly keeps this resolution identical to the client's, 1139 * including for an object which serializes to another serializable object. When the encoding fails 1140 * the object is left in place, to be reported as a usage error below. Note that it rarely does 1141 * fail: wp_json_encode() retries through _wp_json_sanity_check(), which rebuilds the object from 1142 * its public properties and so ignores jsonSerialize() altogether. An object whose serialized form 1143 * JSON cannot represent therefore resolves to whatever that rebuild encodes to, which is what the 1144 * client is sent for it as well. 1145 * 1146 * A throwing JsonSerializable::jsonSerialize() is caught for the same reason the value is checked 1147 * at all: a binding must not be able to abort the render. An exception escaping here would leave 1148 * `$context_stack` and `$namespace_stack` unrestored for every later `process_directives()` call 1149 * on this instance, so the object is treated as one which failed to encode. 1150 */ 1151 if ( is_object( $result ) ) { 1152 try { 1153 $encoded = wp_json_encode( $result ); 1154 } catch ( Throwable $e ) { 1155 $encoded = false; 1156 } 1157 if ( false !== $encoded ) { 1158 $result = json_decode( $encoded ); 1159 } 1160 } 1161 1162 /* 1163 * Only a value which can be sent to the client may be stored in an attribute value. Strings and 1164 * booleans are passed in as-is, numbers are formatted, and everything else is rejected as a usage 1165 * error. 1166 * 1167 * An object which does not serialize to a scalar is rejected even when it defines `__toString()`, 1168 * which PHP would otherwise coerce for the string parameters of the escaping functions. Its string 1169 * representation is not what the client evaluates this reference to, whether that is the form 1170 * serialized into the store or the return value of a derived state's client-side implementation, 1171 * so the two could disagree once the directive is evaluated during hydration. 1172 */ 1173 if ( null !== $result ) { 1174 if ( ! is_scalar( $result ) ) { 1175 _doing_it_wrong( 1176 __METHOD__, 1177 sprintf( 1178 /* translators: %s: The attribute name. */ 1179 __( '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, number, or boolean.' ), 1180 esc_html( $entry['suffix'] ) 1181 ), 1182 '7.1.0' 1183 ); 1184 $result = null; 1185 } elseif ( is_int( $result ) || is_float( $result ) ) { 1186 /* 1187 * A number is formatted by the JSON encoder rather than cast to string, so that the 1188 * attribute value matches the number the client receives for this same reference. Casting 1189 * a float is locale-dependent before PHP 8.0, and rounds to `precision` rather than to the 1190 * encoder's `serialize_precision`. 1191 * 1192 * This closes the cases which differ in practice, not every one. A float written in 1193 * exponent notation still disagrees, since PHP encodes 1e25 as `1.0e+25` where JavaScript 1194 * renders it as `1e+25`, as does negative zero, and an integer above the range JavaScript 1195 * can represent exactly is rounded once it reaches the client. Casting diverged on all 1196 * three as well, so none is a regression. 1197 */ 1198 $encoded = wp_json_encode( $result ); 1199 if ( JSON_ERROR_INF_OR_NAN === json_last_error() ) { 1200 /* 1201 * The encoder only rejects INF and NAN, of which JSON can represent neither. When such 1202 * a value is stored in state, the store itself also fails to encode in its entirety, 1203 * and the client is sent an empty script tag in place of all of its state; only 1204 * removing the value from the state resolves that. A derived state closure returning 1205 * one never reaches the store, so there only the binding itself is affected. 1206 */ 1207 _doing_it_wrong( 1208 __METHOD__, 1209 sprintf( 1210 /* translators: %s: The attribute name. */ 1211 __( 'Attempted to bind a non-finite number to the "%s" attribute. Ensure the state/context property or the derived state closure resolves to a finite number or a string.' ), 1212 esc_html( $entry['suffix'] ) 1213 ), 1214 '7.1.0' 1215 ); 1216 $result = null; 1217 } else { 1218 $result = $encoded; 1219 } 1220 } 1221 } 1047 1222 1048 1223 if ( -
trunk/tests/phpunit/tests/interactivity-api/wpInteractivityAPI-wp-bind.php
r62070 r62944 46 46 * 47 47 * @param string $html The HTML that needs to be processed. 48 * @return array An array containing an instance of the WP_HTML_Tag_Processor and the processed HTML.49 */ 50 private function process_directives( $html ){48 * @return array{ 0: WP_HTML_Tag_Processor, 1: string } An array containing an instance of the WP_HTML_Tag_Processor and the processed HTML. 49 */ 50 private function process_directives( string $html ): array { 51 51 $new_html = $this->interactivity->process_directives( $html ); 52 52 $p = new WP_HTML_Tag_Processor( $new_html ); … … 92 92 list($p) = $this->process_directives( $html ); 93 93 $this->assertSame( '100', $p->get_attribute( 'width' ) ); 94 } 95 96 /** 97 * Tests that a float value is formatted as a string when set as an attribute 98 * via `data-wp-bind`. 99 * 100 * @ticket 65740 101 * 102 * @covers ::process_directives 103 */ 104 public function test_wp_bind_sets_float_value() { 105 $this->interactivity->state( 'myPlugin', array( 'ratio' => 1.5 ) ); 106 107 $html = '<div data-wp-bind--data-ratio="myPlugin::state.ratio">Text</div>'; 108 list($p, $new_html) = $this->process_directives( $html ); 109 $this->assertSame( '1.5', $p->get_attribute( 'data-ratio' ) ); 110 $this->assertSame( '<div data-ratio="1.5" data-wp-bind--data-ratio="myPlugin::state.ratio">Text</div>', $new_html ); 111 } 112 113 /** 114 * Tests that a float value is not formatted with the locale's decimal separator. 115 * 116 * Casting a float to string is locale-dependent before PHP 8.0, whereas the 117 * client receives the number from the JSON-encoded store, which never is. 118 * 119 * @ticket 65740 120 * 121 * @covers ::process_directives 122 */ 123 public function test_wp_bind_sets_float_value_independently_of_the_locale() { 124 $previous_locale = setlocale( LC_NUMERIC, '0' ); // Passing "0" queries the current setting without changing it. 125 if ( false === setlocale( LC_NUMERIC, 'de_DE.UTF-8', 'de_DE', 'de_DE@euro', 'German' ) ) { 126 $this->markTestSkipped( 'No locale with a comma decimal separator is available.' ); 127 } 128 129 try { 130 $this->interactivity->state( 'myPlugin', array( 'ratio' => 1.5 ) ); 131 132 $html = '<div data-wp-bind--data-ratio="myPlugin::state.ratio">Text</div>'; 133 list($p) = $this->process_directives( $html ); 134 $this->assertSame( '1.5', $p->get_attribute( 'data-ratio' ) ); 135 } finally { 136 setlocale( LC_NUMERIC, false === $previous_locale ? 'C' : $previous_locale ); 137 } 94 138 } 95 139 … … 445 489 $this->assertSame( 'some-id', $p->get_attribute( 'id' ) ); 446 490 } 491 492 /** 493 * Data provider for float values which JSON cannot represent. 494 * 495 * @return array<non-empty-string, array{ value: float }> Data provider. 496 */ 497 public function data_non_finite_values(): array { 498 return array( 499 'INF' => array( 'value' => INF ), 500 '-INF' => array( 'value' => -INF ), 501 'NAN' => array( 'value' => NAN ), 502 ); 503 } 504 505 /** 506 * Tests that `data-wp-bind` rejects INF and NAN. 507 * 508 * These are scalars, but a store holding one fails to encode in its 509 * entirety, so the client is sent no state at all rather than a value which 510 * merely disagrees with the server. 511 * 512 * @ticket 65740 513 * 514 * @covers ::process_directives 515 * 516 * @dataProvider data_non_finite_values 517 * 518 * @expectedIncorrectUsage WP_Interactivity_API::data_wp_bind_processor 519 * 520 * @param float $value Non-finite value to bind. 521 */ 522 public function test_wp_bind_rejects_non_finite_value( $value ) { 523 $this->interactivity->state( 'myPlugin', array( 'nonFinite' => $value ) ); 524 525 $html = '<div data-wp-bind--data-ratio="myPlugin::state.nonFinite">Text</div>'; 526 list($p) = $this->process_directives( $html ); 527 $this->assertNull( $p->get_attribute( 'data-ratio' ), 'Expected no attribute to have been set for a value JSON cannot represent.' ); 528 $this->assertSame( 529 array( 530 'WP_Interactivity_API::data_wp_bind_processor' => 'Attempted to bind a non-finite number to the "data-ratio" attribute. Ensure the state/context property or the derived state closure resolves to a finite number or a string. (This message was added in version 7.1.0.)', 531 ), 532 $this->caught_doing_it_wrong, 533 'Expected _doing_it_wrong() to have been called once with the non-finite value message.' 534 ); 535 } 536 537 /** 538 * Data provider for values a bound object may serialize to. 539 * 540 * @return array<non-empty-string, array{ value: mixed, expected: string }> Data provider. 541 */ 542 public function data_json_serializable_values(): array { 543 return array( 544 'string' => array( 545 'value' => 'serialized-form', 546 'expected' => 'serialized-form', 547 ), 548 'integer' => array( 549 'value' => 42, 550 'expected' => '42', 551 ), 552 'float' => array( 553 'value' => 1.5, 554 'expected' => '1.5', 555 ), 556 /* 557 * The JSON encoder keeps resolving a serializable object which serializes to another one, so the 558 * value bound on the server has to follow it all the way down to match what the client receives. 559 */ 560 'nested' => array( 561 'value' => $this->get_json_serializable( 'serialized-form' ), 562 'expected' => 'serialized-form', 563 ), 564 ); 565 } 566 567 /** 568 * Tests that an object is bound as whatever it serializes to for the client. 569 * 570 * @ticket 65740 571 * 572 * @covers ::process_directives 573 * 574 * @dataProvider data_json_serializable_values 575 * 576 * @param mixed $value Value the object serializes to. 577 * @param string $expected Expected attribute value. 578 */ 579 public function test_wp_bind_sets_json_serializable_value( $value, string $expected ) { 580 $this->interactivity->state( 'myPlugin', array( 'serializable' => $this->get_json_serializable( $value ) ) ); 581 582 $html = '<div data-wp-bind--id="myPlugin::state.serializable">Text</div>'; 583 list($p) = $this->process_directives( $html ); 584 $this->assertSame( $expected, $p->get_attribute( 'id' ) ); 585 } 586 587 /** 588 * Tests that the bound attribute value matches what the client is sent. 589 * 590 * This is what makes serializable objects safe to bind: the value rendered 591 * into the attribute is the same one the client store is hydrated with, so 592 * evaluating the directive again in the browser is a no-op. 593 * 594 * @ticket 65740 595 * 596 * @covers ::process_directives 597 */ 598 public function test_wp_bind_json_serializable_value_matches_the_client_store() { 599 $this->interactivity->state( 'myPlugin', array( 'serializable' => $this->get_json_serializable( 'serialized-form' ) ) ); 600 601 $html = '<div data-wp-bind--id="myPlugin::state.serializable">Text</div>'; 602 list($p) = $this->process_directives( $html ); 603 604 $data = $this->interactivity->filter_script_module_interactivity_data( array() ); 605 $encoded = wp_json_encode( $data['state'] ); 606 $this->assertIsString( $encoded, 'Expected the client state to be encodable as JSON.' ); 607 608 $this->assertSame( 'serialized-form', $p->get_attribute( 'id' ) ); 609 $this->assertStringContainsString( 610 '"serializable":"serialized-form"', 611 $encoded, 612 'Expected the rendered attribute value to match the value sent to the client.' 613 ); 614 } 615 616 /** 617 * Tests that a bound object which cannot be serialized does not abort the render. 618 * 619 * `JsonSerializable::jsonSerialize()` is arbitrary code, so resolving an object 620 * through the JSON encoder can throw. A binding must not be able to take down the 621 * page, which is the whole point of checking the value at all. An exception 622 * escaping the directive processor would also leave the context and namespace 623 * stacks unrestored, breaking every later `process_directives()` call on the same 624 * instance. 625 * 626 * @ticket 65740 627 * 628 * @covers ::process_directives 629 * 630 * @expectedIncorrectUsage WP_Interactivity_API::data_wp_bind_processor 631 */ 632 public function test_wp_bind_rejects_object_which_fails_to_serialize() { 633 $unserializable = new class() implements JsonSerializable { 634 /** 635 * Fails to produce a value for the client. 636 * 637 * @return mixed Never returns. 638 * @throws RuntimeException Always. 639 */ 640 #[\ReturnTypeWillChange] 641 public function jsonSerialize() { 642 throw new RuntimeException( 'This object cannot be serialized.' ); 643 } 644 }; 645 646 $this->interactivity->state( 647 'myPlugin', 648 array( 649 'unserializable' => $unserializable, 650 'id' => 'some-id', 651 ) 652 ); 653 654 $html = '<div data-wp-bind--id="myPlugin::state.unserializable">Text</div>'; 655 list($p) = $this->process_directives( $html ); 656 $this->assertNull( $p->get_attribute( 'id' ), 'Expected no attribute to have been set for an object which cannot be serialized.' ); 657 $this->assertSame( 658 array( 659 '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, number, or boolean. (This message was added in version 7.1.0.)', 660 ), 661 $this->caught_doing_it_wrong, 662 'Expected _doing_it_wrong() to have been called once with the non-scalar value message.' 663 ); 664 665 // The stacks are restored for the next render only if no exception escaped. 666 $html = '<div data-wp-bind--id="myPlugin::state.id">Text</div>'; 667 list($p) = $this->process_directives( $html ); 668 $this->assertSame( 'some-id', $p->get_attribute( 'id' ), 'Expected a later render on the same instance to be unaffected.' ); 669 } 670 671 /** 672 * Tests that an object serializing to a value JSON cannot represent is rejected. 673 * 674 * The encoding does not fail here the way it does for a bare INF. When 675 * `json_encode()` rejects the value, `wp_json_encode()` retries with a plain 676 * object rebuilt from the public properties, which discards `jsonSerialize()` 677 * entirely and encodes to `{}`. The object therefore resolves to something 678 * non-scalar and is reported as such, rather than with the non-finite message. 679 * 680 * The store is rebuilt the same way, so the client is sent `{}` for this 681 * reference. The two still agree that there is no usable value here. 682 * 683 * @ticket 65740 684 * 685 * @covers ::process_directives 686 * 687 * @expectedIncorrectUsage WP_Interactivity_API::data_wp_bind_processor 688 */ 689 public function test_wp_bind_rejects_object_serializing_to_a_non_finite_value() { 690 $this->interactivity->state( 'myPlugin', array( 'nonFinite' => $this->get_json_serializable( INF ) ) ); 691 692 $html = '<div data-wp-bind--id="myPlugin::state.nonFinite">Text</div>'; 693 list($p) = $this->process_directives( $html ); 694 $this->assertNull( $p->get_attribute( 'id' ), 'Expected no attribute to have been set.' ); 695 $this->assertSame( 696 array( 697 '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, number, or boolean. (This message was added in version 7.1.0.)', 698 ), 699 $this->caught_doing_it_wrong, 700 'Expected the non-scalar message, since the object resolves to an empty object rather than failing to encode.' 701 ); 702 703 $data = $this->interactivity->filter_script_module_interactivity_data( array() ); 704 $encoded = wp_json_encode( $data['state'] ); 705 $this->assertIsString( $encoded, 'Expected the client state to still be encodable as JSON.' ); 706 $this->assertStringContainsString( 707 '"nonFinite":{}', 708 $encoded, 709 'Expected the client to be sent the same empty object the server resolved.' 710 ); 711 } 712 713 /** 714 * Tests that an object serializing to null removes the attribute quietly. 715 * 716 * The object is resolved before the null check, so it reaches it as the null 717 * the client will receive, and is treated the same as a null value would be. 718 * There is nothing to report: null is a value the client can be sent. 719 * 720 * @ticket 65740 721 * 722 * @covers ::process_directives 723 */ 724 public function test_wp_bind_removes_attribute_for_object_serializing_to_null() { 725 $this->interactivity->state( 'myPlugin', array( 'nothing' => $this->get_json_serializable( null ) ) ); 726 727 $html = '<div id="other-id" data-wp-bind--id="myPlugin::state.nothing">Text</div>'; 728 list($p, $new_html) = $this->process_directives( $html ); 729 $this->assertNull( $p->get_attribute( 'id' ), 'Expected the pre-existing attribute to have been removed.' ); 730 $this->assertEqualHTML( '<div data-wp-bind--id="myPlugin::state.nothing">Text</div>', $new_html ); 731 $this->assertSame( 732 array(), 733 $this->caught_doing_it_wrong, 734 'Expected an object serializing to null to be treated as a null value, without reporting a usage error.' 735 ); 736 } 737 738 /** 739 * Tests that an object serializing to a boolean keeps the boolean attribute 740 * semantics of the value it serializes to. 741 * 742 * Resolving the object first means the checks below it do not have to know an 743 * object was ever involved, so the existing handling composes. This asserts 744 * that it does. 745 * 746 * @ticket 65740 747 * 748 * @covers ::process_directives 749 */ 750 public function test_wp_bind_applies_boolean_semantics_to_object_serializing_to_a_boolean() { 751 $this->interactivity->state( 752 'myPlugin', 753 array( 754 'yes' => $this->get_json_serializable( true ), 755 'no' => $this->get_json_serializable( false ), 756 ) 757 ); 758 759 // True sets a bare boolean attribute. 760 $html = '<div data-wp-bind--hidden="myPlugin::state.yes">Text</div>'; 761 list($p, $new_html) = $this->process_directives( $html ); 762 $this->assertTrue( $p->get_attribute( 'hidden' ) ); 763 $this->assertSame( '<div hidden data-wp-bind--hidden="myPlugin::state.yes">Text</div>', $new_html ); 764 765 // False removes it. 766 $html = '<div hidden data-wp-bind--hidden="myPlugin::state.no">Text</div>'; 767 list($p, $new_html) = $this->process_directives( $html ); 768 $this->assertNull( $p->get_attribute( 'hidden' ) ); 769 $this->assertEqualHTML( '<div data-wp-bind--hidden="myPlugin::state.no">Text</div>', $new_html ); 770 771 // On a `data-` or `aria-` attribute it becomes the string Preact would write. 772 $html = '<div data-wp-bind--data-open="myPlugin::state.yes">Text</div>'; 773 list($p) = $this->process_directives( $html ); 774 $this->assertSame( 'true', $p->get_attribute( 'data-open' ) ); 775 776 $html = '<div data-wp-bind--aria-hidden="myPlugin::state.no">Text</div>'; 777 list($p) = $this->process_directives( $html ); 778 $this->assertSame( 'false', $p->get_attribute( 'aria-hidden' ) ); 779 } 780 781 /** 782 * Tests that a bound number is written the same way the client store writes it. 783 * 784 * This is the invariant the number formatting exists for. A cast would round to 785 * `precision` where the store uses `serialize_precision`, so both are rendered 786 * by the same encoder instead of being compared after the fact. 787 * 788 * @ticket 65740 789 * 790 * @covers ::process_directives 791 */ 792 public function test_wp_bind_number_value_matches_the_client_store() { 793 $this->interactivity->state( 'myPlugin', array( 'ratio' => 1 / 3 ) ); 794 795 $html = '<div data-wp-bind--data-ratio="myPlugin::state.ratio">Text</div>'; 796 list($p) = $this->process_directives( $html ); 797 798 $data = $this->interactivity->filter_script_module_interactivity_data( array() ); 799 $encoded = wp_json_encode( $data['state'] ); 800 $this->assertIsString( $encoded, 'Expected the client state to be encodable as JSON.' ); 801 802 $expected = wp_json_encode( 1 / 3 ); 803 $this->assertSame( $expected, $p->get_attribute( 'data-ratio' ) ); 804 $this->assertStringContainsString( 805 '"ratio":' . $expected, 806 $encoded, 807 'Expected the rendered attribute value to match the number sent to the client.' 808 ); 809 } 810 811 /** 812 * Creates an object which serializes to the given value for the client. 813 * 814 * @param mixed $value Value the object serializes to. 815 * @return JsonSerializable Object serializing to `$value`. 816 */ 817 private function get_json_serializable( $value ): JsonSerializable { 818 return new class( $value ) implements JsonSerializable { 819 /** 820 * Value the object serializes to. 821 * 822 * @var mixed 823 */ 824 private $value; 825 826 /** 827 * Constructor. 828 * 829 * @param mixed $value Value the object serializes to. 830 */ 831 public function __construct( $value ) { 832 $this->value = $value; 833 } 834 835 /** 836 * Returns the value for JSON serialization. 837 * 838 * @return mixed Value the client receives. 839 */ 840 #[\ReturnTypeWillChange] 841 public function jsonSerialize() { 842 return $this->value; 843 } 844 }; 845 } 846 847 /** 848 * Data provider for values which cannot be stored in an attribute value. 849 * 850 * WP_HTML_Tag_Processor::set_attribute() escapes an ordinary attribute with 851 * strtr() and one of the URI attributes listed by wp_kses_uri_attributes() 852 * with esc_url(). Neither should be reached with a non-scalar value, so each 853 * value is paired with one attribute at a time: a regression in one of those 854 * paths then cannot be masked by the other failing first. 855 * 856 * @return array<non-empty-string, array{ value: mixed, tag_name: non-empty-string, attribute: non-empty-string, existing_value: non-empty-string }> Data provider. 857 */ 858 public function data_non_scalar_values(): array { 859 $values = array( 860 'list' => array( 'a', 'b' ), 861 'associative array' => array( 'a' => 'b' ), 862 'empty array' => array(), 863 'object' => new stdClass(), 864 'stringable object' => new class() { 865 /** 866 * Returns the string representation. 867 * 868 * @return string String representation. 869 */ 870 public function __toString() { 871 return 'stringified'; 872 } 873 }, 874 'stringable object serializing to an array' => new class() implements JsonSerializable { 875 /** 876 * Returns the string representation. 877 * 878 * @return string String representation. 879 */ 880 public function __toString() { 881 return 'stringified'; 882 } 883 884 /** 885 * Returns the value for JSON serialization. 886 * 887 * @return array<string, string> Value the client receives. 888 */ 889 #[\ReturnTypeWillChange] 890 public function jsonSerialize() { 891 return array( 'not' => 'the string representation' ); 892 } 893 }, 894 'object serializing to an array' => $this->get_json_serializable( array( 'a', 'b' ) ), 895 ); 896 897 $attributes = array( 898 'ordinary attribute' => array( 899 'tag_name' => 'div', 900 'attribute' => 'id', 901 'existing_value' => 'other-id', 902 ), 903 'URI attribute' => array( 904 'tag_name' => 'a', 905 'attribute' => 'href', 906 'existing_value' => 'https://example.com/', 907 ), 908 ); 909 910 $data = array(); 911 foreach ( $values as $value_label => $value ) { 912 foreach ( $attributes as $attribute_label => $attribute ) { 913 $data[ "$value_label in $attribute_label" ] = array( 'value' => $value ) + $attribute; 914 } 915 } 916 return $data; 917 } 918 919 /** 920 * Tests that `data-wp-bind` rejects non-scalar values instead of passing 921 * them along to WP_HTML_Tag_Processor::set_attribute(). 922 * 923 * @ticket 65740 924 * 925 * @covers ::process_directives 926 * 927 * @dataProvider data_non_scalar_values 928 * 929 * @expectedIncorrectUsage WP_Interactivity_API::data_wp_bind_processor 930 * 931 * @param mixed $value Non-scalar value to bind. 932 * @param string $tag_name Tag name to bind the value on. 933 * @param string $attribute Attribute name to bind the value to. 934 * @param string $existing_value Pre-existing value for the bound attribute. Unused, as the attribute is absent here. 935 */ 936 public function test_wp_bind_rejects_non_scalar_value( $value, string $tag_name, string $attribute, string $existing_value ) { 937 unset( $existing_value ); // The bound attribute is absent here, so there is no pre-existing value to remove. 938 939 $this->interactivity->state( 'myPlugin', array( 'nonScalar' => $value ) ); 940 941 $html = sprintf( '<%1$s data-wp-bind--%2$s="myPlugin::state.nonScalar">Text</%1$s>', $tag_name, $attribute ); 942 list($p, $new_html) = $this->process_directives( $html ); 943 $this->assertNull( $p->get_attribute( $attribute ), "Expected no $attribute attribute to have been set for a non-scalar value." ); 944 $this->assertSame( $html, $new_html, 'Expected the markup to be left unchanged.' ); 945 $this->assertSame( 946 array( 947 'WP_Interactivity_API::data_wp_bind_processor' => sprintf( 948 '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, number, or boolean. (This message was added in version 7.1.0.)', 949 $attribute 950 ), 951 ), 952 $this->caught_doing_it_wrong, 953 'Expected _doing_it_wrong() to have been called once with the non-scalar value message.' 954 ); 955 } 956 957 /** 958 * Tests that `data-wp-bind` removes a pre-existing attribute when the 959 * evaluated value is non-scalar. 960 * 961 * @ticket 65740 962 * 963 * @covers ::process_directives 964 * 965 * @dataProvider data_non_scalar_values 966 * 967 * @expectedIncorrectUsage WP_Interactivity_API::data_wp_bind_processor 968 * 969 * @param mixed $value Non-scalar value to bind. 970 * @param string $tag_name Tag name to bind the value on. 971 * @param string $attribute Attribute name to bind the value to. 972 * @param string $existing_value Pre-existing value for the bound attribute. 973 */ 974 public function test_wp_bind_removes_existing_attribute_for_non_scalar_value( $value, string $tag_name, string $attribute, string $existing_value ) { 975 $this->interactivity->state( 'myPlugin', array( 'nonScalar' => $value ) ); 976 977 $html = sprintf( '<%1$s %2$s="%3$s" data-wp-bind--%2$s="myPlugin::state.nonScalar">Text</%1$s>', $tag_name, $attribute, $existing_value ); 978 list($p, $new_html) = $this->process_directives( $html ); 979 $this->assertNull( $p->get_attribute( $attribute ), "Expected the pre-existing $attribute attribute to have been removed." ); 980 $this->assertEqualHTML( sprintf( '<%1$s data-wp-bind--%2$s="myPlugin::state.nonScalar">Text</%1$s>', $tag_name, $attribute ), $new_html ); 981 $this->assertSame( 982 array( 983 'WP_Interactivity_API::data_wp_bind_processor' => sprintf( 984 '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, number, or boolean. (This message was added in version 7.1.0.)', 985 $attribute 986 ), 987 ), 988 $this->caught_doing_it_wrong, 989 'Expected _doing_it_wrong() to have been called once with the non-scalar value message.' 990 ); 991 } 447 992 } -
trunk/tests/phpunit/tests/interactivity-api/wpInteractivityAPI.php
r61197 r62944 846 846 * 847 847 * @ticket 64106 848 * @ticket 64898 848 849 * 849 850 * @covers ::parse_directive_name … … 891 892 $this->assertNull( $result['unique_id'] ); 892 893 894 /* 895 * Should keep a "0" prefix, suffix, and unique ID rather than discarding it as empty. The 896 * client's `parseDirectiveName` normalizes with `|| null`, which discards only the empty 897 * string, because a non-empty string such as "0" is truthy in JavaScript. Using empty() 898 * here would discard "0" and diverge from the client. 899 */ 900 $this->assertSame( 901 array( 902 'prefix' => 'test', 903 'suffix' => null, 904 'unique_id' => '0', 905 ), 906 $parse_directive_name->invoke( $this->interactivity, 'data-wp-test---0' ) 907 ); 908 $this->assertSame( 909 array( 910 'prefix' => 'test', 911 'suffix' => '0', 912 'unique_id' => null, 913 ), 914 $parse_directive_name->invoke( $this->interactivity, 'data-wp-test--0' ) 915 ); 916 $this->assertSame( 917 array( 918 'prefix' => 'test', 919 'suffix' => '0', 920 'unique_id' => 'unique-id', 921 ), 922 $parse_directive_name->invoke( $this->interactivity, 'data-wp-test--0---unique-id' ) 923 ); 924 $this->assertSame( 925 array( 926 'prefix' => 'test', 927 'suffix' => 'suffix', 928 'unique_id' => '0', 929 ), 930 $parse_directive_name->invoke( $this->interactivity, 'data-wp-test--suffix---0' ) 931 ); 932 $this->assertSame( 933 array( 934 'prefix' => '0', 935 'suffix' => 'suffix', 936 'unique_id' => null, 937 ), 938 $parse_directive_name->invoke( $this->interactivity, 'data-wp-0--suffix' ) 939 ); 940 893 941 // Should handle only dashes (4 or more dashes). 894 942 $result = $parse_directive_name->invoke( $this->interactivity, 'data-wp-test----' ); … … 920 968 $this->assertNull( $result['suffix'] ); 921 969 $this->assertSame( 'unique-id--wrong-suffix', $result['unique_id'] ); 970 971 // Should reject a name containing characters a directive name cannot contain. 972 $this->assertNull( $parse_directive_name->invoke( $this->interactivity, 'data-wp-test.suffix' ) ); 973 974 /* 975 * Should reject a name which is nothing but the prefix, rather than returning an empty 976 * prefix. The client's `parseDirectiveName` returns `{ prefix: '' }` here instead, but 977 * neither an empty prefix nor null matches a registered directive, so the outcome is the 978 * same on both sides: the attribute is ignored. 979 */ 980 $this->assertNull( $parse_directive_name->invoke( $this->interactivity, 'data-wp-' ) ); 981 982 /* 983 * Should reject a name whose prefix would begin with a hyphen, which the directive syntax 984 * does not allow. Neither reading of such a name is meaningful: treating the hyphens as a 985 * suffix separator leaves the prefix empty, and treating them as part of the prefix names 986 * a directive which cannot be registered. The client's `parseDirectiveName` still splits 987 * `data-wp---foo` into `{ prefix: '', suffix: 'foo' }`, but as with an empty name, no 988 * result here matches a registered directive, so the attribute is ignored on both sides 989 * either way. 990 */ 991 $this->assertNull( $parse_directive_name->invoke( $this->interactivity, 'data-wp--bind' ) ); 992 $this->assertNull( $parse_directive_name->invoke( $this->interactivity, 'data-wp---foo' ) ); 993 $this->assertNull( $parse_directive_name->invoke( $this->interactivity, 'data-wp----foo' ) ); 994 995 /* 996 * Should still accept a suffix which begins with hyphens, since only the prefix is 997 * constrained. Here the prefix is "style" and the suffix is "--var". 998 */ 999 $this->assertSame( 1000 array( 1001 'prefix' => 'style', 1002 'suffix' => '--var', 1003 'unique_id' => null, 1004 ), 1005 $parse_directive_name->invoke( $this->interactivity, 'data-wp-style----var' ) 1006 ); 922 1007 } 923 1008 … … 926 1011 * 927 1012 * @ticket 64106 1013 * @ticket 64898 928 1014 * 929 1015 * @covers ::get_directive_entries … … 1139 1225 $results 1140 1226 ) 1227 ); 1228 1229 /* 1230 * Should skip an attribute whose directive name cannot be parsed. Such a name is still 1231 * matched by the prefix search, so it reaches here and has to be filtered out rather than 1232 * destructured. 1233 */ 1234 $html = '<div data-wp-test.suffix="skipped" data-wp-test--valid="kept"></div>'; 1235 $p = new WP_Interactivity_API_Directives_Processor( $html ); 1236 $p->next_tag(); 1237 $this->assertSame( 1238 array( 1239 array( 1240 'namespace' => 'myPlugin', 1241 'value' => 'kept', 1242 'suffix' => 'valid', 1243 'unique_id' => null, 1244 ), 1245 ), 1246 $get_directive_entries->invoke( $this->interactivity, $p, 'test' ) 1141 1247 ); 1142 1248 }
Note:
See TracChangeset
for help on using the changeset viewer.
![(please configure the [header_logo] section in trac.ini)](/chrome/site/your_project_logo.png)