diff --git a/src/wp-includes/class-wp-dependency.php b/src/wp-includes/class-wp-dependency.php index 7e8de9c595335..6666b166af7ad 100644 --- a/src/wp-includes/class-wp-dependency.php +++ b/src/wp-includes/class-wp-dependency.php @@ -50,7 +50,7 @@ class _WP_Dependency { * Used for cache-busting. * * @since 2.6.0 - * @var bool|string + * @var string|false|null */ public $ver = false; diff --git a/src/wp-includes/class-wp-script-modules.php b/src/wp-includes/class-wp-script-modules.php index 52537b1a3412e..07402f66b3f20 100644 --- a/src/wp-includes/class-wp-script-modules.php +++ b/src/wp-includes/class-wp-script-modules.php @@ -41,6 +41,15 @@ class WP_Script_Modules { */ private $a11y_available = false; + /** + * Holds a mapping of dependents (as IDs) for a given script ID. + * Used to optimize recursive dependency tree checks. + * + * @since 6.9.0 + * @var array + */ + private $dependents_map = array(); + /** * Registers the script module if no script module with that script module * identifier has already been registered. @@ -269,6 +278,38 @@ public function add_hooks() { add_action( 'admin_print_footer_scripts', array( $this, 'print_a11y_script_module_html' ), 20 ); } + /** + * Gets the highest fetch priority for the provided script IDs. + * + * @since 6.9.0 + * + * @param string[] $ids Script module IDs. + * @return string 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; + + $highest_priority_index = 0; + foreach ( $ids as $id ) { + if ( isset( $this->registered[ $id ] ) ) { + $highest_priority_index = max( + $highest_priority_index, + array_search( $this->registered[ $id ]['fetchpriority'], $priorities, true ) + ); + if ( $high_priority_index === $highest_priority_index ) { + break; + } + } + } + + return $priorities[ $highest_priority_index ]; + } + /** * Prints the enqueued script modules using script tags with type="module" * attributes. @@ -282,15 +323,21 @@ public function print_enqueued_script_modules() { 'src' => $this->get_src( $id ), 'id' => $id . '-js-module', ); - if ( 'auto' !== $script_module['fetchpriority'] ) { - $args['fetchpriority'] = $script_module['fetchpriority']; + + $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 the static dependencies of the enqueued script modules using + * Prints the static dependencies of the enqueued script modules using * link tags with rel="modulepreload" attributes. * * If a script module is marked for enqueue, it will not be preloaded. @@ -301,12 +348,20 @@ public function print_script_module_preloads() { foreach ( $this->get_dependencies( array_unique( $this->queue ), array( 'static' ) ) as $id => $script_module ) { // Don't preload if it's marked for enqueue. if ( ! in_array( $id, $this->queue, true ) ) { - echo sprintf( - '', + $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' ), - 'auto' !== $script_module['fetchpriority'] ? sprintf( ' fetchpriority="%s"', esc_attr( $script_module['fetchpriority'] ) ) : '' + 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"; } } } @@ -374,18 +429,20 @@ private function get_marked_for_enqueue(): array { * Default is both. * @return array[] List of dependencies, keyed by script module identifier. */ - private function get_dependencies( array $ids, array $import_types = array( 'static', 'dynamic' ) ) { + 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(); - 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'] ]; + 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'] ]; + } } } return array_merge( $dependency_script_modules, $dependencies, $this->get_dependencies( array_keys( $dependencies ), $import_types ) ); @@ -394,6 +451,75 @@ function ( $dependency_script_modules, $id ) use ( $import_types ) { ); } + /** + * Gets all dependents of a script module. + * + * This is not recursive. + * + * @since 6.9.0 + * + * @see WP_Scripts::get_dependents() + * + * @param string $id The script ID. + * @return string[] Script module IDs. + */ + private function get_dependents( string $id ): array { + // Check if dependents map for the handle in question is present. If so, use it. + if ( isset( $this->dependents_map[ $id ] ) ) { + return $this->dependents_map[ $id ]; + } + + $dependents = array(); + + // Iterate over all registered scripts, finding dependents of the script passed to this method. + foreach ( $this->registered as $registered_id => $args ) { + if ( in_array( $id, wp_list_pluck( $args['dependencies'], 'id' ), true ) ) { + $dependents[] = $registered_id; + } + } + + // Add the module's dependents to the map to ease future lookups. + $this->dependents_map[ $id ] = $dependents; + + return $dependents; + } + + /** + * Gets all recursive dependents of a script module. + * + * @since 6.9.0 + * + * @see WP_Scripts::get_dependents() + * + * @param string $id The script ID. + * @return string[] Script module IDs. + */ + private function get_recursive_dependents( string $id ): array { + $get = function ( string $id, array $checked = array() ) use ( &$get ): 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(); + } + + // Mark this script module as checked to guard against infinite recursion. + $checked[ $id ] = true; + + $dependents = array(); + foreach ( $this->get_dependents( $id ) as $dependent ) { + $dependents = array_merge( + $dependents, + array( $dependent ), + $get( $dependent, $checked ) + ); + } + + return $dependents; + }; + + return array_unique( $get( $id ) ); + } + /** * Gets the versioned URL for a script module src. * diff --git a/src/wp-includes/class-wp-scripts.php b/src/wp-includes/class-wp-scripts.php index 71e85a3009577..61ad4c8fbd630 100644 --- a/src/wp-includes/class-wp-scripts.php +++ b/src/wp-includes/class-wp-scripts.php @@ -127,7 +127,7 @@ class WP_Scripts extends WP_Dependencies { * Used to optimize recursive dependency tree checks. * * @since 6.3.0 - * @var array + * @var array */ private $dependents_map = array(); @@ -439,9 +439,24 @@ 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'] && $this->is_valid_fetchpriority( $obj->extra['fetchpriority'] ) ) { - $attr['fetchpriority'] = $obj->extra['fetchpriority']; + + // Determine fetchpriority. + $original_fetchpriority = isset( $obj->extra['fetchpriority'] ) ? $obj->extra['fetchpriority'] : null; + if ( null === $original_fetchpriority || ! $this->is_valid_fetchpriority( $original_fetchpriority ) ) { + $original_fetchpriority = 'auto'; + } + $actual_fetchpriority = $this->get_highest_fetchpriority_with_dependents( $handle ); + if ( null === $actual_fetchpriority ) { + // If null, it's likely this script was not explicitly enqueued, so in this case use the original priority. + $actual_fetchpriority = $original_fetchpriority; + } + if ( is_string( $actual_fetchpriority ) && 'auto' !== $actual_fetchpriority ) { + $attr['fetchpriority'] = $actual_fetchpriority; } + if ( $original_fetchpriority !== $actual_fetchpriority ) { + $attr['data-wp-fetchpriority'] = $original_fetchpriority; + } + $tag = $translations . $ie_conditional_prefix . $before_script; $tag .= wp_get_script_tag( $attr ); $tag .= $after_script . $ie_conditional_suffix; @@ -898,6 +913,8 @@ public function add_data( $handle, $key, $value ) { /** * Gets all dependents of a script. * + * This is not recursive. + * * @since 6.3.0 * * @param string $handle The script handle. @@ -1051,6 +1068,62 @@ private function filter_eligible_strategies( $handle, $eligible_strategies = nul return $eligible_strategies; } + /** + * Gets the highest fetch priority for a given script and all of its dependent scripts. + * + * @since 6.9.0 + * @see self::filter_eligible_strategies() + * @see WP_Script_Modules::get_highest_fetchpriority_with_dependents() + * + * @param string $handle Script module ID. + * @param array $checked Optional. An array of already checked script handles, used to avoid recursive loops. + * @return string|null Highest fetch priority for the script and its dependents. + */ + private function get_highest_fetchpriority_with_dependents( string $handle, array $checked = array() ): ?string { + // If there is a recursive dependency, return early. + if ( isset( $checked[ $handle ] ) ) { + return null; + } + + // Mark this handle as checked to guard against infinite recursion. + $checked[ $handle ] = true; + + // Abort if the script is not enqueued or a dependency of an enqueued script. + if ( ! $this->query( $handle, 'enqueued' ) ) { + return null; + } + + $fetchpriority = $this->get_data( $handle, 'fetchpriority' ); + if ( ! $this->is_valid_fetchpriority( $fetchpriority ) ) { + $fetchpriority = 'auto'; + } + + static $priorities = array( + 'low', + 'auto', + 'high', + ); + $high_priority_index = count( $priorities ) - 1; + + $highest_priority_index = (int) array_search( $fetchpriority, $priorities, true ); + if ( $highest_priority_index !== $high_priority_index ) { + foreach ( $this->get_dependents( $handle ) as $dependent_handle ) { + $dependent_priority = $this->get_highest_fetchpriority_with_dependents( $dependent_handle, $checked ); + if ( is_string( $dependent_priority ) ) { + $highest_priority_index = max( + $highest_priority_index, + (int) array_search( $dependent_priority, $priorities, true ) + ); + if ( $highest_priority_index === $high_priority_index ) { + break; + } + } + } + } + + return $priorities[ $highest_priority_index ]; + } + /** * Gets data for inline scripts registered for a specific handle. * diff --git a/src/wp-includes/script-modules.php b/src/wp-includes/script-modules.php index 0d284833bea09..82e7635f1c538 100644 --- a/src/wp-includes/script-modules.php +++ b/src/wp-includes/script-modules.php @@ -181,9 +181,21 @@ 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. + /* + * 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 . + * 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. + */ $args = array(); - if ( str_starts_with( $script_module_id, '@wordpress/interactivity' ) || str_starts_with( $script_module_id, '@wordpress/block-library' ) ) { + if ( + str_starts_with( $script_module_id, '@wordpress/interactivity' ) || + str_starts_with( $script_module_id, '@wordpress/block-library' ) || + '@wordpress/a11y' === $script_module_id + ) { $args['fetchpriority'] = 'low'; } diff --git a/tests/phpunit/tests/dependencies/scripts.php b/tests/phpunit/tests/dependencies/scripts.php index c7a0d473a8221..bd4cbd27a49b4 100644 --- a/tests/phpunit/tests/dependencies/scripts.php +++ b/tests/phpunit/tests/dependencies/scripts.php @@ -1232,10 +1232,11 @@ public function test_defer_with_async_dependent() { 'fetchpriority' => 'high', ) ); + // Note: All of these scripts have fetchpriority=high because the leaf dependent script has that fetch priority. $output = get_echo( 'wp_print_scripts' ); - $expected = "\n"; - $expected .= "\n"; - $expected .= "\n"; + $expected = "\n"; + $expected .= "\n"; + $expected .= "\n"; $expected .= "\n"; $this->assertEqualHTML( $expected, $output, '', 'Scripts registered as defer but that have dependents that are async are expected to have said dependents deferred.' ); @@ -1343,6 +1344,150 @@ public function test_invalid_fetchpriority_on_alias() { $this->assertArrayNotHasKey( 'fetchpriority', wp_scripts()->registered['alias']->extra ); } + /** + * Data provider. + * + * @return array + */ + public function data_provider_to_test_fetchpriority_bumping(): array { + return array( + 'enqueue_bajo' => array( + 'enqueues' => array( 'bajo' ), + 'expected' => '', + ), + 'enqueue_auto' => array( + 'enqueues' => array( 'auto' ), + 'expected' => ' + + + ', + ), + 'enqueue_alto' => array( + 'enqueues' => array( 'alto' ), + 'expected' => ' + + + + ', + ), + ); + } + + /** + * Tests a higher fetchpriority on a dependent script module causes the fetchpriority of a dependency script module to be bumped. + * + * @ticket 61734 + * + * @covers WP_Scripts::get_dependents + * @covers WP_Scripts::get_highest_fetchpriority_with_dependents + * @covers WP_Scripts::do_item + * + * @dataProvider data_provider_to_test_fetchpriority_bumping + */ + public function test_fetchpriority_bumping( array $enqueues, string $expected ) { + wp_register_script( 'bajo', '/bajo.js', array(), null, array( 'fetchpriority' => 'low' ) ); + wp_register_script( 'auto', '/auto.js', array( 'bajo' ), null, array( 'fetchpriority' => 'auto' ) ); + wp_register_script( 'alto', '/alto.js', array( 'auto' ), null, array( 'fetchpriority' => 'high' ) ); + + foreach ( $enqueues as $enqueue ) { + wp_enqueue_script( $enqueue ); + } + + $actual = get_echo( 'wp_print_scripts' ); + $this->assertEqualHTML( $expected, $actual, '', "Snapshot:\n$actual" ); + } + + /** + * Tests bumping fetchpriority with complex dependency graph. + * + * @ticket 61734 + * @link https://github.com/WordPress/wordpress-develop/pull/9770#issuecomment-3280065818 + * + * @covers WP_Scripts::get_dependents + * @covers WP_Scripts::get_highest_fetchpriority_with_dependents + * @covers WP_Scripts::do_item + */ + public function test_fetchpriority_bumping_a_to_z() { + wp_register_script( 'a', '/a.js', array( 'b' ), null, array( 'fetchpriority' => 'low' ) ); + wp_register_script( 'b', '/b.js', array( 'c' ), null, array( 'fetchpriority' => 'auto' ) ); + wp_register_script( 'c', '/c.js', array( 'd', 'e' ), null, array( 'fetchpriority' => 'auto' ) ); + wp_register_script( 'd', '/d.js', array( 'z' ), null, array( 'fetchpriority' => 'high' ) ); + wp_register_script( 'e', '/e.js', array(), null, array( 'fetchpriority' => 'auto' ) ); + + wp_register_script( 'x', '/x.js', array( 'd', 'y' ), null, array( 'fetchpriority' => 'high' ) ); + wp_register_script( 'y', '/y.js', array( 'z' ), null, array( 'fetchpriority' => 'auto' ) ); + wp_register_script( 'z', '/z.js', array(), null, array( 'fetchpriority' => 'auto' ) ); + + wp_enqueue_script( 'a' ); + wp_enqueue_script( 'x' ); + + $actual = get_echo( 'wp_print_scripts' ); + $expected = ' + + + + + + + + + '; + $this->assertEqualHTML( $expected, $actual, '', "Snapshot:\n$actual" ); + } + + /** + * Tests that printing a script without enqueueing has the same output as when it is enqueued. + * + * @ticket 61734 + * + * @covers WP_Scripts::do_item + * @covers WP_Scripts::do_items + * @covers ::wp_default_scripts + * + * @dataProvider data_provider_enqueue_or_not_to_enqueue + */ + public function test_printing_default_script_comment_reply_enqueued_or_not_enqueued( bool $enqueue ) { + $wp_scripts = wp_scripts(); + wp_default_scripts( $wp_scripts ); + + $this->assertArrayHasKey( 'comment-reply', $wp_scripts->registered ); + $wp_scripts->registered['comment-reply']->ver = null; + $this->assertArrayHasKey( 'fetchpriority', $wp_scripts->registered['comment-reply']->extra ); + $this->assertSame( 'low', $wp_scripts->registered['comment-reply']->extra['fetchpriority'] ); + $this->assertArrayHasKey( 'strategy', $wp_scripts->registered['comment-reply']->extra ); + $this->assertSame( 'async', $wp_scripts->registered['comment-reply']->extra['strategy'] ); + if ( $enqueue ) { + wp_enqueue_script( 'comment-reply' ); + $markup = get_echo( array( $wp_scripts, 'do_items' ), array( false ) ); + } else { + $markup = get_echo( array( $wp_scripts, 'do_items' ), array( array( 'comment-reply' ) ) ); + } + + $this->assertEqualHTML( + sprintf( + '', + includes_url( 'js/comment-reply.js' ) + ), + $markup + ); + } + + /** + * Data provider for test_default_scripts_comment_reply_not_enqueued. + * + * @return array[] + */ + public static function data_provider_enqueue_or_not_to_enqueue(): array { + return array( + 'not_enqueued' => array( + false, + ), + 'enqueued' => array( + true, + ), + ); + } + /** * Tests that scripts registered as defer become blocking when their dependents chain are all blocking. * diff --git a/tests/phpunit/tests/script-modules/wpScriptModules.php b/tests/phpunit/tests/script-modules/wpScriptModules.php index a912a0e95b268..97be85f071489 100644 --- a/tests/phpunit/tests/script-modules/wpScriptModules.php +++ b/tests/phpunit/tests/script-modules/wpScriptModules.php @@ -67,9 +67,17 @@ public function get_enqueued_script_modules(): array { $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', + $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-' ) + ) ); } @@ -112,9 +120,17 @@ public function get_preloaded_script_modules(): array { $id = preg_replace( '/-js-modulepreload$/', '', $p->get_attribute( 'id' ) ); $fetchpriority = $p->get_attribute( 'fetchpriority' ); - $preloads[ $id ] = array( - 'url' => $p->get_attribute( 'href' ), - 'fetchpriority' => is_string( $fetchpriority ) ? $fetchpriority : 'auto', + $preloads[ $id ] = array_merge( + array( + 'url' => $p->get_attribute( 'href' ), + '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-' ) + ) ); } @@ -271,15 +287,17 @@ public function test_comprehensive_methods( bool $use_global_function, bool $onl 'preload_links' => array( 'b-dep' => array( 'url' => '/b-dep.js?ver=99.9.9', - 'fetchpriority' => 'auto', + 'fetchpriority' => 'low', // Propagates from 'b'. ), 'c-dep' => array( - 'url' => '/c-static.js?ver=99.9.9', - 'fetchpriority' => 'low', + 'url' => '/c-static.js?ver=99.9.9', + 'fetchpriority' => 'auto', // Not 'low' because the dependent script 'c' has a fetchpriority of 'auto'. + 'data-wp-fetchpriority' => 'low', ), 'c-static-dep' => array( - 'url' => '/c-static-dep.js?ver=99.9.9', - 'fetchpriority' => 'high', + 'url' => '/c-static-dep.js?ver=99.9.9', + 'fetchpriority' => 'auto', // Propagated from 'c'. + 'data-wp-fetchpriority' => 'high', ), 'd-static-dep' => array( 'url' => '/d-static-dep.js?ver=99.9.9', @@ -732,6 +750,7 @@ public function test_wp_enqueue_preloaded_static_dependencies() { 'import' => 'dynamic', ), ) + // Note: The default fetchpriority=auto is upgraded to high because the dependent script module 'static-dep' has a high fetch priority. ); $this->script_modules->register( 'static-dep', @@ -759,9 +778,9 @@ public function test_wp_enqueue_preloaded_static_dependencies() { $this->assertCount( 2, $preloaded_script_modules ); $this->assertStringStartsWith( '/static-dep.js', $preloaded_script_modules['static-dep']['url'] ); - $this->assertSame( 'high', $preloaded_script_modules['static-dep']['fetchpriority'] ); + $this->assertSame( 'auto', $preloaded_script_modules['static-dep']['fetchpriority'] ); // Not 'high' $this->assertStringStartsWith( '/nested-static-dep.js', $preloaded_script_modules['nested-static-dep']['url'] ); - $this->assertSame( 'auto', $preloaded_script_modules['nested-static-dep']['fetchpriority'] ); + $this->assertSame( 'auto', $preloaded_script_modules['nested-static-dep']['fetchpriority'] ); // Auto because the enqueued script foo has the fetchpriority of auto. $this->assertArrayNotHasKey( 'dynamic-dep', $preloaded_script_modules ); $this->assertArrayNotHasKey( 'nested-dynamic-dep', $preloaded_script_modules ); $this->assertArrayNotHasKey( 'no-dep', $preloaded_script_modules ); @@ -971,7 +990,7 @@ public function test_version_is_propagated_correctly() { $preloaded_script_modules = $this->get_preloaded_script_modules(); $this->assertSame( '/dep.js?ver=2.0', $preloaded_script_modules['dep']['url'] ); - $this->assertSame( 'high', $preloaded_script_modules['dep']['fetchpriority'] ); + $this->assertSame( 'auto', $preloaded_script_modules['dep']['fetchpriority'] ); // Because 'foo' has a priority of 'auto'. } /** @@ -1343,6 +1362,319 @@ public function test_set_fetchpriority_with_invalid_value() { $this->assertSame( 'auto', $registered_modules['foo']['fetchpriority'] ); } + /** + * Data provider. + * + * @return array + */ + public function data_provider_to_test_fetchpriority_bumping(): array { + return array( + 'enqueue_bajo' => array( + 'enqueues' => array( 'bajo' ), + 'expected' => array( + 'preload_links' => array(), + 'script_tags' => array( + 'bajo' => array( + 'url' => '/bajo.js', + 'fetchpriority' => 'high', + 'data-wp-fetchpriority' => 'low', + ), + ), + 'import_map' => array( + 'dyno' => '/dyno.js', + ), + ), + ), + 'enqueue_auto' => array( + 'enqueues' => array( 'auto' ), + 'expected' => array( + 'preload_links' => array( + 'bajo' => array( + 'url' => '/bajo.js', + 'fetchpriority' => 'auto', + 'data-wp-fetchpriority' => 'low', + ), + ), + 'script_tags' => array( + 'auto' => array( + 'url' => '/auto.js', + 'fetchpriority' => 'high', + 'data-wp-fetchpriority' => 'auto', + ), + ), + 'import_map' => array( + 'bajo' => '/bajo.js', + 'dyno' => '/dyno.js', + ), + ), + ), + 'enqueue_alto' => 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', + ), + ), + 'script_tags' => array( + 'alto' => array( + 'url' => '/alto.js', + 'fetchpriority' => 'high', + ), + ), + 'import_map' => array( + 'auto' => '/auto.js', + 'bajo' => '/bajo.js', + 'dyno' => '/dyno.js', + ), + ), + ), + ); + } + + /** + * Tests a higher fetchpriority on a dependent script module causes the fetchpriority of a dependency script module to be bumped. + * + * @ticket 61734 + * + * @covers WP_Script_Modules::print_enqueued_script_modules + * @covers WP_Script_Modules::get_dependents + * @covers WP_Script_Modules::get_recursive_dependents + * @covers WP_Script_Modules::get_highest_fetchpriority + * @covers WP_Script_Modules::print_script_module_preloads + * + * @dataProvider data_provider_to_test_fetchpriority_bumping + */ + public function test_fetchpriority_bumping( array $enqueues, array $expected ) { + $this->script_modules->register( + 'dyno', + '/dyno.js', + array(), + null, + array( 'fetchpriority' => 'low' ) // This won't show up anywhere since it is a dynamic import dependency. + ); + + $this->script_modules->register( + 'bajo', + '/bajo.js', + array( + array( + 'id' => 'dyno', + 'import' => 'dynamic', + ), + ), + null, + array( 'fetchpriority' => 'low' ) + ); + + $this->script_modules->register( + 'auto', + '/auto.js', + array( + array( + 'id' => 'bajo', + 'import' => 'static', + ), + ), + null, + array( 'fetchpriority' => 'auto' ) + ); + $this->script_modules->register( + 'alto', + '/alto.js', + array( 'auto' ), + null, + array( 'fetchpriority' => 'high' ) + ); + + foreach ( $enqueues as $enqueue ) { + $this->script_modules->enqueue( $enqueue ); + } + + $actual = array( + 'preload_links' => $this->get_preloaded_script_modules(), + 'script_tags' => $this->get_enqueued_script_modules(), + 'import_map' => $this->get_import_map(), + ); + $this->assertSame( + $expected, + $actual, + "Snapshot:\n" . var_export( $actual, true ) + ); + } + + /** + * Tests bumping fetchpriority with complex dependency graph. + * + * @ticket 61734 + * @link https://github.com/WordPress/wordpress-develop/pull/9770#issuecomment-3280065818 + * + * @covers WP_Script_Modules::print_enqueued_script_modules + * @covers WP_Script_Modules::get_dependents + * @covers WP_Script_Modules::get_recursive_dependents + * @covers WP_Script_Modules::get_highest_fetchpriority + * @covers WP_Script_Modules::print_script_module_preloads + */ + public function test_fetchpriority_bumping_a_to_z() { + wp_register_script_module( 'a', '/a.js', array( 'b' ), null, array( 'fetchpriority' => 'low' ) ); + wp_register_script_module( 'b', '/b.js', array( 'c' ), null, array( 'fetchpriority' => 'auto' ) ); + wp_register_script_module( 'c', '/c.js', array( 'd', 'e' ), null, array( 'fetchpriority' => 'auto' ) ); + wp_register_script_module( 'd', '/d.js', array( 'z' ), null, array( 'fetchpriority' => 'high' ) ); + wp_register_script_module( 'e', '/e.js', array(), null, array( 'fetchpriority' => 'auto' ) ); + + wp_register_script_module( 'x', '/x.js', array( 'd', 'y' ), null, array( 'fetchpriority' => 'high' ) ); + wp_register_script_module( 'y', '/y.js', array( 'z' ), null, array( 'fetchpriority' => 'auto' ) ); + wp_register_script_module( 'z', '/z.js', array(), null, array( 'fetchpriority' => 'auto' ) ); + + // The fetch priorities are derived from these enqueued dependents. + wp_enqueue_script_module( 'a' ); + wp_enqueue_script_module( 'x' ); + + $actual = get_echo( array( wp_script_modules(), 'print_script_module_preloads' ) ); + $actual .= get_echo( array( wp_script_modules(), 'print_enqueued_script_modules' ) ); + $expected = ' + + + + + + + + + '; + $this->assertEqualHTML( $expected, $actual, '', "Snapshot:\n$actual" ); + } + + /** + * Tests bumping fetchpriority with complex dependency graph. + * + * @ticket 61734 + * @link https://github.com/WordPress/wordpress-develop/pull/9770#issuecomment-3284266884 + * + * @covers WP_Script_Modules::print_enqueued_script_modules + * @covers WP_Script_Modules::get_dependents + * @covers WP_Script_Modules::get_recursive_dependents + * @covers WP_Script_Modules::get_highest_fetchpriority + * @covers WP_Script_Modules::print_script_module_preloads + */ + public function test_fetchpriority_propagation() { + // The high fetchpriority for this module will be disregarded because its enqueued dependent has a non-high priority. + wp_register_script_module( 'a', '/a.js', array( 'd', 'e' ), null, array( 'fetchpriority' => 'high' ) ); + wp_register_script_module( 'b', '/b.js', array( 'e' ), null ); + wp_register_script_module( 'c', '/c.js', array( 'e', 'f' ), null ); + wp_register_script_module( 'd', '/d.js', array(), null ); + // The low fetchpriority for this module will be disregarded because its enqueued dependent has a non-low priority. + wp_register_script_module( 'e', '/e.js', array(), null, array( 'fetchpriority' => 'low' ) ); + wp_register_script_module( 'f', '/f.js', array(), null ); + + wp_register_script_module( 'x', '/x.js', array( 'a' ), null, array( 'fetchpriority' => 'low' ) ); + wp_register_script_module( 'y', '/y.js', array( 'b' ), null, array( 'fetchpriority' => 'auto' ) ); + wp_register_script_module( 'z', '/z.js', array( 'c' ), null, array( 'fetchpriority' => 'high' ) ); + + wp_enqueue_script_module( 'x' ); + wp_enqueue_script_module( 'y' ); + wp_enqueue_script_module( 'z' ); + + $actual = get_echo( array( wp_script_modules(), 'print_script_module_preloads' ) ); + $actual .= get_echo( array( wp_script_modules(), 'print_enqueued_script_modules' ) ); + $expected = ' + + + + + + + + + + '; + $this->assertEqualHTML( $expected, $actual, '', "Snapshot:\n$actual" ); + } + + /** + * Tests that default script modules are printed as expected. + * + * @covers ::wp_default_script_modules + * @covers WP_Script_Modules::print_script_module_preloads + * @covers WP_Script_Modules::print_enqueued_script_modules + */ + public function test_default_script_modules() { + wp_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 = $this->normalize_markup_for_snapshot( $actual ); + + $expected = ' + + + + '; + $this->assertEqualHTML( $expected, $actual, '', "Snapshot:\n$actual" ); + } + + /** + * Tests that a dependent with high priority for default script modules with a low fetch priority are printed as expected. + * + * @covers ::wp_default_script_modules + * @covers WP_Script_Modules::print_script_module_preloads + * @covers WP_Script_Modules::print_enqueued_script_modules + */ + public function test_dependent_of_default_script_modules() { + wp_default_script_modules(); + wp_enqueue_script_module( + 'super-important', + '/super-important-module.js', + array( '@wordpress/a11y', '@wordpress/block-library/navigation/view' ), + null, + array( 'fetchpriority' => 'high' ) + ); + + $actual = get_echo( array( wp_script_modules(), 'print_script_module_preloads' ) ); + $actual .= get_echo( array( wp_script_modules(), 'print_enqueued_script_modules' ) ); + + $actual = $this->normalize_markup_for_snapshot( $actual ); + + $expected = ' + + + + + '; + $this->assertEqualHTML( $expected, $actual, '', "Snapshot:\n$actual" ); + } + + /** + * Normalizes markup for snapshot. + * + * @param string $markup Markup. + * @return string Normalized markup. + */ + private function normalize_markup_for_snapshot( string $markup ): string { + $processor = new WP_HTML_Tag_Processor( $markup ); + $clean_url = static function ( string $url ): string { + $url = preg_replace( '#^https?://[^/]+#', '', $url ); + return remove_query_arg( 'ver', $url ); + }; + while ( $processor->next_tag() ) { + if ( 'LINK' === $processor->get_tag() && is_string( $processor->get_attribute( 'href' ) ) ) { + $processor->set_attribute( 'href', $clean_url( $processor->get_attribute( 'href' ) ) ); + } elseif ( 'SCRIPT' === $processor->get_tag() && is_string( $processor->get_attribute( 'src' ) ) ) { + $processor->set_attribute( 'src', $clean_url( $processor->get_attribute( 'src' ) ) ); + } + } + return $processor->get_updated_html(); + } + /** * Tests that directly manipulating the queue works as expected. *