From 1b2a381e2900e77111eabf46e9202022bb5c3ca6 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Tue, 21 Oct 2025 00:45:29 +0000 Subject: [PATCH] Script Loader: Add support for printing script modules at `wp_footer`. This brings API parity with `WP_Scripts` by implementing opt-in support for printing in the footer via an `in_footer` argument. This argument can be supplied via the `$args` array passed to `wp_enqueue_script_module()` or `wp_register_script_module()`, alongside the existing `fetchpriority` key introduced in #61734. It can also be set for previously-registered script modules via `WP_Script_Modules::set_in_footer()`. This is not applicable to classic themes since modules are enqueued while blocks are rendered after `wp_head` has completed, so all script modules are printed in the footer anyway; the `importmap` script must be printed after all script modules have been enqueued. Script modules used for interactive blocks (with the Interactivity API) are automatically printed in the footer. Such script modules should be deprioritized because they are not in the critical rendering path due to interactive blocks using server-side rendering. Script modules remain printed at `wp_head` by default, although this default should be revisited since they have deferred execution (and they are printed in the footer for classic themes already, as previously noted). Moving a script module to the footer ensures that its loading does not contend with the loading of critical resources, such as the LCP element's image resource, and LCP is improved as a result. This also improves specificity of some PHP types, it ensures that script modules can't be registered with an empty ID, and it prevents printing script modules with empty `src` URLs. Developed in https://github.com/WordPress/wordpress-develop/pull/9867 Follow-up to [60704]. Props b1ink0, westonruter, jonsurrell, peterwilsoncc, vipulpatil, mindctrl. See #61734. Fixes #63486. Built from https://develop.svn.wordpress.org/trunk@60999 git-svn-id: http://core.svn.wordpress.org/trunk@60335 1a063a9b-81f0-0310-95a4-ce76da25c4cd --- wp-includes/blocks.php | 1 + wp-includes/class-wp-script-modules.php | 402 ++++++++++++++++++------ wp-includes/script-modules.php | 9 +- wp-includes/version.php | 2 +- 4 files changed, 316 insertions(+), 98 deletions(-) diff --git a/wp-includes/blocks.php b/wp-includes/blocks.php index 86f6de94a3..22ef6d086d 100644 --- a/wp-includes/blocks.php +++ b/wp-includes/blocks.php @@ -182,6 +182,7 @@ function register_block_script_module_id( $metadata, $field_name, $index = 0 ) { ( isset( $metadata['supports']['interactivity']['interactive'] ) && true === $metadata['supports']['interactivity']['interactive'] ) ) { $args['fetchpriority'] = 'low'; + $args['in_footer'] = true; } wp_register_script_module( diff --git a/wp-includes/class-wp-script-modules.php b/wp-includes/class-wp-script-modules.php index 932fa984db..838c0eaf9c 100644 --- a/wp-includes/class-wp-script-modules.php +++ b/wp-includes/class-wp-script-modules.php @@ -18,7 +18,7 @@ class WP_Script_Modules { * Holds the registered script modules, keyed by script module identifier. * * @since 6.5.0 - * @var array[] + * @var array> */ private $registered = array(); @@ -30,6 +30,14 @@ class WP_Script_Modules { */ private $queue = array(); + /** + * Holds the script module identifiers that have been printed. + * + * @since 6.9.0 + * @var string[] + */ + private $done = array(); + /** * Tracks whether the @wordpress/a11y script module is available. * @@ -50,6 +58,18 @@ class WP_Script_Modules { */ private $dependents_map = array(); + /** + * Holds the valid values for fetchpriority. + * + * @since 6.9.0 + * @var string[] + */ + private $priorities = array( + 'low', + 'auto', + 'high', + ); + /** * Registers the script module if no script module with that script module * identifier has already been registered. @@ -84,10 +104,16 @@ class WP_Script_Modules { * @param array $args { * Optional. An array of additional args. Default empty array. * + * @type bool $in_footer Whether to print the script module in the footer. Only relevant to block themes. Default 'false'. Optional. * @type 'auto'|'low'|'high' $fetchpriority Fetch priority. Default 'auto'. Optional. * } */ public function register( string $id, string $src, array $deps = array(), $version = false, array $args = array() ) { + if ( '' === $id ) { + _doing_it_wrong( __METHOD__, __( 'Non-empty string required for id.' ), '6.9.0' ); + return; + } + if ( ! isset( $this->registered[ $id ] ) ) { $dependencies = array(); foreach ( $deps as $dependency ) { @@ -110,6 +136,8 @@ class WP_Script_Modules { } } + $in_footer = isset( $args['in_footer'] ) && (bool) $args['in_footer']; + $fetchpriority = 'auto'; if ( isset( $args['fetchpriority'] ) ) { if ( $this->is_valid_fetchpriority( $args['fetchpriority'] ) ) { @@ -132,6 +160,7 @@ class WP_Script_Modules { 'src' => $src, 'version' => $version, 'dependencies' => $dependencies, + 'in_footer' => $in_footer, 'fetchpriority' => $fetchpriority, ); } @@ -157,7 +186,7 @@ class WP_Script_Modules { * @return bool Whether valid fetchpriority. */ private function is_valid_fetchpriority( $priority ): bool { - return in_array( $priority, array( 'auto', 'low', 'high' ), true ); + return in_array( $priority, $this->priorities, true ); } /** @@ -192,6 +221,25 @@ class WP_Script_Modules { return true; } + /** + * Sets whether a script module should be printed in the footer. + * + * This is only relevant in block themes. + * + * @since 6.9.0 + * + * @param string $id Script module identifier. + * @param bool $in_footer Whether to print in the footer. + * @return bool Whether setting the printing location was successful. + */ + public function set_in_footer( string $id, bool $in_footer ): bool { + if ( ! isset( $this->registered[ $id ] ) ) { + return false; + } + $this->registered[ $id ]['in_footer'] = $in_footer; + return true; + } + /** * Marks the script module to be enqueued in the page. * @@ -228,10 +276,16 @@ class WP_Script_Modules { * @param array $args { * Optional. An array of additional args. Default empty array. * + * @type bool $in_footer Whether to print the script module in the footer. Only relevant to block themes. Default 'false'. Optional. * @type 'auto'|'low'|'high' $fetchpriority Fetch priority. Default 'auto'. Optional. * } */ public function enqueue( string $id, string $src = '', array $deps = array(), $version = false, array $args = array() ) { + if ( '' === $id ) { + _doing_it_wrong( __METHOD__, __( 'Non-empty string required for id.' ), '6.9.0' ); + return; + } + if ( ! in_array( $id, $this->queue, true ) ) { $this->queue[] = $id; } @@ -274,9 +328,20 @@ class WP_Script_Modules { * @since 6.5.0 */ public function add_hooks() { - $position = wp_is_block_theme() ? 'wp_head' : 'wp_footer'; + $is_block_theme = wp_is_block_theme(); + $position = $is_block_theme ? 'wp_head' : 'wp_footer'; add_action( $position, array( $this, 'print_import_map' ) ); - add_action( $position, array( $this, 'print_enqueued_script_modules' ) ); + if ( $is_block_theme ) { + /* + * Modules can only be printed in the head for block themes because only with + * block themes will import map be fully populated by modules discovered by + * rendering the block template. In classic themes, modules are enqueued during + * template rendering, thus the import map must be printed in the footer, + * followed by all enqueued modules. + */ + add_action( 'wp_head', array( $this, 'print_head_enqueued_script_modules' ) ); + } + add_action( 'wp_footer', array( $this, 'print_enqueued_script_modules' ) ); add_action( $position, array( $this, 'print_script_module_preloads' ) ); add_action( 'admin_print_footer_scripts', array( $this, 'print_import_map' ) ); @@ -295,22 +360,20 @@ class WP_Script_Modules { * @since 6.9.0 * * @param string[] $ids Script module IDs. - * @return string Highest fetch priority for the provided script module IDs. + * @return 'auto'|'low'|'high' Highest fetch priority for the provided script module IDs. */ private function get_highest_fetchpriority( array $ids ): string { - static $priorities = array( - 'low', - 'auto', - 'high', - ); - $high_priority_index = count( $priorities ) - 1; + static $high_priority_index = null; + if ( null === $high_priority_index ) { + $high_priority_index = count( $this->priorities ) - 1; + } $highest_priority_index = 0; foreach ( $ids as $id ) { if ( isset( $this->registered[ $id ] ) ) { - $highest_priority_index = max( + $highest_priority_index = (int) max( $highest_priority_index, - array_search( $this->registered[ $id ]['fetchpriority'], $priorities, true ) + (int) array_search( $this->registered[ $id ]['fetchpriority'], $this->priorities, true ) ); if ( $high_priority_index === $highest_priority_index ) { break; @@ -318,35 +381,87 @@ class WP_Script_Modules { } } - return $priorities[ $highest_priority_index ]; + return $this->priorities[ $highest_priority_index ]; } /** - * Prints the enqueued script modules using script tags with type="module" - * attributes. + * Prints the enqueued script modules in head. + * + * This is only used in block themes. + * + * @since 6.9.0 + */ + public function print_head_enqueued_script_modules() { + foreach ( $this->get_sorted_dependencies( $this->queue ) as $id ) { + if ( + isset( $this->registered[ $id ] ) && + ! $this->registered[ $id ]['in_footer'] + ) { + // If any dependency is set to be printed in footer, skip printing this module in head. + $dependencies = $this->get_dependencies( array( $id ) ); + foreach ( $dependencies as $dependency_id ) { + if ( + in_array( $dependency_id, $this->queue, true ) && + isset( $this->registered[ $dependency_id ] ) && + $this->registered[ $dependency_id ]['in_footer'] + ) { + continue 2; + } + } + $this->print_script_module( $id ); + } + } + } + + /** + * Prints the enqueued script modules in footer. * * @since 6.5.0 */ public function print_enqueued_script_modules() { - foreach ( $this->get_marked_for_enqueue() as $id => $script_module ) { - $args = array( - 'type' => 'module', - 'src' => $this->get_src( $id ), - 'id' => $id . '-js-module', - ); - - $dependents = $this->get_recursive_dependents( $id ); - $fetchpriority = $this->get_highest_fetchpriority( array_merge( array( $id ), $dependents ) ); - if ( 'auto' !== $fetchpriority ) { - $args['fetchpriority'] = $fetchpriority; - } - if ( $fetchpriority !== $script_module['fetchpriority'] ) { - $args['data-wp-fetchpriority'] = $script_module['fetchpriority']; - } - wp_print_script_tag( $args ); + foreach ( $this->get_sorted_dependencies( $this->queue ) as $id ) { + $this->print_script_module( $id ); } } + /** + * Prints the enqueued script module using script tags with type="module" + * attributes. + * + * @since 6.9.0 + * + * @param string $id The script module identifier. + */ + private function print_script_module( string $id ) { + if ( in_array( $id, $this->done, true ) || ! in_array( $id, $this->queue, true ) ) { + return; + } + + $this->done[] = $id; + + $src = $this->get_src( $id ); + if ( '' === $src ) { + return; + } + + $attributes = array( + 'type' => 'module', + 'src' => $src, + 'id' => $id . '-js-module', + ); + + $script_module = $this->registered[ $id ]; + $dependents = $this->get_recursive_dependents( $id ); + $fetchpriority = $this->get_highest_fetchpriority( array_merge( array( $id ), $dependents ) ); + if ( 'auto' !== $fetchpriority ) { + $attributes['fetchpriority'] = $fetchpriority; + } + if ( $fetchpriority !== $script_module['fetchpriority'] ) { + $attributes['data-wp-fetchpriority'] = $script_module['fetchpriority']; + } + wp_print_script_tag( $attributes ); + } + /** * Prints the static dependencies of the enqueued script modules using * link tags with rel="modulepreload" attributes. @@ -356,24 +471,32 @@ class WP_Script_Modules { * @since 6.5.0 */ public function print_script_module_preloads() { - foreach ( $this->get_dependencies( array_unique( $this->queue ), array( 'static' ) ) as $id => $script_module ) { + $dependency_ids = $this->get_sorted_dependencies( $this->queue, array( 'static' ) ); + foreach ( $dependency_ids as $id ) { // Don't preload if it's marked for enqueue. - if ( ! in_array( $id, $this->queue, true ) ) { - $enqueued_dependents = array_intersect( $this->get_recursive_dependents( $id ), $this->queue ); - $highest_fetchpriority = $this->get_highest_fetchpriority( $enqueued_dependents ); - printf( - 'get_src( $id ) ), - esc_attr( $id . '-js-modulepreload' ) - ); - if ( 'auto' !== $highest_fetchpriority ) { - printf( ' fetchpriority="%s"', esc_attr( $highest_fetchpriority ) ); - } - if ( $highest_fetchpriority !== $script_module['fetchpriority'] && 'auto' !== $script_module['fetchpriority'] ) { - printf( ' data-wp-fetchpriority="%s"', esc_attr( $script_module['fetchpriority'] ) ); - } - echo ">\n"; + if ( in_array( $id, $this->queue, true ) ) { + continue; } + + $src = $this->get_src( $id ); + if ( '' === $src ) { + continue; + } + + $enqueued_dependents = array_intersect( $this->get_recursive_dependents( $id ), $this->queue ); + $highest_fetchpriority = $this->get_highest_fetchpriority( $enqueued_dependents ); + printf( + 'registered[ $id ]['fetchpriority'] && 'auto' !== $this->registered[ $id ]['fetchpriority'] ) { + printf( ' data-wp-fetchpriority="%s"', esc_attr( $this->registered[ $id ]['fetchpriority'] ) ); + } + echo ">\n"; } } @@ -386,7 +509,7 @@ class WP_Script_Modules { $import_map = $this->get_import_map(); if ( ! empty( $import_map['imports'] ) ) { wp_print_inline_script_tag( - wp_json_encode( $import_map, JSON_HEX_TAG | JSON_UNESCAPED_SLASHES ), + (string) wp_json_encode( $import_map, JSON_HEX_TAG | JSON_UNESCAPED_SLASHES ), array( 'type' => 'importmap', 'id' => 'wp-importmap', @@ -405,8 +528,11 @@ class WP_Script_Modules { */ private function get_import_map(): array { $imports = array(); - foreach ( $this->get_dependencies( array_unique( $this->queue ) ) as $id => $script_module ) { - $imports[ $id ] = $this->get_src( $id ); + foreach ( $this->get_dependencies( $this->queue ) as $id ) { + $src = $this->get_src( $id ); + if ( '' !== $src ) { + $imports[ $id ] = $src; + } } return array( 'imports' => $imports ); } @@ -414,6 +540,9 @@ class WP_Script_Modules { /** * Retrieves the list of script modules marked for enqueue. * + * Even though this is a private method and is unused in core, there are ecosystem plugins accessing it via the + * Reflection API. The ecosystem should rather use {@see self::get_queue()}. + * * @since 6.5.0 * * @return array Script modules marked for enqueue, keyed by script module identifier. @@ -426,8 +555,7 @@ class WP_Script_Modules { } /** - * Retrieves all the dependencies for the given script module identifiers, - * filtered by import types. + * Retrieves all the dependencies for the given script module identifiers, filtered by import types. * * It will consolidate an array containing a set of unique dependencies based * on the requested import types: 'static', 'dynamic', or both. This method is @@ -437,29 +565,34 @@ class WP_Script_Modules { * * @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. - * @return array[] List of dependencies, keyed by script module identifier. + * Default is both. + * @return string[] List of IDs for script module dependencies. */ private function get_dependencies( array $ids, array $import_types = array( 'static', 'dynamic' ) ): array { - return array_reduce( - $ids, - function ( $dependency_script_modules, $id ) use ( $import_types ) { - $dependencies = array(); - if ( isset( $this->registered[ $id ] ) ) { - foreach ( $this->registered[ $id ]['dependencies'] as $dependency ) { - if ( - in_array( $dependency['import'], $import_types, true ) && - isset( $this->registered[ $dependency['id'] ] ) && - ! isset( $dependency_script_modules[ $dependency['id'] ] ) - ) { - $dependencies[ $dependency['id'] ] = $this->registered[ $dependency['id'] ]; - } - } + $all_dependencies = array(); + $id_queue = $ids; + + while ( ! empty( $id_queue ) ) { + $id = array_shift( $id_queue ); + if ( ! isset( $this->registered[ $id ] ) ) { + continue; + } + + foreach ( $this->registered[ $id ]['dependencies'] as $dependency ) { + if ( + ! isset( $all_dependencies[ $dependency['id'] ] ) && + in_array( $dependency['import'], $import_types, true ) && + isset( $this->registered[ $dependency['id'] ] ) + ) { + $all_dependencies[ $dependency['id'] ] = true; + + // Add this dependency to the list to get dependencies for. + $id_queue[] = $dependency['id']; } - return array_merge( $dependency_script_modules, $dependencies, $this->get_dependencies( array_keys( $dependencies ), $import_types ) ); - }, - array() - ); + } + } + + return array_keys( $all_dependencies ); } /** @@ -506,29 +639,105 @@ class WP_Script_Modules { * @return string[] Script module IDs. */ private function get_recursive_dependents( string $id ): array { - $get = function ( string $id, array $checked = array() ) use ( &$get ): array { + $dependents = array(); + $id_queue = array( $id ); + $processed = array(); - // If by chance an unregistered script module is checked or there is a recursive dependency, return early. - if ( ! isset( $this->registered[ $id ] ) || isset( $checked[ $id ] ) ) { - return array(); + while ( ! empty( $id_queue ) ) { + $current_id = array_shift( $id_queue ); + + // Skip unregistered or already-processed script modules. + if ( ! isset( $this->registered[ $current_id ] ) || isset( $processed[ $current_id ] ) ) { + continue; } - // Mark this script module as checked to guard against infinite recursion. - $checked[ $id ] = true; + // Mark as processed to guard against infinite loops from circular dependencies. + $processed[ $current_id ] = true; - $dependents = array(); - foreach ( $this->get_dependents( $id ) as $dependent ) { - $dependents = array_merge( - $dependents, - array( $dependent ), - $get( $dependent, $checked ) - ); + // Find the direct dependents of the current script. + foreach ( $this->get_dependents( $current_id ) as $dependent_id ) { + // Only add the dependent if we haven't found it before. + if ( ! isset( $dependents[ $dependent_id ] ) ) { + $dependents[ $dependent_id ] = true; + + // Add dependency to the queue. + $id_queue[] = $dependent_id; + } } + } - return $dependents; - }; + return array_keys( $dependents ); + } - return array_unique( $get( $id ) ); + /** + * Sorts the given script module identifiers based on their dependencies. + * + * It will return a list of script module identifiers sorted in the order + * they should be printed, so that dependencies are printed before the script + * modules that depend on them. + * + * @since 6.9.0 + * + * @param string[] $ids The identifiers of the script modules to sort. + * @param string[] $import_types Optional. Import types of dependencies to retrieve: 'static', 'dynamic', or both. + * Default is both. + * @return string[] Sorted list of script module identifiers. + */ + private function get_sorted_dependencies( array $ids, array $import_types = array( 'static', 'dynamic' ) ): array { + $sorted = array(); + + foreach ( $ids as $id ) { + $this->sort_item_dependencies( $id, $import_types, $sorted ); + } + + return array_unique( $sorted ); + } + + /** + * Recursively sorts the dependencies for a single script module identifier. + * + * @since 6.9.0 + * + * @param string $id The identifier of the script module to sort. + * @param string[] $import_types Optional. Import types of dependencies to retrieve: 'static', 'dynamic', or both. + * @param string[] &$sorted The array of sorted identifiers, passed by reference. + * @return bool True on success, false on failure (e.g., missing dependency). + */ + private function sort_item_dependencies( string $id, array $import_types, array &$sorted ): bool { + // If already processed, don't do it again. + if ( in_array( $id, $sorted, true ) ) { + return true; + } + + // If the item doesn't exist, fail. + if ( ! isset( $this->registered[ $id ] ) ) { + return false; + } + + $dependency_ids = array(); + foreach ( $this->registered[ $id ]['dependencies'] as $dependency ) { + if ( in_array( $dependency['import'], $import_types, true ) ) { + $dependency_ids[] = $dependency['id']; + } + } + + // If the item requires dependencies that do not exist, fail. + if ( count( array_diff( $dependency_ids, array_keys( $this->registered ) ) ) > 0 ) { + return false; + } + + // Recursively process dependencies. + foreach ( $dependency_ids as $dependency_id ) { + if ( ! $this->sort_item_dependencies( $dependency_id, $import_types, $sorted ) ) { + // A dependency failed to resolve, so this branch fails. + return false; + } + } + + // All dependencies are sorted, so we can now add the current item. + $sorted[] = $id; + + return true; } /** @@ -551,10 +760,12 @@ class WP_Script_Modules { $script_module = $this->registered[ $id ]; $src = $script_module['src']; - if ( false === $script_module['version'] ) { - $src = add_query_arg( 'ver', get_bloginfo( 'version' ), $src ); - } elseif ( null !== $script_module['version'] ) { - $src = add_query_arg( 'ver', $script_module['version'], $src ); + if ( '' !== $src ) { + if ( false === $script_module['version'] ) { + $src = add_query_arg( 'ver', get_bloginfo( 'version' ), $src ); + } elseif ( null !== $script_module['version'] ) { + $src = add_query_arg( 'ver', $script_module['version'], $src ); + } } /** @@ -566,6 +777,9 @@ class WP_Script_Modules { * @param string $id Module identifier. */ $src = apply_filters( 'script_module_loader_src', $src, $id ); + if ( ! is_string( $src ) ) { + $src = ''; + } return $src; } @@ -681,7 +895,7 @@ class WP_Script_Modules { } wp_print_inline_script_tag( - wp_json_encode( + (string) wp_json_encode( $data, $json_encode_flags ), diff --git a/wp-includes/script-modules.php b/wp-includes/script-modules.php index 82e7635f1c..85331d7aae 100644 --- a/wp-includes/script-modules.php +++ b/wp-includes/script-modules.php @@ -64,6 +64,7 @@ function wp_script_modules(): WP_Script_Modules { * @param array $args { * Optional. An array of additional args. Default empty array. * + * @type bool $in_footer Whether to print the script module in the footer. Only relevant to block themes. Default 'false'. Optional. * @type 'auto'|'low'|'high' $fetchpriority Fetch priority. Default 'auto'. Optional. * } */ @@ -107,6 +108,7 @@ function wp_register_script_module( string $id, string $src, array $deps = array * @param array $args { * Optional. An array of additional args. Default empty array. * + * @type bool $in_footer Whether to print the script module in the footer. Only relevant to block themes. Default 'false'. Optional. * @type 'auto'|'low'|'high' $fetchpriority Fetch priority. Default 'auto'. Optional. * } */ @@ -183,9 +185,9 @@ function wp_default_script_modules() { /* * The Interactivity API is designed with server-side rendering as its primary goal, so all of its script modules - * should be loaded with low fetchpriority since they should not be needed in the critical rendering path. - * Also, the @wordpress/a11y script module is intended to be used as a dynamic import dependency, in which case - * the fetchpriority is irrelevant. See . + * should be loaded with low fetchpriority and printed in the footer since they should not be needed in the + * critical rendering path. Also, the @wordpress/a11y script module is intended to be used as a dynamic import + * dependency, in which case the fetchpriority is irrelevant. See . * However, in case it is added as a static import dependency, the fetchpriority is explicitly set to be 'low' * since the module should not be involved in the critical rendering path, and if it is, its fetchpriority will * be bumped to match the fetchpriority of the dependent script. @@ -197,6 +199,7 @@ function wp_default_script_modules() { '@wordpress/a11y' === $script_module_id ) { $args['fetchpriority'] = 'low'; + $args['in_footer'] = true; } $path = includes_url( "js/dist/script-modules/{$file_name}" ); diff --git a/wp-includes/version.php b/wp-includes/version.php index 36a78d75c6..7600765a2b 100644 --- a/wp-includes/version.php +++ b/wp-includes/version.php @@ -16,7 +16,7 @@ * * @global string $wp_version */ -$wp_version = '6.9-alpha-60998'; +$wp_version = '6.9-alpha-60999'; /** * Holds the WordPress DB revision, increments when changes are made to the WordPress DB schema.