Skip to content
Closed
Show file tree
Hide file tree
Changes from 7 commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
ee14eb6
Script Modules: Bump fetchpriority for dependencies to be as high as …
westonruter Sep 6, 2025
0222ca9
Add missing phpdoc param
westonruter Sep 6, 2025
e2d4676
Explicitly set a11y module to have fetchpriority=low
westonruter Sep 6, 2025
c76ceb0
Add support for fetchpriority bumping in WP_Scripts
westonruter Sep 6, 2025
e652cbd
Account for whether a dependent script is actually enqueued
westonruter Sep 6, 2025
3dd0e0f
Add tests for complex dependency graphs
westonruter Sep 11, 2025
c59f440
Let fetchpriority of script module be determined be enqueued module
westonruter Sep 17, 2025
e453a53
Merge branch 'trunk' of https://github.com/WordPress/wordpress-develo…
westonruter Sep 29, 2025
546e7a7
Stop iterating once highest priority found
westonruter Sep 29, 2025
a03337a
Simplify get_recursive_dependents() with better defensive coding
westonruter Sep 29, 2025
0b0f328
Merge branch 'trunk' into add/dependent-fetchpriority-harmony
westonruter Oct 8, 2025
334a82b
Better harmonize get_highest_fetchpriority implementations
westonruter Oct 8, 2025
cf809c8
Merge branch 'trunk' into add/dependent-fetchpriority-harmony
westonruter Oct 10, 2025
3f5ae8d
Update _WP_Dependency::$ver to allow null in addition to string|false
westonruter Oct 12, 2025
4dd633f
Account for directly printed scripts without enqueueing
westonruter Oct 12, 2025
95789ba
Ensure fetchpriority is processed for scripts without extra key set
westonruter Oct 12, 2025
86c13c9
Merge branch 'trunk' of https://github.com/WordPress/wordpress-develo…
westonruter Oct 13, 2025
98abb51
Switch back to enqueue
westonruter Oct 13, 2025
eada641
Merge branch 'trunk' of https://github.com/WordPress/wordpress-develo…
westonruter Oct 14, 2025
05e3173
Restore array_unique()
westonruter Oct 14, 2025
ed8a53f
Add test for wp_default_script_modules()
westonruter Oct 14, 2025
9d021ae
Test that high priority dependent of default script modules causes de…
westonruter Oct 14, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
146 changes: 134 additions & 12 deletions src/wp-includes/class-wp-script-modules.php
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string[]>
*/
private $dependents_map = array();

/**
* Registers the script module if no script module with that script module
* identifier has already been registered.
Expand Down Expand Up @@ -275,6 +284,34 @@ 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',
);

$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 )
);
}
}

return $priorities[ $highest_priority_index ];
}

/**
* Prints the enqueued script modules using script tags with type="module"
* attributes.
Expand All @@ -288,31 +325,46 @@ 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.
*
* @since 6.5.0
*/
public function print_script_module_preloads() {
foreach ( $this->get_dependencies( array_keys( $this->get_marked_for_enqueue() ), array( 'static' ) ) as $id => $script_module ) {
$enqueued_ids = array_keys( $this->get_marked_for_enqueue() );
foreach ( $this->get_dependencies( $enqueued_ids, array( 'static' ) ) as $id => $script_module ) {
// Don't preload if it's marked for enqueue.
if ( true !== $script_module['enqueue'] ) {
echo sprintf(
'<link rel="modulepreload" href="%s" id="%s"%s>',
$enqueued_dependents = array_intersect( $this->get_recursive_dependents( $id ), $enqueued_ids );
$highest_fetchpriority = $this->get_highest_fetchpriority( $enqueued_dependents );
printf(
'<link rel="modulepreload" href="%s" id="%s"',
esc_url( $this->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 '>';
}
}
}
Expand Down Expand Up @@ -383,16 +435,16 @@ 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'] ] )
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'] ];
}
Expand All @@ -403,6 +455,76 @@ 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 ];
}
Comment on lines +468 to +470
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There's no invalidation in this caching. That's OK right now because we only start exploring the graph when it's time to start outputting tags. The method is private, and it's likely fine, but there is an implicit timing constraint here where this can contain inaccurate information if scripts are added later.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As I just wrote in #9867 (comment), I think we should explicitly warn and noop when attempting to register/enqueue a script module after the importmap has been printed. This would avoid the issue with cache invalidation, and it would address problems that can arise if a script module with dependencies is enqueued without the dependencies being in the already-printed importmap.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think it's critical to figure.

Once the importmap is printed, there's nothing to be done, so the restriction seems reasonable. If this were in an output buffer (something I know you've been working on), there could be a possibility of updating the importmap later before flushing.

There's starting to be support for multiple importmaps, but FireFox does not support it. In the future we may be able to print an early and a late importmap.

https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/script/type/importmap#merging_multiple_import_maps


$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 null;
}

// Mark this script module as checked to guard against infinite recursion.
$checked[ $id ] = true;

$dependents = array();
foreach ( $this->get_dependents( $id ) as $dependent ) {
$dependents[] = $dependent;

$recursive_dependents = $get( $dependent, $checked );
if ( is_array( $recursive_dependents ) ) {
$dependents = array_merge( $dependents, $recursive_dependents );
}
}

return $dependents;
};

return array_unique( $get( $id ) );
}

/**
* Gets the versioned URL for a script module src.
*
Expand Down
75 changes: 72 additions & 3 deletions src/wp-includes/class-wp-scripts.php
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string[]>
*/
private $dependents_map = array();

Expand Down Expand Up @@ -425,8 +425,19 @@ 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'];

if ( isset( $obj->extra['fetchpriority'] ) ) {
$original_fetchpriority = $obj->extra['fetchpriority'];
if ( ! $this->is_valid_fetchpriority( $original_fetchpriority ) ) {
$original_fetchpriority = 'auto';
}
$actual_fetchpriority = $this->get_highest_fetchpriority_with_dependents( $handle );
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 );
Expand Down Expand Up @@ -870,6 +881,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.
Expand Down Expand Up @@ -1023,6 +1036,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<string, true> $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 $priority_mapping = array(
'low' => 0,
'auto' => 1,
'high' => 2,
);

$highest_priority_index = $priority_mapping[ $fetchpriority ];

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,
$priority_mapping[ $dependent_priority ]
);
}
}

$highest_priority = array_search( $highest_priority_index, $priority_mapping, true );
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the same functionality but implemented differently from in modules:

https://github.com/westonruter/wordpress-develop/blob/c59f440dfda0d1d23825e28e587bd5cfb15de45b/src/wp-includes/class-wp-script-modules.php#L296-L312

Notably, there the array_search happens in the loop to get a numeric value for fetchpriority comparison.

I'd like implementations to match.

There's also an opportunity to exit the loop as soon once a high priority is found (I commented similarly on the modules implementation).

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How about 334a82b?

if ( is_string( $highest_priority ) ) {
return $highest_priority;
}

return null;
}

/**
* Gets data for inline scripts registered for a specific handle.
*
Expand Down
16 changes: 14 additions & 2 deletions src/wp-includes/script-modules.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 <https://make.wordpress.org/core/2024/10/14/updates-to-script-modules-in-6-7/>.
* 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';
}

Expand Down
Loading
Loading