Make WordPress Core

Changeset 58729


Ignore:
Timestamp:
07/16/2024 09:42:38 AM (2 months 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.