diff --git a/src/wp-includes/blocks.php b/src/wp-includes/blocks.php index 86f6de94a3647..22ef6d086d2f0 100644 --- a/src/wp-includes/blocks.php +++ b/src/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/src/wp-includes/class-wp-script-modules.php b/src/wp-includes/class-wp-script-modules.php index 932fa984db018..838c0eaf9cdf3 100644 --- a/src/wp-includes/class-wp-script-modules.php +++ b/src/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 @@ public function register( string $id, string $src, array $deps = array(), $versi } } + $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 @@ public function register( string $id, string $src, array $deps = array(), $versi 'src' => $src, 'version' => $version, 'dependencies' => $dependencies, + 'in_footer' => $in_footer, 'fetchpriority' => $fetchpriority, ); } @@ -157,7 +186,7 @@ public function get_queue(): array { * @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 @@ public function set_fetchpriority( string $id, string $priority ): bool { 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 @@ public function set_fetchpriority( string $id, string $priority ): bool { * @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 @@ public function deregister( string $id ) { * @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 @@ public function add_hooks() { * @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,33 +381,85 @@ private function get_highest_fetchpriority( array $ids ): string { } } - 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', - ); + foreach ( $this->get_sorted_dependencies( $this->queue ) as $id ) { + $this->print_script_module( $id ); + } + } - $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 ); + /** + * 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 ); } /** @@ -356,24 +471,32 @@ public function print_enqueued_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 @@ public function print_import_map() { $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 @@ public function print_import_map() { */ 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 @@ private function get_import_map(): array { /** * 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 @@ private function get_marked_for_enqueue(): array { } /** - * 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 @@ private function get_marked_for_enqueue(): 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. - * @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 @@ private function get_dependents( string $id ): array { * @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(); + + while ( ! empty( $id_queue ) ) { + $current_id = array_shift( $id_queue ); - // 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(); + // 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 @@ private function get_src( string $id ): string { $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 @@ private function get_src( string $id ): string { * @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 @@ public function print_script_module_data(): void { } wp_print_inline_script_tag( - wp_json_encode( + (string) wp_json_encode( $data, $json_encode_flags ), diff --git a/src/wp-includes/script-modules.php b/src/wp-includes/script-modules.php index 82e7635f1c538..85331d7aaec01 100644 --- a/src/wp-includes/script-modules.php +++ b/src/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/tests/phpunit/tests/script-modules/wpScriptModules.php b/tests/phpunit/tests/script-modules/wpScriptModules.php index bec646b521bad..62f3551a50051 100644 --- a/tests/phpunit/tests/script-modules/wpScriptModules.php +++ b/tests/phpunit/tests/script-modules/wpScriptModules.php @@ -56,30 +56,38 @@ public function tear_down() { * @return array Enqueued script module URLs, keyed by script module identifier. */ 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' ) ) ) { - $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' ) ); + $get_modules = function ( string $html, bool $in_footer ): array { + $modules = array(); + $p = new WP_HTML_Tag_Processor( $html ); + while ( $p->next_tag( array( 'tag' => 'SCRIPT' ) ) ) { + $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_merge( + array( + 'url' => $p->get_attribute( 'src' ), + 'fetchpriority' => is_string( $fetchpriority ) ? $fetchpriority : 'auto', + 'in_footer' => $in_footer, + ), + ...array_map( + static function ( $attribute_name ) use ( $p ) { + return array( $attribute_name => $p->get_attribute( $attribute_name ) ); + }, + $p->get_attribute_names_with_prefix( 'data-' ) + ) + ); + } + return $modules; + }; - $id = preg_replace( '/-js-module$/', '', (string) $p->get_attribute( 'id' ) ); - $fetchpriority = $p->get_attribute( 'fetchpriority' ); - $modules[ $id ] = array_merge( - array( - 'url' => $p->get_attribute( 'src' ), - 'fetchpriority' => is_string( $fetchpriority ) ? $fetchpriority : 'auto', - ), - ...array_map( - static function ( $attribute_name ) use ( $p ) { - return array( $attribute_name => $p->get_attribute( $attribute_name ) ); - }, - $p->get_attribute_names_with_prefix( 'data-' ) - ) - ); - } + $modules = array_merge( + $get_modules( get_echo( array( $this->script_modules, 'print_head_enqueued_script_modules' ) ), false ), + $get_modules( get_echo( array( $this->script_modules, 'print_enqueued_script_modules' ) ), true ) + ); return $modules; } @@ -146,12 +154,44 @@ public function test_wp_script_modules() { $this->assertSame( $this->script_modules, wp_script_modules() ); } + /** + * Test wp_register_script_module() with empty ID. + * + * @ticket 63486 + * + * @expectedIncorrectUsage WP_Script_Modules::register + * + * @covers ::wp_register_script_module + * @covers WP_Script_Modules::register + */ + public function test_register_with_empty_id() { + wp_register_script_module( '', '/null-and-void.js' ); + $this->assertArrayNotHasKey( '', $this->get_registered_script_modules( wp_script_modules() ) ); + } + + /** + * Test wp_enqueue_script_module() with empty ID. + * + * @ticket 63486 + * + * @expectedIncorrectUsage WP_Script_Modules::enqueue + * + * @covers ::wp_enqueue_script_module + * @covers WP_Script_Modules::enqueue + */ + public function test_enqueue_with_empty_id() { + wp_enqueue_script_module( '', '/null-and-void.js' ); + $this->assertArrayNotHasKey( '', $this->get_registered_script_modules( wp_script_modules() ) ); + $this->assertNotContains( '', wp_script_modules()->get_queue() ); + } + /** * Tests various ways of registering, enqueueing, dequeuing, and deregistering a script module. * * This ensures that the global function aliases pass all the same parameters as the class methods. * * @ticket 56313 + * @ticket 63486 * * @dataProvider data_test_register_and_enqueue_script_module * @@ -163,7 +203,10 @@ public function test_wp_script_modules() { * @covers WP_Script_Modules::dequeue() * @covers ::wp_deregister_script_module() * @covers WP_Script_Modules::deregister() + * @covers WP_Script_Modules::get_queue() + * @covers WP_Script_Modules::get_marked_for_enqueue() * @covers WP_Script_Modules::set_fetchpriority() + * @covers WP_Script_Modules::print_head_enqueued_script_modules() * @covers WP_Script_Modules::print_enqueued_script_modules() * @covers WP_Script_Modules::print_import_map() * @covers WP_Script_Modules::print_script_module_preloads() @@ -180,6 +223,12 @@ public function test_comprehensive_methods( bool $use_global_function, bool $onl } }; + $reflection_class = new ReflectionClass( wp_script_modules() ); + $get_marked_for_enqueue = $reflection_class->getMethod( 'get_marked_for_enqueue' ); + if ( PHP_VERSION_ID < 80100 ) { + $get_marked_for_enqueue->setAccessible( true ); + } + $register_and_enqueue = static function ( ...$args ) use ( $use_global_function, $only_enqueue ) { if ( $use_global_function ) { if ( $only_enqueue ) { @@ -200,11 +249,17 @@ public function test_comprehensive_methods( bool $use_global_function, bool $onl // Minimal args. $register_and_enqueue( 'a', '/a.js' ); + $this->assertSame( array( 'a' ), wp_script_modules()->get_queue(), 'Expected queue to match.' ); + $marked_for_enqueue = $get_marked_for_enqueue->invoke( wp_script_modules() ); + $this->assertSame( wp_script_modules()->get_queue(), array_keys( $marked_for_enqueue ), 'Expected get_queue() to match keys returned by get_marked_for_enqueue().' ); + $this->assertIsArray( $marked_for_enqueue['a'], 'Expected script module "a" to have an array entry.' ); + $this->assertSame( '/a.js', $marked_for_enqueue['a']['src'], 'Expected script module "a" to have the given src.' ); // One Dependency. $register( 'b-dep', '/b-dep.js' ); $register_and_enqueue( 'b', '/b.js', array( 'b-dep' ) ); $this->assertTrue( wp_script_modules()->set_fetchpriority( 'b', 'low' ) ); + $this->assertSame( array( 'a', 'b' ), wp_script_modules()->get_queue() ); // Two dependencies with different formats and a false version. $register( 'c-dep', '/c-static.js', array(), false, array( 'fetchpriority' => 'low' ) ); @@ -221,6 +276,7 @@ public function test_comprehensive_methods( bool $use_global_function, bool $onl ), false ); + $this->assertSame( array( 'a', 'b', 'c' ), wp_script_modules()->get_queue() ); // Two dependencies, one imported statically and the other dynamically, with a null version. $register( 'd-static-dep', '/d-static-dep.js', array(), false, array( 'fetchpriority' => 'auto' ) ); @@ -240,6 +296,7 @@ public function test_comprehensive_methods( bool $use_global_function, bool $onl ), null ); + $this->assertSame( array( 'a', 'b', 'c', 'd' ), wp_script_modules()->get_queue() ); // No dependencies, with a string version version. $register_and_enqueue( @@ -248,6 +305,7 @@ public function test_comprehensive_methods( bool $use_global_function, bool $onl array(), '1.0.0' ); + $this->assertSame( array( 'a', 'b', 'c', 'd', 'e' ), wp_script_modules()->get_queue() ); // No dependencies, with a string version and fetch priority. $register_and_enqueue( @@ -257,6 +315,7 @@ public function test_comprehensive_methods( bool $use_global_function, bool $onl '2.0.0', array( 'fetchpriority' => 'auto' ) ); + $this->assertSame( array( 'a', 'b', 'c', 'd', 'e', 'f' ), wp_script_modules()->get_queue() ); // No dependencies, with a string version and fetch priority of low. $register_and_enqueue( @@ -266,6 +325,7 @@ public function test_comprehensive_methods( bool $use_global_function, bool $onl '2.0.0', array( 'fetchpriority' => 'low' ) ); + $this->assertSame( array( 'a', 'b', 'c', 'd', 'e', 'f', 'g' ), wp_script_modules()->get_queue() ); // No dependencies, with a string version and fetch priority of high. $register_and_enqueue( @@ -275,6 +335,47 @@ public function test_comprehensive_methods( bool $use_global_function, bool $onl '3.0.0', array( 'fetchpriority' => 'high' ) ); + $this->assertSame( array( 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h' ), wp_script_modules()->get_queue() ); + + // Register and enqueue something which we'll dequeue right away. + $register_and_enqueue( + 'i', + '/i.js', + array(), + '3.0.0' + ); + $this->assertSame( array( 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i' ), wp_script_modules()->get_queue() ); + + // Register and enqueue something which we'll deregister right away. + $register_and_enqueue( + 'j', + '/j.js', + array(), + '3.0.0' + ); + $this->assertSame( array( 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j' ), wp_script_modules()->get_queue() ); + + // Make sure unregister functions work. + $deregister_id = 'j'; + $this->assertArrayHasKey( 'j', $this->get_registered_script_modules( $this->script_modules ) ); + if ( $use_global_function ) { + wp_deregister_script_module( $deregister_id ); + } else { + wp_script_modules()->deregister( $deregister_id ); + } + $this->assertArrayNotHasKey( 'j', $this->get_registered_script_modules( $this->script_modules ) ); + $this->assertSame( array( 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i' ), wp_script_modules()->get_queue() ); + + // Make sure dequeue functions work. + $dequeue_id = 'i'; + $this->assertArrayHasKey( 'i', $this->get_registered_script_modules( $this->script_modules ) ); + if ( $use_global_function ) { + wp_dequeue_script_module( $dequeue_id ); + } else { + wp_script_modules()->dequeue( $dequeue_id ); + } + $this->assertArrayHasKey( 'i', $this->get_registered_script_modules( $this->script_modules ) ); + $this->assertSame( array( 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h' ), wp_script_modules()->get_queue() ); $actual = array( 'preload_links' => $this->get_preloaded_script_modules(), @@ -308,34 +409,42 @@ public function test_comprehensive_methods( bool $use_global_function, bool $onl 'a' => array( 'url' => '/a.js?ver=99.9.9', 'fetchpriority' => 'auto', + 'in_footer' => false, ), 'b' => array( 'url' => '/b.js?ver=99.9.9', 'fetchpriority' => 'low', + 'in_footer' => false, ), 'c' => array( 'url' => '/c.js?ver=99.9.9', 'fetchpriority' => 'auto', + 'in_footer' => false, ), 'd' => array( 'url' => '/d.js', 'fetchpriority' => 'auto', + 'in_footer' => false, ), 'e' => array( 'url' => '/e.js?ver=1.0.0', 'fetchpriority' => 'auto', + 'in_footer' => false, ), 'f' => array( 'url' => '/f.js?ver=2.0.0', 'fetchpriority' => 'auto', + 'in_footer' => false, ), 'g' => array( 'url' => '/g.js?ver=2.0.0', 'fetchpriority' => 'low', + 'in_footer' => false, ), 'h' => array( 'url' => '/h.js?ver=3.0.0', 'fetchpriority' => 'high', + 'in_footer' => false, ), ), 'import_map' => array( @@ -349,71 +458,6 @@ public function test_comprehensive_methods( bool $use_global_function, bool $onl $actual, "Snapshot:\n" . var_export( $actual, true ) ); - - // Dequeue the first half of the scripts. - foreach ( array( 'a', 'b', 'c', 'd' ) as $id ) { - if ( $use_global_function ) { - wp_dequeue_script_module( $id ); - } else { - wp_script_modules()->dequeue( $id ); - } - } - - $actual = array( - 'preload_links' => $this->get_preloaded_script_modules(), - 'script_tags' => $this->get_enqueued_script_modules(), - 'import_map' => $this->get_import_map(), - ); - $this->assertSame( - array( - 'preload_links' => array(), - 'script_tags' => array( - 'e' => array( - 'url' => '/e.js?ver=1.0.0', - 'fetchpriority' => 'auto', - ), - 'f' => array( - 'url' => '/f.js?ver=2.0.0', - 'fetchpriority' => 'auto', - ), - 'g' => array( - 'url' => '/g.js?ver=2.0.0', - 'fetchpriority' => 'low', - ), - 'h' => array( - 'url' => '/h.js?ver=3.0.0', - 'fetchpriority' => 'high', - ), - ), - 'import_map' => array(), - ), - $actual, - "Snapshot:\n" . var_export( $actual, true ) - ); - - // Unregister the remaining scripts. - foreach ( array( 'e', 'f', 'g', 'h' ) as $id ) { - if ( $use_global_function ) { - wp_dequeue_script_module( $id ); - } else { - wp_script_modules()->dequeue( $id ); - } - } - - $actual = array( - 'preload_links' => $this->get_preloaded_script_modules(), - 'script_tags' => $this->get_enqueued_script_modules(), - 'import_map' => $this->get_import_map(), - ); - $this->assertSame( - array( - 'preload_links' => array(), - 'script_tags' => array(), - 'import_map' => array(), - ), - $actual, - "Snapshot:\n" . var_export( $actual, true ) - ); } /** @@ -442,17 +486,30 @@ public function data_test_register_and_enqueue_script_module(): array { * Tests that a script module gets enqueued correctly after being registered. * * @ticket 56313 + * @ticket 63486 * * @covers WP_Script_Modules::register() * @covers WP_Script_Modules::enqueue() + * @covers WP_Script_Modules::print_head_enqueued_script_modules() * @covers WP_Script_Modules::print_enqueued_script_modules() * @covers WP_Script_Modules::set_fetchpriority() + * @covers WP_Script_Modules::set_in_footer() */ public function test_wp_enqueue_script_module() { $this->script_modules->register( 'foo', '/foo.js' ); - $this->script_modules->register( 'bar', '/bar.js', array(), false, array( 'fetchpriority' => 'high' ) ); + $this->script_modules->register( + 'bar', + '/bar.js', + array(), + false, + array( + 'fetchpriority' => 'high', + 'in_footer' => true, + ) + ); $this->script_modules->register( 'baz', '/baz.js' ); $this->assertTrue( $this->script_modules->set_fetchpriority( 'baz', 'low' ) ); + $this->assertTrue( $this->script_modules->set_in_footer( 'baz', true ) ); $this->script_modules->enqueue( 'foo' ); $this->script_modules->enqueue( 'bar' ); $this->script_modules->enqueue( 'baz' ); @@ -462,20 +519,66 @@ public function test_wp_enqueue_script_module() { $this->assertCount( 3, $enqueued_script_modules ); $this->assertStringStartsWith( '/foo.js', $enqueued_script_modules['foo']['url'] ); $this->assertSame( 'auto', $enqueued_script_modules['foo']['fetchpriority'] ); + $this->assertFalse( $enqueued_script_modules['foo']['in_footer'] ); $this->assertStringStartsWith( '/bar.js', $enqueued_script_modules['bar']['url'] ); $this->assertSame( 'high', $enqueued_script_modules['bar']['fetchpriority'] ); + $this->assertTrue( $enqueued_script_modules['bar']['in_footer'] ); $this->assertStringStartsWith( '/baz.js', $enqueued_script_modules['baz']['url'] ); $this->assertSame( 'low', $enqueued_script_modules['baz']['fetchpriority'] ); + $this->assertTrue( $enqueued_script_modules['baz']['in_footer'] ); + } + + /** + * Tests that no script is printed for a script without a src. + * + * @ticket 63486 + * + * @covers WP_Script_Modules::register() + * @covers WP_Script_Modules::enqueue() + * @covers WP_Script_Modules::print_head_enqueued_script_modules() + * @covers WP_Script_Modules::print_enqueued_script_modules() + * @covers WP_Script_Modules::get_src() + */ + public function test_wp_enqueue_script_module_with_empty_src() { + wp_enqueue_script_module( 'with-src', '/src.js' ); + wp_register_script_module( 'without-src', '' ); + wp_register_script_module( 'without-src-but-filtered', '' ); + wp_enqueue_script_module( 'without-src' ); + wp_enqueue_script_module( 'without-src-but-filtered' ); + $this->assertSame( array( 'with-src', 'without-src', 'without-src-but-filtered' ), wp_script_modules()->get_queue() ); + add_filter( + 'script_module_loader_src', + static function ( $src, $id ) { + if ( 'without-src-but-filtered' === $id ) { + $src = '/was-empty-but-added-via-filter.js'; + } + return $src; + }, + 10, + 2 + ); + $actual = get_echo( array( wp_script_modules(), 'print_enqueued_script_modules' ) ); + $this->assertEqualHTML( + ' + + + ', + $actual, + '', + "Expected only one SCRIPT tag to be printed. Snapshot:\n$actual" + ); } /** * Tests that a script module can be dequeued after being enqueued. * * @ticket 56313 + * @ticket 63486 * * @covers WP_Script_Modules::register() * @covers WP_Script_Modules::enqueue() * @covers WP_Script_Modules::dequeue() + * @covers WP_Script_Modules::print_head_enqueued_script_modules() * @covers WP_Script_Modules::print_enqueued_script_modules() */ public function test_wp_dequeue_script_module() { @@ -570,9 +673,11 @@ public function test_wp_deregister_already_deregistered_script_module() { * be handled correctly once registered. * * @ticket 56313 + * @ticket 63486 * * @covers WP_Script_Modules::register() * @covers WP_Script_Modules::enqueue() + * @covers WP_Script_Modules::print_head_enqueued_script_modules() * @covers WP_Script_Modules::print_enqueued_script_modules() */ public function test_wp_enqueue_script_module_works_before_register() { @@ -593,10 +698,12 @@ public function test_wp_enqueue_script_module_works_before_register() { * ensures that it is not enqueued after registration. * * @ticket 56313 + * @ticket 63486 * * @covers WP_Script_Modules::register() * @covers WP_Script_Modules::enqueue() * @covers WP_Script_Modules::dequeue() + * @covers WP_Script_Modules::print_head_enqueued_script_modules() * @covers WP_Script_Modules::print_enqueued_script_modules() */ public function test_wp_dequeue_script_module_works_before_register() { @@ -960,9 +1067,11 @@ function ( $src, $id ) { * script modules and preloaded script modules. * * @ticket 56313 + * @ticket 63486 * * @covers WP_Script_Modules::register() * @covers WP_Script_Modules::enqueue() + * @covers WP_Script_Modules::print_head_enqueued_script_modules() * @covers WP_Script_Modules::print_enqueued_script_modules() * @covers WP_Script_Modules::print_import_map() * @covers WP_Script_Modules::print_script_module_preloads() @@ -998,8 +1107,10 @@ public function test_version_is_propagated_correctly() { * valid src. * * @ticket 56313 + * @ticket 63486 * * @covers WP_Script_Modules::enqueue() + * @covers WP_Script_Modules::print_head_enqueued_script_modules() * @covers WP_Script_Modules::print_enqueued_script_modules() */ public function test_wp_enqueue_script_module_doesnt_register_without_a_valid_src() { @@ -1016,8 +1127,10 @@ public function test_wp_enqueue_script_module_doesnt_register_without_a_valid_sr * src. * * @ticket 56313 + * @ticket 63486 * * @covers WP_Script_Modules::enqueue() + * @covers WP_Script_Modules::print_head_enqueued_script_modules() * @covers WP_Script_Modules::print_enqueued_script_modules() */ public function test_wp_enqueue_script_module_registers_with_valid_src() { @@ -1035,8 +1148,10 @@ public function test_wp_enqueue_script_module_registers_with_valid_src() { * src the second time. * * @ticket 56313 + * @ticket 63486 * * @covers WP_Script_Modules::enqueue() + * @covers WP_Script_Modules::print_head_enqueued_script_modules() * @covers WP_Script_Modules::print_enqueued_script_modules() */ public function test_wp_enqueue_script_module_registers_with_valid_src_the_second_time() { @@ -1061,9 +1176,11 @@ public function test_wp_enqueue_script_module_registers_with_valid_src_the_secon * enqueue. * * @ticket 56313 + * @ticket 63486 * * @covers WP_Script_Modules::register() * @covers WP_Script_Modules::enqueue() + * @covers WP_Script_Modules::print_head_enqueued_script_modules() * @covers WP_Script_Modules::print_enqueued_script_modules() * @covers WP_Script_Modules::print_import_map() */ @@ -1314,6 +1431,58 @@ public function test_fetchpriority_values( string $fetchpriority ) { $this->assertSame( 'auto', $registered_modules['test-script-2']['fetchpriority'] ); } + /** + * Tests ways of setting in_footer. + * + * @ticket 63486 + * @ticket 63486 + * + * @covers ::wp_register_script_module + * @covers ::wp_enqueue_script_module + * @covers WP_Script_Modules::set_in_footer + */ + public function test_in_footer_methods() { + wp_register_script_module( 'default', '/default.js', array(), null ); + wp_enqueue_script_module( 'default' ); + + wp_register_script_module( 'in-footer-via-register', '/in-footer-via-register.js', array(), null, array( 'in_footer' => true ) ); + wp_enqueue_script_module( 'in-footer-via-register' ); + + wp_enqueue_script_module( 'in-footer-via-enqueue', '/in-footer-via-enqueue.js', array(), null, array( 'in_footer' => true ) ); + + wp_enqueue_script_module( 'not-in-footer-via-enqueue', '/not-in-footer-via-enqueue.js', array(), null, array( 'in_footer' => false ) ); + + wp_enqueue_script_module( 'in-footer-via-override', '/in-footer-via-override.js' ); + wp_script_modules()->set_in_footer( 'in-footer-via-override', true ); + + wp_enqueue_script_module( 'not-in-footer-via-override', '/not-in-footer-via-override.js', array(), null, array( 'in_footer' => true ) ); + wp_script_modules()->set_in_footer( 'not-in-footer-via-override', false ); + + $actual_head = get_echo( array( wp_script_modules(), 'print_head_enqueued_script_modules' ) ); + $actual_footer = get_echo( array( wp_script_modules(), 'print_enqueued_script_modules' ) ); + + $this->assertEqualHTML( + $actual_head, + ' + + + + ', + '', + "Expected equal script modules in the HEAD. Snapshot:\n$actual_head" + ); + $this->assertEqualHTML( + $actual_footer, + ' + + + + ', + '', + "Expected equal script modules in the footer. Snapshot:\n$actual_footer" + ); + } + /** * Tests that a script module with an invalid fetchpriority value gets a value of auto. * @@ -1377,6 +1546,7 @@ public function data_provider_to_test_fetchpriority_bumping(): array { 'bajo' => array( 'url' => '/bajo.js', 'fetchpriority' => 'high', + 'in_footer' => false, 'data-wp-fetchpriority' => 'low', ), ), @@ -1399,6 +1569,7 @@ public function data_provider_to_test_fetchpriority_bumping(): array { 'auto' => array( 'url' => '/auto.js', 'fetchpriority' => 'high', + 'in_footer' => false, 'data-wp-fetchpriority' => 'auto', ), ), @@ -1412,20 +1583,21 @@ public function data_provider_to_test_fetchpriority_bumping(): array { 'enqueues' => array( 'alto' ), 'expected' => array( 'preload_links' => array( - 'auto' => array( - 'url' => '/auto.js', - 'fetchpriority' => 'high', - ), 'bajo' => array( 'url' => '/bajo.js', 'fetchpriority' => 'high', 'data-wp-fetchpriority' => 'low', ), + 'auto' => array( + 'url' => '/auto.js', + 'fetchpriority' => 'high', + ), ), 'script_tags' => array( 'alto' => array( 'url' => '/alto.js', 'fetchpriority' => 'high', + 'in_footer' => false, ), ), 'import_map' => array( @@ -1539,11 +1711,11 @@ public function test_fetchpriority_bumping_a_to_z() { $actual = get_echo( array( wp_script_modules(), 'print_script_module_preloads' ) ); $actual .= get_echo( array( wp_script_modules(), 'print_enqueued_script_modules' ) ); $expected = ' - - + - + + @@ -1584,12 +1756,12 @@ public function test_fetchpriority_propagation() { $actual = get_echo( array( wp_script_modules(), 'print_script_module_preloads' ) ); $actual .= get_echo( array( wp_script_modules(), 'print_enqueued_script_modules' ) ); $expected = ' - + - + @@ -1600,8 +1772,11 @@ public function test_fetchpriority_propagation() { /** * Tests that default script modules are printed as expected. * + * @ticket 63486 + * * @covers ::wp_default_script_modules * @covers WP_Script_Modules::print_script_module_preloads + * @covers WP_Script_Modules::print_head_enqueued_script_modules * @covers WP_Script_Modules::print_enqueued_script_modules */ public function test_default_script_modules() { @@ -1609,17 +1784,34 @@ public function test_default_script_modules() { wp_enqueue_script_module( '@wordpress/a11y' ); wp_enqueue_script_module( '@wordpress/block-library/navigation/view' ); - $actual = get_echo( array( wp_script_modules(), 'print_script_module_preloads' ) ); - $actual .= get_echo( array( wp_script_modules(), 'print_enqueued_script_modules' ) ); + $actual_preloads = $this->normalize_markup_for_snapshot( get_echo( array( wp_script_modules(), 'print_script_module_preloads' ) ) ); + $this->assertEqualHTML( + ' + + ', + $actual_preloads, + '', + "Snapshot:\n$actual_preloads" + ); - $actual = $this->normalize_markup_for_snapshot( $actual ); + $actual_head_script_modules = $this->normalize_markup_for_snapshot( get_echo( array( wp_script_modules(), 'print_head_enqueued_script_modules' ) ) ); + $this->assertEqualHTML( + '', + $actual_head_script_modules, + '', + "Snapshot:\n$actual_head_script_modules" + ); - $expected = ' - - - - '; - $this->assertEqualHTML( $expected, $actual, '', "Snapshot:\n$actual" ); + $actual_footer_script_modules = $this->normalize_markup_for_snapshot( get_echo( array( wp_script_modules(), 'print_enqueued_script_modules' ) ) ); + $this->assertEqualHTML( + ' + + + ', + $actual_footer_script_modules, + '', + "Snapshot:\n$actual_footer_script_modules" + ); } /** @@ -1646,8 +1838,8 @@ public function test_dependent_of_default_script_modules() { $expected = ' - + '; $this->assertEqualHTML( $expected, $actual, '', "Snapshot:\n$actual" ); @@ -1730,4 +1922,379 @@ public static function data_invalid_script_module_data(): array { 'string' => array( 'string' ), ); } + + /** + * Tests various ways of printing and dependency ordering of script modules. + * + * This ensures that the global function aliases pass all the same parameters as the class methods. + * + * @ticket 63486 + * + * @dataProvider data_test_register_and_enqueue_script_module + * + * @covers ::wp_register_script_module() + * @covers WP_Script_Modules::register() + * @covers ::wp_enqueue_script_module() + * @covers WP_Script_Modules::enqueue() + * @covers ::wp_dequeue_script_module() + * @covers WP_Script_Modules::dequeue() + * @covers ::wp_deregister_script_module() + * @covers WP_Script_Modules::deregister() + * @covers WP_Script_Modules::set_fetchpriority() + * @covers WP_Script_Modules::print_head_enqueued_script_modules() + * @covers WP_Script_Modules::print_enqueued_script_modules() + * @covers WP_Script_Modules::print_import_map() + * @covers WP_Script_Modules::print_script_module_preloads() + */ + public function test_script_module_printing_and_dependency_ordering( bool $use_global_function, bool $only_enqueue ) { + global $wp_version; + $wp_version = '99.9.9'; + + $register = static function ( ...$args ) use ( $use_global_function ) { + if ( $use_global_function ) { + wp_register_script_module( ...$args ); + } else { + wp_script_modules()->register( ...$args ); + } + }; + + $register_and_enqueue = static function ( ...$args ) use ( $use_global_function, $only_enqueue ) { + if ( $use_global_function ) { + if ( $only_enqueue ) { + wp_enqueue_script_module( ...$args ); + } else { + wp_register_script_module( ...$args ); + wp_enqueue_script_module( $args[0] ); + } + } else { + if ( $only_enqueue ) { + wp_script_modules()->enqueue( ...$args ); + } else { + wp_script_modules()->register( ...$args ); + wp_script_modules()->enqueue( $args[0] ); + } + } + }; + + $deregister = static function ( array $ids ) use ( $use_global_function ) { + foreach ( $ids as $id ) { + if ( $use_global_function ) { + wp_deregister_script_module( $id ); + } else { + wp_script_modules()->deregister( $id ); + } + } + }; + + // Test script module is placed in footer when in_footer is true. + $register_and_enqueue( 'a', '/a.js', array(), '1.0.0', array( 'in_footer' => true ) ); + + $actual = array( + 'preload_links' => $this->get_preloaded_script_modules(), + 'script_tags' => $this->get_enqueued_script_modules(), + 'import_map' => $this->get_import_map(), + ); + $this->assertSame( + array( + 'preload_links' => array(), + 'script_tags' => array( + 'a' => array( + 'url' => '/a.js?ver=1.0.0', + 'fetchpriority' => 'auto', + 'in_footer' => true, + ), + ), + 'import_map' => array(), + ), + $actual, + "Snapshot:\n" . var_export( $actual, true ) + ); + + $deregister( array( 'a' ) ); + + // Test that dependant also gets placed in footer when its dependency is in footer. + $register_and_enqueue( 'b', '/b.js', array(), '1.0.0', array( 'in_footer' => true ) ); + $register_and_enqueue( 'c', '/c.js', array( 'b' ), '1.0.0' ); + + $actual = array( + 'preload_links' => $this->get_preloaded_script_modules(), + 'script_tags' => $this->get_enqueued_script_modules(), + 'import_map' => $this->get_import_map(), + ); + $this->assertSame( + array( + 'preload_links' => array(), + 'script_tags' => array( + 'b' => array( + 'url' => '/b.js?ver=1.0.0', + 'fetchpriority' => 'auto', + 'in_footer' => true, + ), + 'c' => array( + 'url' => '/c.js?ver=1.0.0', + 'fetchpriority' => 'auto', + 'in_footer' => true, + ), + ), + 'import_map' => array( + 'b' => '/b.js?ver=1.0.0', + ), + ), + $actual, + "Snapshot:\n" . var_export( $actual, true ) + ); + + $deregister( array( 'b', 'c ' ) ); + + // Test that registered dependency in footer doesn't place dependant in footer. + $register( 'd', '/d.js', array(), '1.0.0', array( 'in_footer' => true ) ); + $register_and_enqueue( 'e', '/e.js', array( 'd' ), '1.0.0' ); + + $actual = array( + 'preload_links' => $this->get_preloaded_script_modules(), + 'script_tags' => $this->get_enqueued_script_modules(), + 'import_map' => $this->get_import_map(), + ); + $this->assertSame( + array( + 'preload_links' => array( + 'd' => array( + 'url' => '/d.js?ver=1.0.0', + 'fetchpriority' => 'auto', + ), + ), + 'script_tags' => array( + 'e' => array( + 'url' => '/e.js?ver=1.0.0', + 'fetchpriority' => 'auto', + 'in_footer' => false, + ), + ), + 'import_map' => array( + 'd' => '/d.js?ver=1.0.0', + ), + ), + $actual, + "Snapshot:\n" . var_export( $actual, true ) + ); + + $deregister( array( 'd', 'e' ) ); + + // Test if one of the dependency is in footer, the dependant and other dependant dependencies are also placed in footer. + $register_and_enqueue( 'f', '/f.js', array(), '1.0.0' ); + $register_and_enqueue( 'g', '/g.js', array( 'f' ), '1.0.0', array( 'in_footer' => true ) ); + $register_and_enqueue( 'h', '/h.js', array( 'g' ), '1.0.0' ); + $register_and_enqueue( 'i', '/i.js', array( 'h' ), '1.0.0' ); + + $actual = array( + 'preload_links' => $this->get_preloaded_script_modules(), + 'script_tags' => $this->get_enqueued_script_modules(), + 'import_map' => $this->get_import_map(), + ); + + $this->assertSame( + array( + 'preload_links' => array(), + 'script_tags' => array( + 'f' => array( + 'url' => '/f.js?ver=1.0.0', + 'fetchpriority' => 'auto', + 'in_footer' => false, + ), + 'g' => array( + 'url' => '/g.js?ver=1.0.0', + 'fetchpriority' => 'auto', + 'in_footer' => true, + ), + 'h' => array( + 'url' => '/h.js?ver=1.0.0', + 'fetchpriority' => 'auto', + 'in_footer' => true, + ), + 'i' => array( + 'url' => '/i.js?ver=1.0.0', + 'fetchpriority' => 'auto', + 'in_footer' => true, + ), + ), + 'import_map' => array( + 'f' => '/f.js?ver=1.0.0', + 'g' => '/g.js?ver=1.0.0', + 'h' => '/h.js?ver=1.0.0', + ), + ), + $actual, + "Snapshot:\n" . var_export( $actual, true ) + ); + + $deregister( array( 'f', 'g', 'h', 'i' ) ); + + // Test dependency ordering when all scripts modules are enqueued in head. + // Expected order: j, k, l, m. + $register_and_enqueue( 'm', '/m.js', array( 'j', 'l' ), '1.0.0' ); + $register_and_enqueue( 'k', '/k.js', array( 'j' ), '1.0.0' ); + $register_and_enqueue( 'l', '/l.js', array( 'k' ), '1.0.0' ); + $register_and_enqueue( 'j', '/j.js', array(), '1.0.0' ); + + $actual = array( + 'preload_links' => $this->get_preloaded_script_modules(), + 'script_tags' => $this->get_enqueued_script_modules(), + 'import_map' => $this->get_import_map(), + ); + + $this->assertSame( + array( + 'preload_links' => array(), + 'script_tags' => array( + 'j' => array( + 'url' => '/j.js?ver=1.0.0', + 'fetchpriority' => 'auto', + 'in_footer' => false, + ), + 'k' => array( + 'url' => '/k.js?ver=1.0.0', + 'fetchpriority' => 'auto', + 'in_footer' => false, + ), + 'l' => array( + 'url' => '/l.js?ver=1.0.0', + 'fetchpriority' => 'auto', + 'in_footer' => false, + ), + 'm' => array( + 'url' => '/m.js?ver=1.0.0', + 'fetchpriority' => 'auto', + 'in_footer' => false, + ), + ), + 'import_map' => array( + 'j' => '/j.js?ver=1.0.0', + 'l' => '/l.js?ver=1.0.0', + 'k' => '/k.js?ver=1.0.0', + ), + ), + $actual, + "Snapshot:\n" . var_export( $actual, true ) + ); + + $deregister( array( 'j', 'k', 'l', 'm' ) ); + + // Test dependency ordering when scripts modules are enqueued in both head and footer. + // Expected order: q, n, o, p, r. + $register_and_enqueue( 'n', '/n.js', array( 'q' ), '1.0.0' ); + $register_and_enqueue( 'q', '/q.js', array(), '1.0.0' ); + $register_and_enqueue( 'o', '/o.js', array( 'n' ), '1.0.0', array( 'in_footer' => true ) ); + $register_and_enqueue( 'r', '/r.js', array( 'q', 'o', 'p' ), '1.0.0' ); + $register_and_enqueue( 'p', '/p.js', array(), '1.0.0', array( 'in_footer' => true ) ); + + $actual = array( + 'preload_links' => $this->get_preloaded_script_modules(), + 'script_tags' => $this->get_enqueued_script_modules(), + 'import_map' => $this->get_import_map(), + ); + + $this->assertSame( + array( + 'preload_links' => array(), + 'script_tags' => array( + 'q' => array( + 'url' => '/q.js?ver=1.0.0', + 'fetchpriority' => 'auto', + 'in_footer' => false, + ), + 'n' => array( + 'url' => '/n.js?ver=1.0.0', + 'fetchpriority' => 'auto', + 'in_footer' => false, + ), + 'o' => array( + 'url' => '/o.js?ver=1.0.0', + 'fetchpriority' => 'auto', + 'in_footer' => true, + ), + 'p' => array( + 'url' => '/p.js?ver=1.0.0', + 'fetchpriority' => 'auto', + 'in_footer' => true, + ), + 'r' => array( + 'url' => '/r.js?ver=1.0.0', + 'fetchpriority' => 'auto', + 'in_footer' => true, + ), + ), + 'import_map' => array( + 'q' => '/q.js?ver=1.0.0', + 'n' => '/n.js?ver=1.0.0', + 'o' => '/o.js?ver=1.0.0', + 'p' => '/p.js?ver=1.0.0', + ), + ), + $actual, + "Snapshot:\n" . var_export( $actual, true ) + ); + } + + /** + * Tests various ways of printing and dependency ordering of script modules. + * + * @ticket 63486 + * + * @dataProvider data_test_register_and_enqueue_script_module + * + * @covers ::wp_register_script_module() + * @covers WP_Script_Modules::register() + * @covers ::wp_enqueue_script_module() + * @covers WP_Script_Modules::enqueue() + * @covers ::wp_dequeue_script_module() + * @covers WP_Script_Modules::dequeue() + * @covers ::wp_deregister_script_module() + * @covers WP_Script_Modules::deregister() + * @covers WP_Script_Modules::set_fetchpriority() + * @covers WP_Script_Modules::print_head_enqueued_script_modules() + * @covers WP_Script_Modules::print_enqueued_script_modules() + * @covers WP_Script_Modules::print_import_map() + * @covers WP_Script_Modules::print_script_module_preloads() + */ + public function test_static_import_dependency_with_dynamic_imports_depending_on_static_import_dependency() { + $get_dependency = function ( string $id, string $import ): array { + return compact( 'id', 'import' ); + }; + + wp_register_script_module( 'enqueued', '/enqueued.js', array( $get_dependency( 'static1', 'static' ) ), null ); + wp_register_script_module( 'static1', '/static1.js', array( $get_dependency( 'dynamic1', 'dynamic' ) ), null ); + wp_register_script_module( 'dynamic1', '/dynamic1.js', array( $get_dependency( 'static2', 'static' ) ), null ); + wp_register_script_module( 'static2', '/static2.js', array(), null ); + + wp_enqueue_script_module( 'enqueued' ); + $import_map = $this->get_import_map(); + $preload_links = get_echo( array( wp_script_modules(), 'print_script_module_preloads' ) ); + $script_modules = get_echo( array( wp_script_modules(), 'print_enqueued_script_modules' ) ); + + $this->assertEquals( + array( + 'static1' => '/static1.js', + 'dynamic1' => '/dynamic1.js', + 'static2' => '/static2.js', + ), + $import_map, + "Expected import map to match snapshot:\n" . var_export( $import_map, true ) + ); + $this->assertEqualHTML( + ' + + ', + $preload_links, + '', + "Expected preload links to match snapshot:\n$preload_links" + ); + $this->assertEqualHTML( + ' + + ', + $script_modules, + '', + "Expected script modules to match snapshot:\n$script_modules" + ); + } }