diff --git a/src/wp-includes/blocks.php b/src/wp-includes/blocks.php index 838cd84a19921..dce056d9234f3 100644 --- a/src/wp-includes/blocks.php +++ b/src/wp-includes/blocks.php @@ -175,11 +175,21 @@ function register_block_script_module_id( $metadata, $field_name, $index = 0 ) { $block_version = isset( $metadata['version'] ) ? $metadata['version'] : false; $module_version = isset( $module_asset['version'] ) ? $module_asset['version'] : $block_version; + $args = array(); + if ( + ( isset( $metadata['supports']['interactivity'] ) && true === $metadata['supports']['interactivity'] ) || + ( isset( $metadata['supports']['interactivity']['interactive'] ) && true === $metadata['supports']['interactivity']['interactive'] ) + ) { + // TODO: Add ability for the fetchpriority to be specified in block.json for the viewScriptModule. In wp_default_script_modules() the fetchpriority defaults to low since server-side rendering is employed for core blocks, but there are no guarantees that this is the case for non-core blocks. That said, viewScriptModule entails Interactivity API, following the pattern from core it _should_ be SSR'ed and that is why this is the default for when the block supports interactivity. + $args['fetchpriority'] = 'low'; + } + wp_register_script_module( $module_id, $module_uri, $module_dependencies, - $module_version + $module_version, + $args ); return $module_id; @@ -243,6 +253,7 @@ function register_block_script_handle( $metadata, $field_name, $index = 0 ) { $script_args = array(); if ( 'viewScript' === $field_name && $script_uri ) { $script_args['strategy'] = 'defer'; + // TODO: There needs to be a way to specify that a script defined in a module is safe to use fetchpriority=low. Perhaps the viewScript should not only allow a handle string or a `file:./foo.js` string, but allow an an array of params like { "href": "./foo.js", "fetchpriority": "low" }. This would allow for more metadata to be supplied, like the script loading strategy. Related: and . } $result = wp_register_script( diff --git a/src/wp-includes/class-wp-script-modules.php b/src/wp-includes/class-wp-script-modules.php index dbfa038f8cbe2..ddc4b95f7466e 100644 --- a/src/wp-includes/class-wp-script-modules.php +++ b/src/wp-includes/class-wp-script-modules.php @@ -12,6 +12,14 @@ * Core class used to register script modules. * * @since 6.5.0 + * + * @phpstan-type ScriptModule array{ + * src: string, + * version: string|false|null, + * enqueue: bool, + * dependencies: array, + * fetchpriority: 'auto'|'low'|'high', + * } */ class WP_Script_Modules { /** @@ -19,6 +27,7 @@ class WP_Script_Modules { * * @since 6.5.0 * @var array[] + * @phpstan-var array */ private $registered = array(); @@ -46,6 +55,7 @@ class WP_Script_Modules { * identifier has already been registered. * * @since 6.5.0 + * @since n.e.x.t Added the $args parameter. * * @param string $id The identifier of the script module. Should be unique. It will be used in the * final import map. @@ -71,13 +81,18 @@ class WP_Script_Modules { * It is added to the URL as a query string for cache busting purposes. If $version * is set to false, the version number is the currently installed WordPress version. * If $version is set to null, no version is added. + * @param array $args { + * Optional. An array of additional args. Default empty array. + * + * @type 'auto'|'low'|'high' $fetchpriority Fetch priority. Default 'auto'. Optional. + * } */ - public function register( string $id, string $src, array $deps = array(), $version = false ) { + public function register( string $id, string $src, array $deps = array(), $version = false, array $args = array() ) { if ( ! isset( $this->registered[ $id ] ) ) { $dependencies = array(); foreach ( $deps as $dependency ) { if ( is_array( $dependency ) ) { - if ( ! isset( $dependency['id'] ) ) { + if ( ! isset( $dependency['id'] ) || ! is_string( $dependency['id'] ) ) { _doing_it_wrong( __METHOD__, __( 'Missing required id key in entry among dependencies array.' ), '6.5.0' ); continue; } @@ -95,15 +110,68 @@ public function register( string $id, string $src, array $deps = array(), $versi } } + $fetchpriority = 'auto'; + if ( isset( $args['fetchpriority'] ) ) { + if ( $this->is_valid_fetchpriority( $args['fetchpriority'] ) ) { + $fetchpriority = $args['fetchpriority']; + } else { + _doing_it_wrong( + __METHOD__, + /* translators: %s: Invalid fetchpriority. */ + sprintf( __( 'Invalid fetchpriority: %s' ), $args['fetchpriority'] ), + 'n.e.x.t' + ); + } + } + $this->registered[ $id ] = array( - 'src' => $src, - 'version' => $version, - 'enqueue' => isset( $this->enqueued_before_registered[ $id ] ), - 'dependencies' => $dependencies, + 'src' => $src, + 'version' => $version, + 'enqueue' => isset( $this->enqueued_before_registered[ $id ] ), + 'dependencies' => $dependencies, + 'fetchpriority' => $fetchpriority, ); } } + /** + * Checks if the provided fetchpriority is valid. + * + * @since n.e.x.t + * + * @param mixed $priority Fetch priority. + * @return bool Whether valid fetchpriority. + */ + private function is_valid_fetchpriority( $priority ): bool { + return in_array( $priority, array( 'auto', 'low', 'high' ), true ); + } + + /** + * Sets the fetch priority for a script module. + * + * @since n.e.x.t + * + * @param string $id Script module identifier. + * @param 'auto'|'low'|'high' $priority Fetch priority for the script module. + */ + public function set_fetchpriority( string $id, string $priority ) { + if ( ! isset( $this->registered[ $id ] ) ) { + return; + } + + if ( ! $this->is_valid_fetchpriority( $priority ) ) { + _doing_it_wrong( + __METHOD__, + /* translators: %s: Invalid fetchpriority. */ + sprintf( __( 'Invalid fetchpriority: %s' ), $priority ), + 'n.e.x.t' + ); + return; + } + + $this->registered[ $id ]['fetchpriority'] = $priority; + } + /** * Marks the script module to be enqueued in the page. * @@ -111,6 +179,7 @@ public function register( string $id, string $src, array $deps = array(), $versi * will be registered. * * @since 6.5.0 + * @since n.e.x.t Added the $args parameter. * * @param string $id The identifier of the script module. Should be unique. It will be used in the * final import map. @@ -136,12 +205,17 @@ public function register( string $id, string $src, array $deps = array(), $versi * It is added to the URL as a query string for cache busting purposes. If $version * is set to false, the version number is the currently installed WordPress version. * If $version is set to null, no version is added. + * @param array $args { + * Optional. An array of additional args. Default empty array. + * + * @type 'auto'|'low'|'high' $fetchpriority Fetch priority. Default 'auto'. Optional. + * } */ - public function enqueue( string $id, string $src = '', array $deps = array(), $version = false ) { + public function enqueue( string $id, string $src = '', array $deps = array(), $version = false, $args = array() ) { if ( isset( $this->registered[ $id ] ) ) { $this->registered[ $id ]['enqueue'] = true; } elseif ( $src ) { - $this->register( $id, $src, $deps, $version ); + $this->register( $id, $src, $deps, $version, $args ); $this->registered[ $id ]['enqueue'] = true; } else { $this->enqueued_before_registered[ $id ] = true; @@ -208,13 +282,15 @@ public function add_hooks() { */ public function print_enqueued_script_modules() { foreach ( $this->get_marked_for_enqueue() as $id => $script_module ) { - wp_print_script_tag( - array( - 'type' => 'module', - 'src' => $this->get_src( $id ), - 'id' => $id . '-js-module', - ) + $args = array( + 'type' => 'module', + 'src' => $this->get_src( $id ), + 'id' => $id . '-js-module', ); + if ( 'auto' !== $script_module['fetchpriority'] ) { + $args['fetchpriority'] = $script_module['fetchpriority']; + } + wp_print_script_tag( $args ); } } @@ -231,9 +307,10 @@ public function print_script_module_preloads() { // Don't preload if it's marked for enqueue. if ( true !== $script_module['enqueue'] ) { echo sprintf( - '', + '', esc_url( $this->get_src( $id ) ), - esc_attr( $id . '-js-modulepreload' ) + esc_attr( $id . '-js-modulepreload' ), + 'auto' !== $script_module['fetchpriority'] ? sprintf( ' fetchpriority="%s"', esc_attr( $script_module['fetchpriority'] ) ) : '' ); } } @@ -278,7 +355,9 @@ private function get_import_map(): array { * * @since 6.5.0 * - * @return array[] Script modules marked for enqueue, keyed by script module identifier. + * @phpstan-return array + * + * @return array Script modules marked for enqueue, keyed by script module identifier. */ private function get_marked_for_enqueue(): array { $enqueued = array(); @@ -300,6 +379,8 @@ private function get_marked_for_enqueue(): array { * * @since 6.5.0 * + * @phpstan-return array + * * @param string[] $ids The identifiers of the script modules for which to gather dependencies. * @param string[] $import_types Optional. Import types of dependencies to retrieve: 'static', 'dynamic', or both. * Default is both. diff --git a/src/wp-includes/class-wp-scripts.php b/src/wp-includes/class-wp-scripts.php index 77dff94c0497a..e7b1620519650 100644 --- a/src/wp-includes/class-wp-scripts.php +++ b/src/wp-includes/class-wp-scripts.php @@ -425,6 +425,9 @@ public function do_item( $handle, $group = false ) { if ( $intended_strategy ) { $attr['data-wp-strategy'] = $intended_strategy; } + if ( isset( $obj->extra['fetchpriority'] ) && 'auto' !== $obj->extra['fetchpriority'] ) { + $attr['fetchpriority'] = $obj->extra['fetchpriority']; + } $tag = $translations . $ie_conditional_prefix . $before_script; $tag .= wp_get_script_tag( $attr ); $tag .= $after_script . $ie_conditional_suffix; diff --git a/src/wp-includes/functions.wp-scripts.php b/src/wp-includes/functions.wp-scripts.php index 1be1822aa7c3d..aa7127280a672 100644 --- a/src/wp-includes/functions.wp-scripts.php +++ b/src/wp-includes/functions.wp-scripts.php @@ -158,6 +158,7 @@ function wp_add_inline_script( $handle, $data, $position = 'after' ) { * @since 2.1.0 * @since 4.3.0 A return value was added. * @since 6.3.0 The $in_footer parameter of type boolean was overloaded to be an $args parameter of type array. + * @since n.e.x.t The $fetchpriority parameter of type string was added to the $args parameter of type array. * * @param string $handle Name of the script. Should be unique. * @param string|false $src Full URL of the script, or path of the script relative to the WordPress root directory. @@ -171,8 +172,9 @@ function wp_add_inline_script( $handle, $data, $position = 'after' ) { * Optional. An array of additional script loading strategies. Default empty array. * Otherwise, it may be a boolean in which case it determines whether the script is printed in the footer. Default false. * - * @type string $strategy Optional. If provided, may be either 'defer' or 'async'. - * @type bool $in_footer Optional. Whether to print the script in the footer. Default 'false'. + * @type string $strategy Optional. If provided, may be either 'defer' or 'async'. + * @type bool $in_footer Optional. Whether to print the script in the footer. Default 'false'. + * @type string $fetchpriority Optional. The fetch priority for the script. Default 'auto'. * } * @return bool Whether the script has been registered. True on success, false on failure. */ @@ -193,6 +195,9 @@ function wp_register_script( $handle, $src, $deps = array(), $ver = false, $args if ( ! empty( $args['strategy'] ) ) { $wp_scripts->add_data( $handle, 'strategy', $args['strategy'] ); } + if ( ! empty( $args['fetchpriority'] ) ) { + $wp_scripts->add_data( $handle, 'fetchpriority', $args['fetchpriority'] ); + } return $registered; } @@ -339,6 +344,7 @@ function wp_deregister_script( $handle ) { * * @since 2.1.0 * @since 6.3.0 The $in_footer parameter of type boolean was overloaded to be an $args parameter of type array. + * @since n.e.x.t The $fetchpriority parameter of type string was added to the $args parameter of type array. * * @param string $handle Name of the script. Should be unique. * @param string $src Full URL of the script, or path of the script relative to the WordPress root directory. @@ -352,8 +358,9 @@ function wp_deregister_script( $handle ) { * Optional. An array of additional script loading strategies. Default empty array. * Otherwise, it may be a boolean in which case it determines whether the script is printed in the footer. Default false. * - * @type string $strategy Optional. If provided, may be either 'defer' or 'async'. - * @type bool $in_footer Optional. Whether to print the script in the footer. Default 'false'. + * @type string $strategy Optional. If provided, may be either 'defer' or 'async'. + * @type bool $in_footer Optional. Whether to print the script in the footer. Default 'false'. + * @type string $fetchpriority Optional. The fetch priority for the script. Default 'auto'. * } */ function wp_enqueue_script( $handle, $src = '', $deps = array(), $ver = false, $args = array() ) { @@ -378,6 +385,9 @@ function wp_enqueue_script( $handle, $src = '', $deps = array(), $ver = false, $ if ( ! empty( $args['strategy'] ) ) { $wp_scripts->add_data( $_handle[0], 'strategy', $args['strategy'] ); } + if ( ! empty( $args['fetchpriority'] ) ) { + $wp_scripts->add_data( $_handle[0], 'fetchpriority', $args['fetchpriority'] ); + } } $wp_scripts->enqueue( $handle ); diff --git a/src/wp-includes/script-loader.php b/src/wp-includes/script-loader.php index d43962988256e..9fdb5fdde991d 100644 --- a/src/wp-includes/script-loader.php +++ b/src/wp-includes/script-loader.php @@ -1053,7 +1053,10 @@ function wp_default_scripts( $scripts ) { did_action( 'init' ) && $scripts->localize( 'swfupload-handlers', 'swfuploadL10n', $uploader_l10n ); $scripts->add( 'comment-reply', "/wp-includes/js/comment-reply$suffix.js", array(), false, 1 ); - did_action( 'init' ) && $scripts->add_data( 'comment-reply', 'strategy', 'async' ); + if ( did_action( 'init' ) ) { + $scripts->add_data( 'comment-reply', 'strategy', 'async' ); + $scripts->add_data( 'comment-reply', 'fetchpriority', 'low' ); // In Chrome this is automatically low due to the async strategy, but in Firefox and Safari the priority is normal/medium. + } $scripts->add( 'json2', "/wp-includes/js/json2$suffix.js", array(), '2015-05-03' ); did_action( 'init' ) && $scripts->add_data( 'json2', 'conditional', 'lt IE 8' ); diff --git a/src/wp-includes/script-modules.php b/src/wp-includes/script-modules.php index 31ee51b2a749f..a5e2fd6aaff77 100644 --- a/src/wp-includes/script-modules.php +++ b/src/wp-includes/script-modules.php @@ -35,6 +35,7 @@ function wp_script_modules(): WP_Script_Modules { * identifier has already been registered. * * @since 6.5.0 + * @since n.e.x.t Added the $args parameter. * * @param string $id The identifier of the script module. Should be unique. It will be used in the * final import map. @@ -60,9 +61,14 @@ function wp_script_modules(): WP_Script_Modules { * It is added to the URL as a query string for cache busting purposes. If $version * is set to false, the version number is the currently installed WordPress version. * If $version is set to null, no version is added. + * @param array $args { + * Optional. An array of additional args. Default empty array. + * + * @type 'auto'|'low'|'high' $fetchpriority Fetch priority. Default 'auto'. Optional. + * } */ -function wp_register_script_module( string $id, string $src, array $deps = array(), $version = false ) { - wp_script_modules()->register( $id, $src, $deps, $version ); +function wp_register_script_module( string $id, string $src, array $deps = array(), $version = false, array $args = array() ) { + wp_script_modules()->register( $id, $src, $deps, $version, $args ); } /** @@ -72,6 +78,7 @@ function wp_register_script_module( string $id, string $src, array $deps = array * will be registered. * * @since 6.5.0 + * @since n.e.x.t Added the $args parameter. * * @param string $id The identifier of the script module. Should be unique. It will be used in the * final import map. @@ -97,9 +104,14 @@ function wp_register_script_module( string $id, string $src, array $deps = array * It is added to the URL as a query string for cache busting purposes. If $version * is set to false, the version number is the currently installed WordPress version. * If $version is set to null, no version is added. + * @param array $args { + * Optional. An array of additional args. Default empty array. + * + * @type 'auto'|'low'|'high' $fetchpriority Fetch priority. Default 'auto'. Optional. + * } */ -function wp_enqueue_script_module( string $id, string $src = '', array $deps = array(), $version = false ) { - wp_script_modules()->enqueue( $id, $src, $deps, $version ); +function wp_enqueue_script_module( string $id, string $src = '', array $deps = array(), $version = false, array $args = array() ) { + wp_script_modules()->enqueue( $id, $src, $deps, $version, $args ); } /** @@ -169,7 +181,13 @@ function wp_default_script_modules() { break; } + // The Interactivity API is designed with server-side rendering as its primary goal, so all of its script modules should be loaded with low fetch priority since they should not be needed in the critical rendering path. + $args = array(); + if ( str_starts_with( $script_module_id, '@wordpress/interactivity' ) || str_starts_with( $script_module_id, '@wordpress/block-library' ) ) { + $args['fetchpriority'] = 'low'; + } + $path = includes_url( "js/dist/script-modules/{$file_name}" ); - wp_register_script_module( $script_module_id, $path, $script_module_data['dependencies'], $script_module_data['version'] ); + wp_register_script_module( $script_module_id, $path, $script_module_data['dependencies'], $script_module_data['version'], $args ); } } diff --git a/tests/phpunit/tests/dependencies/scripts.php b/tests/phpunit/tests/dependencies/scripts.php index 89bfb4ef922e6..b4aa9f2cdf8a8 100644 --- a/tests/phpunit/tests/dependencies/scripts.php +++ b/tests/phpunit/tests/dependencies/scripts.php @@ -1023,23 +1023,54 @@ public function test_loading_strategy_with_all_defer_dependencies() { /** * Tests that dependents that are async but attached to a deferred main script, print with defer as opposed to async. * + * Also tests that fetchpriority attributes are added as expected. + * * @ticket 12009 + * @ticket 61734 * * @covers WP_Scripts::do_item * @covers WP_Scripts::get_eligible_loading_strategy + * @covers ::wp_register_script * @covers ::wp_enqueue_script */ public function test_defer_with_async_dependent() { // case with one async dependent. - wp_enqueue_script( 'main-script-d4', '/main-script-d4.js', array(), null, array( 'strategy' => 'defer' ) ); - wp_enqueue_script( 'dependent-script-d4-1', '/dependent-script-d4-1.js', array( 'main-script-d4' ), null, array( 'strategy' => 'defer' ) ); - wp_enqueue_script( 'dependent-script-d4-2', '/dependent-script-d4-2.js', array( 'dependent-script-d4-1' ), null, array( 'strategy' => 'async' ) ); - wp_enqueue_script( 'dependent-script-d4-3', '/dependent-script-d4-3.js', array( 'dependent-script-d4-2' ), null, array( 'strategy' => 'defer' ) ); + wp_register_script( 'main-script-d4', '/main-script-d4.js', array(), null, array( 'strategy' => 'defer' ) ); + wp_enqueue_script( + 'dependent-script-d4-1', + '/dependent-script-d4-1.js', + array( 'main-script-d4' ), + null, + array( + 'strategy' => 'defer', + 'fetchpriority' => 'auto', + ) + ); + wp_enqueue_script( + 'dependent-script-d4-2', + '/dependent-script-d4-2.js', + array( 'dependent-script-d4-1' ), + null, + array( + 'strategy' => 'async', + 'fetchpriority' => 'low', + ) + ); + wp_enqueue_script( + 'dependent-script-d4-3', + '/dependent-script-d4-3.js', + array( 'dependent-script-d4-2' ), + null, + array( + 'strategy' => 'defer', + 'fetchpriority' => 'high', + ) + ); $output = get_echo( 'wp_print_scripts' ); $expected = "\n"; $expected .= "\n"; - $expected .= "\n"; - $expected .= "\n"; + $expected .= "\n"; + $expected .= "\n"; $this->assertEqualMarkup( $expected, $output, 'Scripts registered as defer but that have dependents that are async are expected to have said dependents deferred.' ); } diff --git a/tests/phpunit/tests/script-modules/wpScriptModules.php b/tests/phpunit/tests/script-modules/wpScriptModules.php index 85f9599f0dac3..324a6ae517a75 100644 --- a/tests/phpunit/tests/script-modules/wpScriptModules.php +++ b/tests/phpunit/tests/script-modules/wpScriptModules.php @@ -8,11 +8,19 @@ * @since 6.5.0 * * @group script-modules - * - * @coversDefaultClass WP_Script_Modules */ class Tests_Script_Modules_WpScriptModules extends WP_UnitTestCase { + /** + * @var WP_Script_Modules + */ + protected $original_script_modules; + + /** + * @var string + */ + protected $original_wp_version; + /** * Instance of WP_Script_Modules. * @@ -24,60 +32,396 @@ class Tests_Script_Modules_WpScriptModules extends WP_UnitTestCase { * Set up. */ public function set_up() { + global $wp_script_modules, $wp_version; parent::set_up(); - // Set up the WP_Script_Modules instance. - $this->script_modules = new WP_Script_Modules(); + $this->original_script_modules = $wp_script_modules; + $this->original_wp_version = $wp_version; + $wp_script_modules = null; + $this->script_modules = wp_script_modules(); + } + + /** + * Tear down. + */ + public function tear_down() { + global $wp_script_modules, $wp_version; + parent::tear_down(); + $wp_script_modules = $this->original_script_modules; + $wp_version = $this->original_wp_version; } /** * Gets a list of the enqueued script modules. * + * @phpstan-return array + * * @return array Enqueued script module URLs, keyed by script module identifier. */ - public function get_enqueued_script_modules() { - $script_modules_markup = get_echo( array( $this->script_modules, 'print_enqueued_script_modules' ) ); - $p = new WP_HTML_Tag_Processor( $script_modules_markup ); - $enqueued_script_modules = array(); + public function get_enqueued_script_modules(): array { + $modules = array(); + $p = new WP_HTML_Tag_Processor( get_echo( array( $this->script_modules, 'print_enqueued_script_modules' ) ) ); while ( $p->next_tag( array( 'tag' => 'SCRIPT' ) ) ) { - if ( 'module' === $p->get_attribute( 'type' ) ) { - $id = preg_replace( '/-js-module$/', '', $p->get_attribute( 'id' ) ); - $enqueued_script_modules[ $id ] = $p->get_attribute( 'src' ); - } + $this->assertSame( 'module', $p->get_attribute( 'type' ) ); + $this->assertIsString( $p->get_attribute( 'id' ) ); + $this->assertIsString( $p->get_attribute( 'src' ) ); + $this->assertStringEndsWith( '-js-module', $p->get_attribute( 'id' ) ); + + $id = preg_replace( '/-js-module$/', '', (string) $p->get_attribute( 'id' ) ); + $fetchpriority = $p->get_attribute( 'fetchpriority' ); + $modules[ $id ] = array( + 'url' => $p->get_attribute( 'src' ), + 'fetchpriority' => is_string( $fetchpriority ) ? $fetchpriority : 'auto', + ); } - return $enqueued_script_modules; + return $modules; } /** * Gets the script modules listed in the import map. * - * @return array Import map entry URLs, keyed by script module identifier. + * @return array Import map entry URLs, keyed by script module identifier. */ - public function get_import_map() { - $import_map_markup = get_echo( array( $this->script_modules, 'print_import_map' ) ); - preg_match( '/', '\u003C/script\u003E', 'iso-8859-1' ), - 'Entity-encoded malicious script closer' => array( '</script>', '</script>', 'iso-8859-1' ), + 'Flag of england non-utf8' => array( '🏴󠁧󠁢󠁥󠁮󠁧󠁿', "\ud83c\udff4\udb40\udc67\udb40\udc62\udb40\udc65\udb40\udc6e\udb40\udc67\udb40\udc7f", 'iso-8859-1' ), + 'Malicious script closer non-utf8' => array( '', '\u003C/script\u003E', 'iso-8859-1' ), + 'Entity-encoded malicious script closer non-utf8' => array( '</script>', '</script>', 'iso-8859-1' ), ); }