Make WordPress Core

Changeset 62944


Ignore:
Timestamp:
07/30/2026 05:04:25 PM (8 days ago)
Author:
westonruter
Message:

Interactivity API: Fix a fatal error when binding a non-scalar value.

The WP_Interactivity_API::data_wp_bind_processor() method passed the evaluated reference straight to WP_HTML_Tag_Processor::set_attribute(), which is typed string|bool, so a non-scalar value raised a TypeError inside the escaping functions: in strtr() for an ordinary attribute, and in esc_url() for a URI one. This is reachable from post content alone, with no plugin code, because data-wp-context can supply an array inline. Such a value is now rejected with _doing_it_wrong() and the attribute is left unset, as a null value already did, rather than taking down the whole page render.

An object is resolved by round-tripping it through wp_json_encode() and json_decode(), which is how the client store itself is built, so the two cannot disagree about what it serializes to. __toString() is never consulted: an object whose string form differed from its JSON form used to render a value the client immediately overwrote, and one implementing only JsonSerializable fataled even though the store was already correct. Numbers are formatted by that same encoder rather than cast, which avoids both the locale-dependent decimal separator a float cast produces before PHP 8.0 and the rounding to precision where the store uses serialize_precision. INF and NAN are scalars but JSON can represent neither, and there the cost is the page's entire state rather than one attribute, so they are rejected with a message of their own.

Parsing of directive attributes is hardened. The code touching the ::data_wp_bind_processor() method is brought to PHPStan level 10 without ignores, including the use of more specific types where available.

Developed in https://github.com/WordPress/wordpress-develop/pull/12725.
Follow-up to r57563, r61020, r62070.

Props westonruter, dmsnell, luisherranz, darerodz.
See #64898.
Fixes #65740.

Location:
trunk
Files:
4 edited

Legend:

Unmodified
Added
Removed
  • trunk/src/wp-includes/html-api/class-wp-html-tag-processor.php

    r62715 r62944  
    719719         * @since 6.2.0
    720720         * @var WP_HTML_Attribute_Token[]
     721         * @phpstan-var array<non-empty-string, WP_HTML_Attribute_Token>
    721722         */
    722723        private $attributes = array();
     
    29582959         * @param string $prefix Prefix of requested attribute names.
    29592960         * @return array|null List of attribute names, or `null` when no tag opener is matched.
     2961         * @phpstan-return list<non-empty-string>|null
    29602962         */
    29612963        public function get_attribute_names_with_prefix( $prefix ): ?array {
  • trunk/src/wp-includes/interactivity-api/class-wp-interactivity-api.php

    r62503 r62944  
    1818         *
    1919         * @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(
    2333                'data-wp-interactive'   => 'data_wp_interactive_processor',
    2434                'data-wp-router-region' => 'data_wp_router_region_processor',
     
    100110         * This is only available during directive processing, otherwise it is `null`.
    101111         *
     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         *
    102120         * @since 6.6.0
    103          * @var array<string>|null
     121         * @var array<string|false>|null
     122         * @phpstan-var list<string|false>|null
    104123         */
    105124        private $namespace_stack = null;
     
    753772        /**
    754773         * 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.
    756775         * - Suffix: An optional suffix used during directive processing, extracted after the first double hyphen "--".
    757776         * - Unique ID: An optional unique identifier, extracted after the first triple hyphen "---".
    758777         *
    759778         * 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.
    763787         *
    764788         * @since 6.9.0
    765789         *
    766790         * @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
    768797         */
    769798        private function parse_directive_name( string $directive_name ): ?array {
    770799                // 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 ) ) {
    775804                        return null;
    776805                }
    777806
    778                 // Find the first occurrence of '--' to separate the prefix
     807                // Find the first occurrence of '--' to separate the prefix.
    779808                $suffix_index = strpos( $name, '--' );
    780809
     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 '---'.)
    781822                if ( false === $suffix_index ) {
    782823                        return array(
     
    791832
    792833                // 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 );
    794836                        return array(
    795837                                'prefix'    => $prefix,
    796838                                'suffix'    => null,
    797                                 'unique_id' => '---' !== $remaining ? substr( $remaining, 3 ) : null,
     839                                'unique_id' => '' === $unique_id ? null : $unique_id,
    798840                        );
    799841                }
    800842
    801843                // Otherwise, remove the first two dashes for a potential suffix
    802                 $suffix = substr( $remaining, 2 );
     844                $suffix = (string) substr( $remaining, 2 );
    803845
    804846                // Look for '---' in the suffix for a unique_id
     
    806848
    807849                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 );
    810852                        return array(
    811853                                '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,
    814856                        );
    815857                }
     
    817859                return array(
    818860                        'prefix'    => $prefix,
    819                         'suffix'    => empty( $suffix ) ? null : $suffix,
     861                        'suffix'    => '' === $suffix ? null : $suffix,
    820862                        'unique_id' => null,
    821863                );
     
    847889         * @return array An array containing the namespace in the first item and the JSON, the reference path, or null on the
    848890         *               second item.
     891         * @phpstan-return array{ 0: string|null, 1: mixed }
    849892         */
    850893        private function extract_directive_value( $directive_value, $default_namespace = null ): array {
     
    879922         * @param string                                    $prefix The directive prefix to filter by.
    880923         * @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 {
    883932                $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();
    885938                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;
    887945                        // Ensure it is the desired directive.
    888946                        if ( $prefix !== $attr_prefix ) {
    889947                                continue;
    890948                        }
    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 );
    892964                        $entries[]                 = array(
    893965                                'namespace' => $namespace,
     
    10031075                        }
    10041076
     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
    10051086                        $context = array_replace_recursive(
    10061087                                $context,
     
    10181099         *
    10191100         * @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 {
    10251109                if ( 'enter' === $mode ) {
    10261110                        $entries = $this->get_directive_entries( $p, 'bind' );
    10271111                        foreach ( $entries as $entry ) {
    10281112                                if ( empty( $entry['suffix'] ) || null !== $entry['unique_id'] ) {
    1029                                                 continue;
     1113                                        continue;
    10301114                                }
    10311115
     
    10451129
    10461130                                $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                                }
    10471222
    10481223                                if (
  • trunk/tests/phpunit/tests/interactivity-api/wpInteractivityAPI-wp-bind.php

    r62070 r62944  
    4646         *
    4747         * @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 {
    5151                $new_html = $this->interactivity->process_directives( $html );
    5252                $p        = new WP_HTML_Tag_Processor( $new_html );
     
    9292                list($p) = $this->process_directives( $html );
    9393                $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                }
    94138        }
    95139
     
    445489                $this->assertSame( 'some-id', $p->get_attribute( 'id' ) );
    446490        }
     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        }
    447992}
  • trunk/tests/phpunit/tests/interactivity-api/wpInteractivityAPI.php

    r61197 r62944  
    846846         *
    847847         * @ticket 64106
     848         * @ticket 64898
    848849         *
    849850         * @covers ::parse_directive_name
     
    891892                $this->assertNull( $result['unique_id'] );
    892893
     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
    893941                // Should handle only dashes (4 or more dashes).
    894942                $result = $parse_directive_name->invoke( $this->interactivity, 'data-wp-test----' );
     
    920968                $this->assertNull( $result['suffix'] );
    921969                $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                );
    9221007        }
    9231008
     
    9261011         *
    9271012         * @ticket 64106
     1013         * @ticket 64898
    9281014         *
    9291015         * @covers ::get_directive_entries
     
    11391225                                $results
    11401226                        )
     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' )
    11411247                );
    11421248        }
Note: See TracChangeset for help on using the changeset viewer.