Skip to content

Conversation

@westonruter
Copy link
Member

@westonruter westonruter commented Sep 6, 2025

This is based on feedback from @sirreal in WordPress/gutenberg#70173 (review):

Script modules are registered with fetch priority. What if I enqueue a high fetch priority script that depends on a module registered with low? As the browser explores the dependency graph, will it determine that it should increase the priority of the dependency? Is that sort of priority dependence something that should be built into the core script modules system?

This PR ensures that if you have a dependent script module with fetchpriority=high, that any recursive dependencies will also get printed with fetchpriority=high regardless of the originally-registered fetchpriority, in which case the originally fetchpriority is emitted as a data-wp-fetchpriority attribute on the SCRIPT[type=module] or LINK[rel=modulepreload] for the sake of debugging. This is highly analogous to how non-module scripts check their dependents to see if any have async or defer, where if a blocking script depends on a defer script, the defer script will become blocking and a data-wp-strategy attribute is added to the SCRIPT tag.

The same is also implemented here for non-module scripts.

Also, this PR addresses this feedback in WordPress/gutenberg#70173 (comment):

There's @wordpress/a11y module that's not strictly an iAPI concept.

https://make.wordpress.org/core/2024/10/14/updates-to-script-modules-in-6-7/

I don't think other modules have been introduced, but I could have missed something. Should a11y be low as well?

The script @wordpress/a11y is set to have a fetchpriority=low.

Prompt for Gemini CLI:

Summarize the changes in this branch compared to origin/trunk. List out any API changes. Do a code review review of the changes. Anything amiss?

Output:

Summary of Changes

The core of this change is the introduction of a "fetchpriority bumping" mechanism for both traditional scripts and script modules. If a script with a higher fetchpriority depends on another script with a lower fetchpriority, the dependency's fetchpriority is elevated to match the dependent's. This ensures that all scripts in a critical dependency chain are loaded with the appropriate priority.

The key changes are:

  • WP_Script_Modules and WP_Scripts:
    • Both classes now have a new private method, get_highest_fetchpriority_with_dependents(), which recursively traverses the dependency tree to find the highest fetchpriority.
    • WP_Script_Modules has a new get_dependents() method (and a corresponding $dependents_map property for caching) to find scripts that depend on a given script.
    • The methods responsible for printing <script> and <link rel="modulepreload"> tags (print_enqueued_script_modules, print_script_module_preloads in WP_Script_Modules, and do_item in WP_Scripts) have been updated to use the new get_highest_fetchpriority_with_dependents() method.
    • If a script's fetchpriority is "bumped", the original priority is stored in a data-wp-fetchpriority attribute for debugging purposes.
  • wp_default_script_modules():
    • The @wordpress/a11y script module is now assigned a low fetchpriority by default.
  • Tests:
    • New PHPUnit tests have been added to verify the fetchpriority bumping logic for both scripts and script modules.

API Changes

There are no public API changes. The new methods are private, and the existing functions for registering and enqueuing scripts remain unchanged. The only difference is in the rendered HTML.

Code Review

The changes are well-implemented and adhere to WordPress coding standards.

  • Logic: The logic for determining the highest fetchpriority is sound and correctly handles recursive dependencies.
  • Performance: The use of a $dependents_map to cache the dependency tree is a good performance optimization.
  • Clarity: The addition of the data-wp-fetchpriority attribute is a thoughtful touch that will make it easier to debug script loading issues.
  • Testing: The new tests are comprehensive and cover the new functionality well.

I don't see anything amiss. The changes are a valuable improvement to the script loading functionality in WordPress.

Trac ticket: https://core.trac.wordpress.org/ticket/61734

TODO

  • Add tests for whether comment-reply and interactive blocks view modules are getting the expected fetchpriority.

This Pull Request is for code review only. Please keep all other discussion in the Trac ticket. Do not merge this Pull Request. See GitHub Pull Requests for Code Review in the Core Handbook for more details.

@github-actions
Copy link

github-actions bot commented Sep 6, 2025

The following accounts have interacted with this PR and/or linked issues. I will continue to update these lists as activity occurs. You can also manually ask me to refresh this list by adding the props-bot label.

Core Committers: Use this line as a base for the props when committing in SVN:

Props westonruter, jonsurrell.

To understand the WordPress project's expectations around crediting contributors, please review the Contributor Attribution page in the Core Handbook.

@github-actions
Copy link

github-actions bot commented Sep 6, 2025

Test using WordPress Playground

The changes in this pull request can previewed and tested using a WordPress Playground instance.

WordPress Playground is an experimental project that creates a full WordPress instance entirely within the browser.

Some things to be aware of

  • The Plugin and Theme Directories cannot be accessed within Playground.
  • All changes will be lost when closing a tab with a Playground instance.
  • All changes will be lost when refreshing the page.
  • A fresh instance is created each time the link below is clicked.
  • Every time this pull request is updated, a new ZIP file containing all changes is created. If changes are not reflected in the Playground instance,
    it's possible that the most recent build failed, or has not completed. Check the list of workflow runs to be sure.

For more details about these limitations and more, check out the Limitations page in the WordPress Playground documentation.

Test this pull request with WordPress Playground.

@westonruter westonruter changed the title Script Modules: Bump fetchpriority for dependencies to be as high as recursive dependents Script Loader: Bump fetchpriority for dependencies to be as high as recursive dependents Sep 6, 2025
@westonruter westonruter changed the title Script Loader: Bump fetchpriority for dependencies to be as high as recursive dependents Script Loader: Bump fetchpriority for dependencies to be as high as recursive dependents for scripts and script modules Sep 6, 2025
@sirreal
Copy link
Member

sirreal commented Sep 11, 2025

I've been reviewing and considering this. What this does is find the highest fetch priority in a dependency graph and apply that fetch priority to the entire graph. Am I interpreting the changes correctly?

Let's take an example with two enqueues (entry points), A and X:

---
config:
  theme: base
---
flowchart LR
    classDef HI stroke-width:1px, stroke:#ff0000, fill:#ff000022
    classDef LO stroke-width:1px, stroke:#0000ff, fill:#0000ff22
    classDef ENQUEUE stroke-width:4px

    A["A::low"]
    A:::LO
    B["B"]
    C["C"]
    E["E"]
    D["D::high"]
    D:::HI
    
    X["X::high"]
    Y["Y"]
    Z["Z"]
    X:::HI
    
    A --> B --> C --> E & D
    A:::ENQUEUE

    X --> D & Y --> Z
    X:::ENQUEUE
Loading

With this PR, the D module's high priority would "infect" the entire graph and make everything high priority. That's not desirable, is it? We'd wind up with this:

---
config:
  theme: base
---
flowchart LR
    classDef HI stroke-width:1px, stroke:#ff0000, fill:#ff000022
    classDef LO stroke-width:1px, stroke:#0000ff, fill:#0000ff22
    classDef ENQUEUE stroke-width:4px

    A:::HI
    B:::HI
    C:::HI
    E:::HI
    Y:::HI
    Z:::HI

    A["A::low"]
    B["B"]
    C["C"]
    E["E"]
    D["D::high"]
    D:::HI
    
    X["X::high"]
    Y["Y"]
    Z["Z"]
    X:::HI
    
    A --> B --> C --> E & D
    A:::ENQUEUE

    X --> D & Y --> Z
    X:::ENQUEUE
Loading

It seems like the fetch priority should be directed from dependent to dependency. In fact, I wonder whether modules that aren't enqueued should have a fetchpriority at all or if fetchpriority should be associated with enqueue, and cascade to dependents. In other words, dependency modules would inherit fetch priority from their dependents.

I think our ideal result graph would look like this:

---
config:
  theme: base
---
flowchart LR
    classDef HI stroke-width:1px, stroke:#ff0000, fill:#ff000022
    classDef LO stroke-width:1px, stroke:#0000ff, fill:#0000ff22
    classDef ENQUEUE stroke-width:4px

    A:::LO
    B:::LO
    C:::LO
    E:::LO
    Y:::HI
    Z:::HI

    A["A::low"]
    B["B"]
    C["C"]
    E["E"]
    D["D::high"]
    D:::HI
    
    X["X::high"]
    Y["Y"]
    Z["Z"]
    X:::HI
    
    A --> B --> C --> E & D
    A:::ENQUEUE

    X --> D & Y --> Z
    X:::ENQUEUE
Loading

@westonruter
Copy link
Member Author

westonruter commented Sep 11, 2025

@sirreal I think what you're describing is almost exactly what was implemented. It is indeed being directed from dependent to dependency. This follows the same approach as was implemented by the script loading strategies, where the dependent dictates what loading strategy a dependency is eligible to have. For example, a dependent with a blocking loading strategy cannot have a dependency with a defer loading strategy, or else they get loaded out of order. In this case, the dependency's defer gets downgraded to blocking.

I've added two test cases that show what happens with both classic scripts and script modules in 3dd0e0f, using the dependency graph you came up with. The result is it comes up with the ideal result graph, with the exception that it is also taking into account the fetchpriority of auto:

The initial graph you depicted actually gets resolved as follows:

---
config:
  theme: base
---
flowchart LR
    classDef HI stroke-width:1px, stroke:#ff0000, fill:#ff000022
    classDef LO stroke-width:1px, stroke:#0000ff, fill:#0000ff22
    classDef ENQUEUE stroke-width:4px

    A:::LO
    Y:::HI
    Z:::HI

    A["A::low"]
    B["B"]
    C["C"]
    E["E"]
    D["D::high"]
    D:::HI
    
    X["X::high"]
    Y["Y"]
    Z["Z"]
    X:::HI
    
    A --> B --> C --> E & D
    A:::ENQUEUE

    X --> D & Y --> Z
    X:::ENQUEUE
Loading

Classic scripts:

<script type="text/javascript" src="/z.js" id="z-js" fetchpriority="high" data-wp-fetchpriority="auto"></script>
<script type="text/javascript" src="/d.js" id="d-js" fetchpriority="high"></script>
<script type="text/javascript" src="/e.js" id="e-js"></script>
<script type="text/javascript" src="/c.js" id="c-js"></script>
<script type="text/javascript" src="/b.js" id="b-js"></script>
<script type="text/javascript" src="/a.js" id="a-js" fetchpriority="low"></script>
<script type="text/javascript" src="/y.js" id="y-js" fetchpriority="high" data-wp-fetchpriority="auto"></script>
<script type="text/javascript" src="/x.js" id="x-js" fetchpriority="high"></script>

Script modules:

<link rel="modulepreload" href="/b.js" id="b-js-modulepreload">
<link rel="modulepreload" href="/c.js" id="c-js-modulepreload">
<link rel="modulepreload" href="/d.js" id="d-js-modulepreload" fetchpriority="high">
<link rel="modulepreload" href="/e.js" id="e-js-modulepreload">
<link rel="modulepreload" href="/z.js" id="z-js-modulepreload" fetchpriority="high" data-wp-fetchpriority="auto">
<link rel="modulepreload" href="/y.js" id="y-js-modulepreload" fetchpriority="high" data-wp-fetchpriority="auto">
<script type="module" src="/a.js" id="a-js-module" fetchpriority="low"></script>
<script type="module" src="/x.js" id="x-js-module" fetchpriority="high"></script>

@sirreal
Copy link
Member

sirreal commented Sep 12, 2025

I think what you're describing is almost exactly what was implemented.

That's good, I misinterpreted the changes. Thanks for the additional test and clarification, they're helpful 👍

I'll do my best to give this thorough review early next week.

Do you have thoughts on this point?

I wonder whether modules that aren't enqueued should have a fetchpriority at all or if fetchpriority should be associated with enqueue, and cascade to dependents.

Would it make sense to always derive a module's fetch priority from whatever enqueues it, so that it inherits the highest fetch priority of an enqueued module?

We'd start with a graph like this

---
config:
  theme: base
---
flowchart LR
    classDef HI stroke-width:1px, stroke:#ff0000, fill:#ff000022
    classDef LO stroke-width:1px, stroke:#0000ff, fill:#0000ff22
    classDef ENQUEUE stroke-width:4px

    X["X::low"]
    X:::LO
    X:::ENQUEUE
    Y["Y::auto"]
    Z["Z::high"]
    Z:::HI
    X:::ENQUEUE
    Y:::ENQUEUE
    Z:::ENQUEUE

    X ---> A
    Y ---> B
    Z ---> C
    
    A --> D & E
    B --> E
    C --> E & F
Loading

And the result would be this

---
config:
  theme: base
---
flowchart LR
    classDef HI stroke-width:1px, stroke:#ff0000, fill:#ff000022
    classDef LO stroke-width:1px, stroke:#0000ff, fill:#0000ff22
    classDef ENQUEUE stroke-width:4px

    X["X::low"]
    X:::LO
    X:::ENQUEUE
    Y["Y::auto"]
    Z["Z::high"]
    Z:::HI
    X:::ENQUEUE
    Y:::ENQUEUE
    Z:::ENQUEUE

    X ---> A
    Y ---> B
    Z ---> C
    
    A --> D & E
    B --> E
    C --> E & F

    A["A::low"]:::LO
    C["C::high"]:::HI
    D["D::low"]:::LO
    E["E::high"]:::HI
    F["F::high"]:::HI
Loading

@westonruter
Copy link
Member Author

@sirreal That's a good question, and I'm not entirely sure which way is better, or if “enqueued-script fetchpriority determinance” should apply to both script modules and classic scripts. I suppose so? I took a stab at implementing what you suggested in c59f440, specifically for script modules. If that looks right I can implement the same for classic scripts.

Note this changes the priorities from #9770 (comment) which were originally:

---
config:
  theme: base
---
flowchart LR
    classDef HI stroke-width:1px, stroke:#ff0000, fill:#ff000022
    classDef LO stroke-width:1px, stroke:#0000ff, fill:#0000ff22
    classDef ENQUEUE stroke-width:4px

    A:::LO
    Y:::HI
    Z:::HI

    A["A::low"]
    B["B"]
    C["C"]
    E["E"]
    D["D::high"]
    D:::HI
    
    X["X::high"]
    Y["Y"]
    Z["Z"]
    X:::HI
    
    A --> B --> C --> E & D
    A:::ENQUEUE

    X --> D & Y --> Z
    X:::ENQUEUE
Loading

But now they are:

---
config:
  theme: base
---
flowchart LR
    classDef HI stroke-width:1px, stroke:#ff0000, fill:#ff000022
    classDef LO stroke-width:1px, stroke:#0000ff, fill:#0000ff22
    classDef ENQUEUE stroke-width:4px

    A:::LO
    B:::LO
    C:::LO
    E:::LO
    Y:::HI
    Z:::HI

    A["A::low"]
    B["B"]
    C["C"]
    E["E"]
    D["D::high"]
    D:::HI
    
    X["X::high"]
    Y["Y"]
    Z["Z"]
    X:::HI
    
    A --> B --> C --> E & D
    A:::ENQUEUE

    X --> D & Y --> Z
    X:::ENQUEUE
Loading

@westonruter
Copy link
Member Author

Relatedly: If an enqueued script module has a low priority, would it make sense to just omit the modulepreload links rather than add them with fetchpriority=low as well? If something is truly low priority, then there shouldn't be a need to preload dependencies. This would reduce additional network contention for the LCP element resource.

@sirreal
Copy link
Member

sirreal commented Sep 23, 2025

If an enqueued script module has a low priority, would it make sense to just omit the modulepreload links rather than add them with fetchpriority=low as well? If something is truly low priority, then there shouldn't be a need to preload dependencies. This would reduce additional network contention for the LCP element resource.

I'm not sure of the specifics on modulepreload in this case, but it's a good question. I understand that they're only browser hints, they give additional information to the browser and it decides exactly what to do with them and how to prioritize them. I'd hope that browsers are capable of prioritizing those preloads well so that there low priority module preloads are not harmful to things like LCP.

It's worth doing some testing, but let's leave that for another PR if we want to make any changes.

Copy link
Member

@sirreal sirreal left a comment

Choose a reason for hiding this comment

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

I've tested this and it seems to work well. I think this does the correct thing of inheriting fetch priority from an enqueued script.

I left a few minor comments.

Comment on lines +472 to +474
if ( isset( $this->dependents_map[ $id ] ) ) {
return $this->dependents_map[ $id ];
}
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

Comment on lines 1069 to 1087
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?

@sirreal sirreal requested a review from Copilot September 23, 2025 13:53

This comment was marked as off-topic.

@sirreal
Copy link
Member

sirreal commented Sep 23, 2025

What this does is find the highest fetch priority in a dependency graph and apply that fetch priority to the entire graph. Am I interpreting the changes correctly?

Replying to myself: No, I was not interpreting the changes correctly. I had mixed up dependents with dependencies in the graph exploration so had backwards expectations. The implementation does the right thing.

@westonruter westonruter force-pushed the add/dependent-fetchpriority-harmony branch from 51f6c5d to eada641 Compare October 14, 2025 00:48
pento pushed a commit that referenced this pull request Oct 14, 2025
…ies.

This introduces a "fetchpriority bumping" mechanism for both classic scripts (`WP_Scripts`) and script modules (`WP_Script_Modules`). When a script with a higher `fetchpriority` is enqueued, any of its dependencies will have their `fetchpriority` elevated to match that of the highest-priority dependent. This ensures that all assets in a critical dependency chain are loaded with the appropriate priority, preventing a high-priority script from being blocked by a low-priority dependency. This is similar to logic used in script loading strategies to ensure that a blocking dependent causes delayed (`async`/`defer`) dependencies to also become blocking. See #12009. 

When a script's `fetchpriority` is escalated, its original, registered priority is added to the tag via a `data-wp-fetchpriority` attribute. This matches the addition of the `data-wp-strategy` parameter added when the resulting loading strategy does not match the original.

Developed in #9770.

Follow-up to [60704].

Props westonruter, jonsurrell.
Fixes #61734.


git-svn-id: https://develop.svn.wordpress.org/trunk@60931 602fd350-edb4-49c9-b593-d223f7449a82
@github-actions
Copy link

A commit was made that fixes the Trac ticket referenced in the description of this pull request.

SVN changeset: 60931
GitHub commit: fe9b235

This PR will be closed, but please confirm the accuracy of this and reopen if there is more work to be done.

@github-actions github-actions bot closed this Oct 14, 2025
markjaquith pushed a commit to markjaquith/WordPress that referenced this pull request Oct 14, 2025
…ies.

This introduces a "fetchpriority bumping" mechanism for both classic scripts (`WP_Scripts`) and script modules (`WP_Script_Modules`). When a script with a higher `fetchpriority` is enqueued, any of its dependencies will have their `fetchpriority` elevated to match that of the highest-priority dependent. This ensures that all assets in a critical dependency chain are loaded with the appropriate priority, preventing a high-priority script from being blocked by a low-priority dependency. This is similar to logic used in script loading strategies to ensure that a blocking dependent causes delayed (`async`/`defer`) dependencies to also become blocking. See #12009. 

When a script's `fetchpriority` is escalated, its original, registered priority is added to the tag via a `data-wp-fetchpriority` attribute. This matches the addition of the `data-wp-strategy` parameter added when the resulting loading strategy does not match the original.

Developed in WordPress/wordpress-develop#9770.

Follow-up to [60704].

Props westonruter, jonsurrell.
Fixes #61734.

Built from https://develop.svn.wordpress.org/trunk@60931


git-svn-id: http://core.svn.wordpress.org/trunk@60267 1a063a9b-81f0-0310-95a4-ce76da25c4cd
github-actions bot pushed a commit to platformsh/wordpress-performance that referenced this pull request Oct 14, 2025
…ies.

This introduces a "fetchpriority bumping" mechanism for both classic scripts (`WP_Scripts`) and script modules (`WP_Script_Modules`). When a script with a higher `fetchpriority` is enqueued, any of its dependencies will have their `fetchpriority` elevated to match that of the highest-priority dependent. This ensures that all assets in a critical dependency chain are loaded with the appropriate priority, preventing a high-priority script from being blocked by a low-priority dependency. This is similar to logic used in script loading strategies to ensure that a blocking dependent causes delayed (`async`/`defer`) dependencies to also become blocking. See #12009. 

When a script's `fetchpriority` is escalated, its original, registered priority is added to the tag via a `data-wp-fetchpriority` attribute. This matches the addition of the `data-wp-strategy` parameter added when the resulting loading strategy does not match the original.

Developed in WordPress/wordpress-develop#9770.

Follow-up to [60704].

Props westonruter, jonsurrell.
Fixes #61734.

Built from https://develop.svn.wordpress.org/trunk@60931


git-svn-id: https://core.svn.wordpress.org/trunk@60267 1a063a9b-81f0-0310-95a4-ce76da25c4cd
@ciobanu0151
Copy link

ciobanu0151 commented Nov 4, 2025

There is an issue here: https://github.com/WordPress/wordpress-develop/blame/81b3c68c692b79b9bbaed0f8e16dfa51147b356b/src/wp-includes/class-wp-scripts.php#L1073. The checked param of the function needs a reference the way it works now you only save the checked one way down the recursive tree not in parallel. This can cause huge performance problems.

Propsed a fix to not have sometimes page loads of 10-20s #10459

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants