Make WordPress Core

Changeset 58729


Ignore:
Timestamp:
07/16/2024 09:42:38 AM (2 years ago)
Author:
Bernhard Reiter
Message:

Interactivity API: Use Script Modules filter for store & config data.

A dedicated API exists for passing data to Script Modules implemented in changeset [58579].
Use this Core API instead of a custom implementation for Interactivity API to pass data to the client.

Developed in ​https://github.com/WordPress/wordpress-develop/pull/6683.

Props jonsurrell, gziolo, luisherranz, cbravobernal.
Fixes #61512.

Location:
trunk
Files:
2 edited

Legend:

Unmodified
Added
Removed
  • trunk/src/wp-includes/interactivity-api/class-wp-interactivity-api.php

    r58327 r58729  
    193193         *
    194194         * @since 6.5.0
     195         *
     196         * @deprecated 6.7.0 Client data passing is handled by the {@see "script_module_data_{$module_id}"} filter.
    195197         */
    196198        public function print_client_interactivity_data() {
     199                _deprecated_function( __METHOD__, '6.7.0' );
     200        }
     201
     202        /**
     203         * Set client-side interactivity data.
     204         *
     205         * Once in the browser, the state will be parsed and used to hydrate the client-side
     206         * interactivity stores and the configuration will be available using a `getConfig` utility.
     207         *
     208         * @since 6.7.0
     209         *
     210         * @param array $data Data to filter.
     211         * @return array Data for the Interactivity API script module.
     212         */
     213        public function filter_script_module_interactivity_data( array $data ): array {
    197214                if ( empty( $this->state_data ) && empty( $this->config_data ) ) {
    198                         return;
    199                 }
    200 
    201                 $interactivity_data = array();
     215                        return $data;
     216                }
    202217
    203218                $config = array();
    … …  
    208223                }
    209224                if ( ! empty( $config ) ) {
    210                         $interactivity_data['config'] = $config;
     225                        $data['config'] = $config;
    211226                }
    212227
    … …  
    218233                }
    219234                if ( ! empty( $state ) ) {
    220                         $interactivity_data['state'] = $state;
    221                 }
    222 
    223                 if ( ! empty( $interactivity_data ) ) {
    224                         /*
    225                          * This data will be printed as JSON inside a script tag like this:
    226                          *   <script type="application/json"></script>
    227                          *
    228                          * A script tag must be closed by a sequence beginning with `</`. It's impossible to
    229                          * close a script tag without using `<`. We ensure that `<` is escaped and `/` can
    230                          * remain unescaped, so `</script>` will be printed as `\u003C/script\u00E3`.
    231                          *
    232                          *   - JSON_HEX_TAG: All < and > are converted to \u003C and \u003E.
    233                          *   - JSON_UNESCAPED_SLASHES: Don't escape /.
    234                          *
    235                          * If the page will use UTF-8 encoding, it's safe to print unescaped unicode:
    236                          *
    237                          *   - JSON_UNESCAPED_UNICODE: Encode multibyte Unicode characters literally (instead of as `\uXXXX`).
    238                          *   - JSON_UNESCAPED_LINE_TERMINATORS: The line terminators are kept unescaped when
    239                          *     JSON_UNESCAPED_UNICODE is supplied. It uses the same behaviour as it was
    240                          *     before PHP 7.1 without this constant. Available as of PHP 7.1.0.
    241                          *
    242                          * The JSON specification requires encoding in UTF-8, so if the generated HTML page
    243                          * is not encoded in UTF-8 then it's not safe to include those literals. They must
    244                          * be escaped to avoid encoding issues.
    245                          *
    246                          * @see https://www.rfc-editor.org/rfc/rfc8259.html for details on encoding requirements.
    247                          * @see https://www.php.net/manual/en/json.constants.php for details on these constants.
    248                          * @see https://html.spec.whatwg.org/#script-data-state for details on script tag parsing.
    249                          */
    250                         $json_encode_flags = JSON_HEX_TAG | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_LINE_TERMINATORS;
    251                         if ( ! is_utf8_charset() ) {
    252                                 $json_encode_flags = JSON_HEX_TAG | JSON_UNESCAPED_SLASHES;
    253                         }
    254 
    255                         wp_print_inline_script_tag(
    256                                 wp_json_encode(
    257                                         $interactivity_data,
    258                                         $json_encode_flags
    259                                 ),
    260                                 array(
    261                                         'type' => 'application/json',
    262                                         'id'   => 'wp-interactivity-data',
    263                                 )
    264                         );
    265                 }
     235                        $data['state'] = $state;
     236                }
     237
     238                return $data;
    266239        }
    267240
    … …  
    330303         *
    331304         * @since 6.5.0
     305         * @since 6.7.0 Use the {@see "script_module_data_{$module_id}"} filter to pass client-side data.
    332306         */
    333307        public function add_hooks() {
    334308                add_action( 'wp_enqueue_scripts', array( $this, 'register_script_modules' ) );
    335                 add_action( 'wp_footer', array( $this, 'print_client_interactivity_data' ) );
    336 
    337309                add_action( 'admin_enqueue_scripts', array( $this, 'register_script_modules' ) );
    338                 add_action( 'admin_print_footer_scripts', array( $this, 'print_client_interactivity_data' ) );
     310
     311                add_filter( 'script_module_data_@wordpress/interactivity', array( $this, 'filter_script_module_interactivity_data' ) );
    339312        }
    340313
  • trunk/tests/phpunit/tests/interactivity-api/wpInteractivityAPI.php

    r58594 r58729  
    185185
    186186        /**
    187          * Invokes the private `print_client_interactivity` method of
    188          * WP_Interactivity_API class.
    189          *
    190          * @return array|null The content of the JSON object printed on the client-side or null if nothings was printed.
    191          */
    192         private function print_client_interactivity_data() {
    193                 $interactivity_data_markup = get_echo( array( $this->interactivity, 'print_client_interactivity_data' ) );
    194                 preg_match( '/<script type="application\/json" id="wp-interactivity-data">.*?(\{.*\}).*?<\/script>/s', $interactivity_data_markup, $interactivity_data_string );
    195                 return isset( $interactivity_data_string[1] ) ? json_decode( $interactivity_data_string[1], true ) : null;
    196         }
    197 
    198         /**
    199          * Tests that the initial state and config are correctly printed on the
    200          * client-side.
    201          *
    202          * @ticket 60356
    203          *
    204          * @covers ::state
    205          * @covers ::config
    206          * @covers ::print_client_interactivity_data
    207          */
    208         public function test_state_and_config_is_correctly_printed() {
    209                 $this->interactivity->state( 'myPlugin', array( 'a' => 1 ) );
    210                 $this->interactivity->state( 'otherPlugin', array( 'b' => 2 ) );
    211                 $this->interactivity->config( 'myPlugin', array( 'a' => 1 ) );
    212                 $this->interactivity->config( 'otherPlugin', array( 'b' => 2 ) );
    213 
    214                 $result = $this->print_client_interactivity_data();
    215 
    216                 $data = array(
    217                         'myPlugin'    => array( 'a' => 1 ),
    218                         'otherPlugin' => array( 'b' => 2 ),
    219                 );
    220 
    221                 $this->assertSame(
    222                         array(
    223                                 'config' => $data,
    224                                 'state'  => $data,
    225                         ),
    226                         $result
    227                 );
    228         }
    229 
    230         /**
    231187         * Tests that the wp-interactivity-data script is not printed if both state
    232188         * and config are empty.
    233189         *
    234190         * @ticket 60356
     191         * @ticket 61512
     192         */
     193        public function test_state_and_config_dont_print_when_empty() {
     194                $filter = $this->get_script_data_filter_result();
     195
     196                $this->assertSame( array(), $filter->get_args()[0][0] );
     197        }
     198
     199        /**
     200         * Test that the print_client_interactivity_data is deprecated and produces no output.
     201         *
     202         * @ticket 60356
     203         * @ticket 61512
    235204         *
    236205         * @covers ::print_client_interactivity_data
    237          */
    238         public function test_state_and_config_dont_print_when_empty() {
    239                 $result = $this->print_client_interactivity_data();
    240                 $this->assertNull( $result );
    241         }
    242 
    243         /**
    244          * Tests that the config is not printed if it's empty.
    245          *
    246          * @ticket 60356
    247          *
    248          * @covers ::state
    249          * @covers ::print_client_interactivity_data
     206         *
     207         * @expectedDeprecated WP_Interactivity_API::print_client_interactivity_data
    250208         */
    251209        public function test_config_not_printed_when_empty() {
    252                 $this->interactivity->state( 'myPlugin', array( 'a' => 1 ) );
    253                 $result = $this->print_client_interactivity_data();
    254                 $this->assertSame( array( 'state' => array( 'myPlugin' => array( 'a' => 1 ) ) ), $result );
    255         }
    256 
    257         /**
    258          * Tests that the state is not printed if it's empty.
    259          *
    260          * @ticket 60356
    261          *
    262          * @covers ::config
    263          * @covers ::print_client_interactivity_data
     210                $this->interactivity->print_client_interactivity_data();
     211                $this->expectOutputString( '' );
     212        }
     213
     214        /**
     215         * Sets up an activity, runs an optional callback, and returns a MockAction for inspection.
     216         *
     217         * @since 6.7.0
     218         *
     219         * @param  ?Closure $callback Optional. Callback to run to set up interactivity state and config.
     220         * @return MockAction
     221         */
     222        private function get_script_data_filter_result( ?Closure $callback = null ): MockAction {
     223                $this->interactivity->add_hooks();
     224                $this->interactivity->register_script_modules();
     225                wp_enqueue_script_module( '@wordpress/interactivity' );
     226                $filter = new MockAction();
     227                add_filter( 'script_module_data_@wordpress/interactivity', array( $filter, 'filter' ) );
     228
     229                if ( $callback ) {
     230                        $callback();
     231                }
     232
     233                ob_start();
     234                wp_script_modules()->print_script_module_data();
     235                ob_end_clean();
     236
     237                return $filter;
     238        }
     239
     240        /**
     241         * Tests that the state is not included in client data if it's empty.
     242         *
     243         * @ticket 60356
     244         * @ticket 61512
    264245         */
    265246        public function test_state_not_printed_when_empty() {
    266                 $this->interactivity->config( 'myPlugin', array( 'a' => 1 ) );
    267                 $result = $this->print_client_interactivity_data();
    268                 $this->assertSame( array( 'config' => array( 'myPlugin' => array( 'a' => 1 ) ) ), $result );
     247                $filter = $this->get_script_data_filter_result(
     248                        function () {
     249                                $this->interactivity->config( 'myPlugin', array( 'a' => 1 ) );
     250                        }
     251                );
     252
     253                $this->assertSame( array( 'config' => array( 'myPlugin' => array( 'a' => 1 ) ) ), $filter->get_args()[0][0] );
    269254        }
    270255
    … …  
    273258         *
    274259         * @ticket 60761
    275          *
    276          * @covers ::print_client_interactivity_data
     260         * @ticket 61512
    277261         */
    278262        public function test_state_not_printed_when_empty_array() {
    279                 $this->interactivity->state( 'pluginWithEmptyState_prune', array() );
    280                 $this->interactivity->state( 'pluginWithState_include', array( 'value' => 'excellent' ) );
    281                 $printed_script = get_echo( array( $this->interactivity, 'print_client_interactivity_data' ) );
    282                 $expected       = <<<'SCRIPT_TAG'
    283 <script type="application/json" id="wp-interactivity-data">
    284 {"state":{"pluginWithState_include":{"value":"excellent"}}}
    285 </script>
    286 
    287 SCRIPT_TAG;
    288 
    289                 $this->assertSameIgnoreEOL( $expected, $printed_script );
     263                $filter = $this->get_script_data_filter_result(
     264                        function () {
     265                                $this->interactivity->state( 'pluginWithEmptyState_prune', array() );
     266                                $this->interactivity->state( 'pluginWithState_include', array( 'value' => 'excellent' ) );
     267                        }
     268                );
     269
     270                $this->assertSame( array( 'state' => array( 'pluginWithState_include' => array( 'value' => 'excellent' ) ) ), $filter->get_args()[0][0] );
    290271        }
    291272
    … …  
    294275         *
    295276         * @ticket 60761
    296          *
    297          * @covers ::print_client_interactivity_data
     277         * @ticket 61512
    298278         */
    299279        public function test_state_not_printed_when_only_empty_arrays() {
    300                 $this->interactivity->state( 'pluginWithEmptyState_prune', array() );
    301                 $printed_script = get_echo( array( $this->interactivity, 'print_client_interactivity_data' ) );
    302                 $this->assertSame( '', $printed_script );
     280                $filter = $this->get_script_data_filter_result(
     281                        function () {
     282                                $this->interactivity->state( 'pluginWithEmptyState_prune', array() );
     283                        }
     284                );
     285
     286                $this->assertSame( array(), $filter->get_args()[0][0] );
    303287        }
    304288
    … …  
    307291         *
    308292         * @ticket 60761
    309          *
    310          * @covers ::print_client_interactivity_data
     293         * @ticket 61512
    311294         */
    312295        public function test_state_printed_correctly_with_nested_empty_array() {
    313                 $this->interactivity->state( 'myPlugin', array( 'emptyArray' => array() ) );
    314                 $printed_script = get_echo( array( $this->interactivity, 'print_client_interactivity_data' ) );
    315                 $expected       = <<<'SCRIPT_TAG'
    316 <script type="application/json" id="wp-interactivity-data">
    317 {"state":{"myPlugin":{"emptyArray":[]}}}
    318 </script>
    319 
    320 SCRIPT_TAG;
    321 
    322                 $this->assertSameIgnoreEOL( $expected, $printed_script );
     296                $filter = $this->get_script_data_filter_result(
     297                        function () {
     298                                $this->interactivity->state( 'myPlugin', array( 'emptyArray' => array() ) );
     299                        }
     300                );
     301
     302                $this->assertSame( array( 'state' => array( 'myPlugin' => array( 'emptyArray' => array() ) ) ), $filter->get_args()[0][0] );
    323303        }
    324304
    … …  
    327307         *
    328308         * @ticket 60761
    329          *
    330          * @covers ::print_client_interactivity_data
     309         * @ticket 61512
    331310         */
    332311        public function test_config_not_printed_when_empty_array() {
    333                 $this->interactivity->config( 'pluginWithEmptyConfig_prune', array() );
    334                 $this->interactivity->config( 'pluginWithConfig_include', array( 'value' => 'excellent' ) );
    335                 $printed_script = get_echo( array( $this->interactivity, 'print_client_interactivity_data' ) );
    336                 $expected       = <<<'SCRIPT_TAG'
    337 <script type="application/json" id="wp-interactivity-data">
    338 {"config":{"pluginWithConfig_include":{"value":"excellent"}}}
    339 </script>
    340 
    341 SCRIPT_TAG;
    342 
    343                 $this->assertSameIgnoreEOL( $expected, $printed_script );
     312                $filter = $this->get_script_data_filter_result(
     313                        function () {
     314                                $this->interactivity->config( 'pluginWithEmptyConfig_prune', array() );
     315                                $this->interactivity->config( 'pluginWithConfig_include', array( 'value' => 'excellent' ) );
     316                        }
     317                );
     318
     319                $this->assertSame( array( 'config' => array( 'pluginWithConfig_include' => array( 'value' => 'excellent' ) ) ), $filter->get_args()[0][0] );
    344320        }
    345321
    … …  
    348324         *
    349325         * @ticket 60761
    350          *
    351          * @covers ::print_client_interactivity_data
     326         * @ticket 61512
    352327         */
    353328        public function test_config_not_printed_when_only_empty_arrays() {
    354                 $this->interactivity->config( 'pluginWithEmptyConfig_prune', array() );
    355                 $printed_script = get_echo( array( $this->interactivity, 'print_client_interactivity_data' ) );
    356                 $this->assertSame( '', $printed_script );
     329                $filter = $this->get_script_data_filter_result(
     330                        function () {
     331                                $this->interactivity->config( 'pluginWithEmptyConfig_prune', array() );
     332                        }
     333                );
     334
     335                $this->assertSame( array(), $filter->get_args()[0][0] );
    357336        }
    358337
    … …  
    361340         *
    362341         * @ticket 60761
    363          *
    364          * @covers ::print_client_interactivity_data
     342         * @ticket 61512
    365343         */
    366344        public function test_config_printed_correctly_with_nested_empty_array() {
    367                 $this->interactivity->config( 'myPlugin', array( 'emptyArray' => array() ) );
    368                 $printed_script = get_echo( array( $this->interactivity, 'print_client_interactivity_data' ) );
    369                 $expected       = <<<'SCRIPT_TAG'
    370 <script type="application/json" id="wp-interactivity-data">
    371 {"config":{"myPlugin":{"emptyArray":[]}}}
    372 </script>
    373 
    374 SCRIPT_TAG;
    375 
    376                 $this->assertSameIgnoreEOL( $expected, $printed_script );
    377         }
    378 
    379         /**
    380          * Tests that special characters in the initial state and configuration are
    381          * properly escaped.
    382          *
    383          * @ticket 60356
    384          * @ticket 61170
    385          *
    386          * @covers ::state
    387          * @covers ::config
    388          * @covers ::print_client_interactivity_data
    389          */
    390         public function test_state_and_config_escape_special_characters() {
    391                 $this->interactivity->state(
    392                         'myPlugin',
    393                         array(
    394                                 'ampersand'                              => '&',
    395                                 'less-than sign'                         => '<',
    396                                 'greater-than sign'                      => '>',
    397                                 'solidus'                                => '/',
    398                                 'line separator'                         => "\u{2028}",
    399                                 'paragraph separator'                    => "\u{2029}",
    400                                 'flag of england'                        => "\u{1F3F4}\u{E0067}\u{E0062}\u{E0065}\u{E006E}\u{E0067}\u{E007F}",
    401                                 'malicious script closer'                => '</script>',
    402                                 'entity-encoded malicious script closer' => '&lt;/script&gt;',
    403                         )
    404                 );
    405                 $this->interactivity->config( 'myPlugin', array( 'chars' => '&<>/' ) );
    406 
    407                 $interactivity_data_markup = get_echo( array( $this->interactivity, 'print_client_interactivity_data' ) );
    408                 preg_match( '~<script type="application/json" id="wp-interactivity-data">\s*(\{.*\})\s*</script>~s', $interactivity_data_markup, $interactivity_data_string );
    409 
    410                 $expected = <<<"JSON"
    411 {"config":{"myPlugin":{"chars":"&\\u003C\\u003E/"}},"state":{"myPlugin":{"ampersand":"&","less-than sign":"\\u003C","greater-than sign":"\\u003E","solidus":"/","line separator":"\u{2028}","paragraph separator":"\u{2029}","flag of england":"\u{1F3F4}\u{E0067}\u{E0062}\u{E0065}\u{E006E}\u{E0067}\u{E007F}","malicious script closer":"\\u003C/script\\u003E","entity-encoded malicious script closer":"&lt;/script&gt;"}}}
    412 JSON;
    413                 $this->assertSame( $expected, $interactivity_data_string[1] );
    414         }
    415 
    416         /**
    417          * Tests that special characters in the initial state and configuration are
    418          * properly escaped when the blog_charset is not UTF-8 (unicode compatible).
    419          *
    420          * This this test, unicode and line terminators should be escaped to their
    421          * JSON unicode sequences.
    422          *
    423          * @ticket 61170
    424          *
    425          * @covers ::state
    426          * @covers ::config
    427          * @covers ::print_client_interactivity_data
    428          */
    429         public function test_state_and_config_escape_special_characters_non_utf8() {
    430                 add_filter( 'pre_option_blog_charset', array( $this, 'charset_iso_8859_1' ) );
    431                 $this->interactivity->state(
    432                         'myPlugin',
    433                         array(
    434                                 'ampersand'                              => '&',
    435                                 'less-than sign'                         => '<',
    436                                 'greater-than sign'                      => '>',
    437                                 'solidus'                                => '/',
    438                                 'line separator'                         => "\u{2028}",
    439                                 'paragraph separator'                    => "\u{2029}",
    440                                 'flag of england'                        => "\u{1F3F4}\u{E0067}\u{E0062}\u{E0065}\u{E006E}\u{E0067}\u{E007F}",
    441                                 'malicious script closer'                => '</script>',
    442                                 'entity-encoded malicious script closer' => '&lt;/script&gt;',
    443                         )
    444                 );
    445                 $this->interactivity->config( 'myPlugin', array( 'chars' => '&<>/' ) );
    446 
    447                 $interactivity_data_markup = get_echo( array( $this->interactivity, 'print_client_interactivity_data' ) );
    448                 preg_match( '~<script type="application/json" id="wp-interactivity-data">\s*(\{.*\})\s*</script>~s', $interactivity_data_markup, $interactivity_data_string );
    449 
    450                 $expected = <<<"JSON"
    451 {"config":{"myPlugin":{"chars":"&\\u003C\\u003E/"}},"state":{"myPlugin":{"ampersand":"&","less-than sign":"\\u003C","greater-than sign":"\\u003E","solidus":"/","line separator":"\\u2028","paragraph separator":"\\u2029","flag of england":"\\ud83c\\udff4\\udb40\\udc67\\udb40\\udc62\\udb40\\udc65\\udb40\\udc6e\\udb40\\udc67\\udb40\\udc7f","malicious script closer":"\\u003C/script\\u003E","entity-encoded malicious script closer":"&lt;/script&gt;"}}}
    452 JSON;
    453                 $this->assertSame( $expected, $interactivity_data_string[1] );
     345                $filter = $this->get_script_data_filter_result(
     346                        function () {
     347                                $this->interactivity->config( 'myPlugin', array( 'emptyArray' => array() ) );
     348                        }
     349                );
     350
     351                $this->assertSame( array( 'config' => array( 'myPlugin' => array( 'emptyArray' => array() ) ) ), $filter->get_args()[0][0] );
    454352        }
    455353
Note: See TracChangeset for help on using the changeset viewer.