Skip to content

Script Loader: Drop obsolete pr attribute and build resource hint and preload tags with the HTML API - #15331

Open
westonruter wants to merge 15 commits into
WordPress:trunkfrom
westonruter:fix/resource-hints-pr-attribute
Open

westonruter wants to merge 15 commits into
WordPress:trunkfrom
westonruter:fix/resource-hints-pr-attribute

Conversation

@westonruter

@westonruter westonruter commented Nov 1, 2026 •

Copy link
Copy Markdown
Member

The pr ("hint probability") attribute allowed by wp_resource_hints() since r38826 was only ever part of the W3C Resource Hints working drafts. No browser implemented it, and it was removed from that spec in w3c/resource-hints#76. The HTML Living Standard, which now defines resource hints, has no such attribute on <link>, so printing it only produces invalid markup.

This PR:

  • Stops printing pr and removes it from the wp_resource_hints filter documentation.
  • Moves the inline allowlist into an $allowed_attributes map from each attribute name to whether it may be printed without a value. Only crossorigin can be valueless.
  • Allows fetchpriority, hreflang, media, referrerpolicy, and title, the remaining <link> attributes the spec permits with dns-prefetch, preconnect, prefetch, and prerender. Of these, fetchpriority most closely fills the role pr was meant to. The other <link> attributes stay disallowed because the spec restricts them to other link types: blocking, color, disabled, imagesizes, imagesrcset, integrity, and sizes.
  • Applies the same $allowed_attributes map approach to wp_preload_resources(), which shared the same hand-built markup and the same valueless attribute handling.
  • Builds the <link> tags in both functions with WP_HTML_Tag_Processor instead of concatenating strings, so set_attribute() escapes the values (running href through esc_url()) and prints true as a bare attribute. The preload href is still limited to the http and https protocols.

Behavior changes for valueless attributes, in both wp_resource_hints() and wp_preload_resources():

  • 'crossorigin' => true prints a bare crossorigin instead of crossorigin='1', and false omits it instead of printing crossorigin=''.
  • A list item, as in array( 'crossorigin', 'href' => … ), is printed only when it names an allowed attribute that may be valueless. Previously any string list item was printed as a bare attribute, which let arbitrary attribute names bypass the allowlist.
  • true or false supplied for an attribute that requires a value is skipped instead of being printed as '1' or ''.

Markup changes from using the HTML API:

  • Attribute values are now in double quotes, and the tags no longer end in />. For example, <link rel='dns-prefetch' href='//wordpress.org' /> is now printed as <link rel="dns-prefetch" href="//wordpress.org">. The tests now compare markup with assertEqualHTML(), so they are not tied to either serialization.
  • Every & in a value is encoded, whereas esc_attr() left existing character references alone. A value that was already HTML-escaped by the caller, such as Tom &amp; Jerry, is now double-encoded.

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

Use of AI Tools

AI assistance: Yes
Tool(s): Claude Code
Model(s): Claude Opus 5.5 (medium effort)
Used for: Researching the status of the pr attribute and the <link> attributes in the HTML spec, implementing the changes and tests, and drafting the Trac ticket and this description, under the direction of the author, who reviewed the result.


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.

westonruter and others added 4 commits November 1, 2026 09:39
The `pr` (hint probability) attribute was only ever part of the W3C
Resource Hints working drafts. No browser implemented it, and it was
removed from that spec in 2018. The HTML Living Standard, which now
defines resource hints, has no such attribute on `<link>`, so printing it
only produces invalid markup.

Remove `pr` from the allowed attributes and from the `wp_resource_hints`
filter documentation. The test keeps passing `pr` to assert that it is
now dropped.

Co-Authored-By: Claude Opus 5.5 (medium effort) <noreply@anthropic.com>
Claude-Code-Session-Id: 7f6d7eea-e6bc-4b05-9181-cad948a21000
Move the inline attribute allowlist in `wp_resource_hints()` up into an
`$allowed_attributes` map, keyed by attribute name, whose value says
whether the attribute may be printed without a value. Of the currently
allowed attributes, only `crossorigin` can be.

This tightens how valueless attributes are handled:

* `'crossorigin' => true` now prints a bare `crossorigin` instead of
  `crossorigin='1'`, and `false` omits it instead of printing
  `crossorigin=''`.
* A list item, as in `array( 'crossorigin', 'href' => … )`, is now
  printed only when it names an allowed attribute which may be valueless.
  Previously any string list item was printed as a bare attribute, which
  let arbitrary attribute names past the allowlist.
* `true` or `false` given for an attribute that requires a value is
  skipped rather than printed as `'1'` or `''`.

Co-Authored-By: Claude Opus 5.5 (medium effort) <noreply@anthropic.com>
Claude-Code-Session-Id: 7f6d7eea-e6bc-4b05-9181-cad948a21000
Add `fetchpriority`, `hreflang`, `media`, `referrerpolicy`, and `title` to
the attributes `wp_resource_hints()` prints. They are the remaining
attributes of the HTML link element which the spec allows with the
dns-prefetch, preconnect, prefetch, and prerender link types. Of these,
`fetchpriority` is the closest successor to the removed `pr` attribute,
as it lets a hint signal how important its fetch is.

The other link attributes stay disallowed, since the spec only permits
them with other link types: `blocking` (stylesheet, expect), `color`
(mask-icon), `disabled` (stylesheet), `imagesizes` and `imagesrcset`
(preload of an image), `integrity` (stylesheet, preload, modulepreload),
and `sizes` (icons).

Co-Authored-By: Claude Opus 5.5 (medium effort) <noreply@anthropic.com>
Claude-Code-Session-Id: 7f6d7eea-e6bc-4b05-9181-cad948a21000
Co-Authored-By: Claude Opus 5.5 (medium effort) <noreply@anthropic.com>
Claude-Code-Session-Id: 7f6d7eea-e6bc-4b05-9181-cad948a21000
@github-actions

github-actions Bot commented Nov 1, 2026

Copy link
Copy Markdown

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

  • 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 commented Nov 1, 2026 •

Copy link
Copy Markdown
Member Author

🤖 Comment by Claude Opus 5.5 (medium effort)

A Veloria search of the plugin and theme directories found no code using pr for resource hints, so dropping it appears to affect no one. Four extensions pass 'crossorigin' => true, which now prints a valid bare crossorigin instead of crossorigin='1', with fetch behavior unchanged.

Details

pr attribute: no usage found

  • No plugin or theme passes 'pr' => with a probability value. Case-insensitive matches for 'pr' => turned up only unrelated arrays: Freemius country lists ('PR' => 'Puerto Rico'), public suffix lists, CSS padding options (pt/pr/pb/pl), payment line items, and query args.
  • 'pr' => with a variable value (15 matches across 13 plugins) is likewise unrelated to resource hints.
  • No plugin or theme prints pr= on a hand-written <link rel="dns-prefetch|preconnect|prefetch|prerender"> either.

'crossorigin' => true: output improves

About 850 wp_resource_hints filter callbacks exist (459 across 387 plugins, 787 across 571 themes). Most add a fonts.gstatic.com preconnect using the list-item form from the bundled themes, array( 'href' => …, 'crossorigin' ). That form is unchanged.

These pass 'crossorigin' => true:

  • Go (60,000 installs): includes/core.php:524
  • Simple CAPTCHA with Cloudflare Turnstile (200,000 installs): inc/integrations/other/resource-hints.php:27
  • Tiger Elements Forms: src/Form/Widget/Asset_Manager.php:373
  • Ayyash: inc/customizer/class-ayyash-customize.php:189

Since '1' is an invalid value for a CORS settings attribute, browsers already treat it as anonymous.

Most other 'crossorigin' => true matches (233 across 146 plugins) are wp_kses() allowed-HTML arrays, which are unaffected.

List items other than crossorigin: none found

No hint arrays were found that pass a list item other than crossorigin, such as 'anonymous' or 'use-credentials' without a key. Those will now be dropped. The searches here are line-based, so a list item inside a multi-line array that does not sit on its own line could have been missed.

westonruter and others added 5 commits November 1, 2026 21:38
The resource hint tests compared the printed `<link>` tags as exact
strings, so they also asserted incidental details such as attribute order
and quoting style. Use `assertEqualHTML()` so they assert only that the
markup is equivalent HTML.

Co-Authored-By: Claude Opus 5.5 (medium effort) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Tnky3GYbMqxozZLnF3PvQp
Claude-Code-Session-Id: 7f6d7eea-e6bc-4b05-9181-cad948a21000
Co-Authored-By: Claude Opus 5.5 (medium effort) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Tnky3GYbMqxozZLnF3PvQp
Claude-Code-Session-Id: 7f6d7eea-e6bc-4b05-9181-cad948a21000
Constructing the `<link>` markup by hand meant escaping each value and
printing valueless attributes manually. `WP_HTML_Tag_Processor::set_attribute()`
handles both: it escapes values (running URL attributes like `href` through
`esc_url()`), and it prints `true` as a bare attribute. The allowlist still
decides which attributes may be printed and which may be valueless.

Co-Authored-By: Claude Opus 5.5 (medium effort) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Tnky3GYbMqxozZLnF3PvQp
Claude-Code-Session-Id: 7f6d7eea-e6bc-4b05-9181-cad948a21000
Like `wp_resource_hints()`, `wp_preload_resources()` assembled its
`<link>` markup by hand. Build it with `WP_HTML_Tag_Processor` instead,
so attribute values are escaped and valueless attributes are printed by
`set_attribute()`. The `href` is still limited to the `http` and `https`
protocols.

The attribute allowlist becomes an `$allowed_attributes` map recording
whether each attribute may be valueless, as in `wp_resource_hints()`.
This fixes two cases:

* `'crossorigin' => true` printed `crossorigin='1'`, and now prints a
  bare `crossorigin`.
* Any list item was printed as a bare attribute, unchecked against the
  allowlist. Now only `crossorigin` is.

The tests now compare markup with `assertEqualHTML()` and cover these
cases.

Co-Authored-By: Claude Opus 5.5 (medium effort) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Tnky3GYbMqxozZLnF3PvQp
Claude-Code-Session-Id: 7f6d7eea-e6bc-4b05-9181-cad948a21000
@westonruter westonruter changed the title Script Loader: Remove obsolete pr attribute from resource hints and allow spec-defined link attributes Script Loader: Drop obsolete pr attribute and build resource hint and preload tags with the HTML API Nov 4, 2026
@westonruter
westonruter marked this pull request as ready for review November 4, 2026 07:24
@github-actions

github-actions Bot commented Nov 4, 2026 •

Copy link
Copy Markdown

The following accounts have interacted with this PR and/or linked issues. I will continue to update these lists as activity occurs.

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

Props westonruter, jonsurrell, nathanrice.

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


Props Bot commands

You can trigger Props Bot actions by commenting on this PR:

  • @propsbot add <username> to give props to a GitHub user. Requires write access.
  • @propsbot remove <username> to leave a user out of the props. Requires write access.
  • @propsbot refresh to refresh this list. Adding the props-bot label works too.

@westonruter

Copy link
Copy Markdown
Member Author

cc @dmsnell @sirreal for how the HTML Tag Processor is now used to construct the LINK tags in wp_preload_resources() and wp_resource_hints().

@sirreal
sirreal self-requested a review November 4, 2026 15:37

@sirreal sirreal left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I love to see this!

I've left some notes, nothing major. This seems ready to merge at your discretion.

}

$html = trim( $html );
$processor->set_attribute( $attr, $value );

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This is a small behavioral change here. Before, duplicate attributes were all appended. According to HTML, the first one wins. The tag processor will not add duplicate attributes, it sets the last set_attribute() making this "last one wins".

To preserve the "first in the array wins" behavior, the script tag handles it like this:

/*
* Lexical variations of an attribute name may represent the
* same attribute in HTML, therefore it’s possible that the
* input array might contain duplicate attributes even though
* it’s keyed on their name. Calling code should rewrite an
* attribute’s value rather than sending a duplicate attribute.
*
* Example:
*
* array( 'id' => 'main', 'ID' => 'nav' )
*
* In this example, there are two keys both describing the `id`
* attribute. PHP array iteration is in key-insertion order so
* the 'id' value will be set in the SCRIPT tag.
*/
if ( null !== $processor->get_attribute( $name ) ) {
continue;
}
$processor->set_attribute( $name, $value ?? true );

It would be good to add a few tests for the duplicate attribute behaviors, especially like [ 'crossorigin', 'crossorigin' => 'value' ].

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

🤖 Comment by Claude Opus 5.5 (medium effort)

Fixed in dde14f8. An attribute the tag already has is now skipped, so the first one wins, as in wp_get_inline_script_tag(). Tests were added for [ 'crossorigin', 'crossorigin' => 'use-credentials' ], the reverse order, and a repeated list item, in both functions.

* imagesrcset, integrity, and sizes.
*/
$allowed_attributes = array(
'as' => false,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I find the structure of this data and the description confusing. As a local value, it shouldn't be a big deal but I think some refinement here would be helpful.

Here's what I understand:

  • key is present: attribute is allowed
  • value: true indicates the attribute may appear as a boolean (valueless) attribute.

A note on as's specification

The as attribute specifies…
The attribute must be specified on link elements that have a rel attribute that contains the preload keyword…
It may be specified on link elements that have a rel attribute that contains the modulepreload keyword…
For other link elements, it must not be specified.

So it's required by the spec in some cases, and prohibited in others.

I'm happy to leave that out of scope, but it's something to consider. I'm not sure what the implications are, this could probably remove as in some cases where it must not be specified, but where it's required I'm less sure what would be a good failure mode.


Looking at the specification for rel, I think a get_attribute_as_token_list() or attribute_token_list_contains( 'rel', 'preload' ): bool would be useful for this type of thing.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

🤖 Comment by Claude Opus 5.5 (medium effort)

The comment was reworded in 3ece593: a key's presence is what allows an attribute, and a true value lets it also be printed as a boolean attribute.

Restricting as is left out of scope. It was already allowed on resource hints before this PR, and plugins may pass it with prefetch, so dropping it would be a separate behavior change.

A token-list helper for rel would be a nice HTML API addition, but it belongs on its own ticket.

Comment thread src/wp-includes/general-template.php Outdated
$html .= " $resource_value";
} else {
$html .= " $resource_key='$resource_value'";
if ( 'href' === $resource_key && is_string( $resource_value ) ) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

The allow list makes case sensitivity irrelevant (this is correct).

The is_string() is redundant at this point, isn't it? Above, scalars are coerced to strings and non-scalars continue the loop iteration.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

🤖 Comment by Claude Opus 5.5 (medium effort)

Yes, it was redundant. It was removed in 2743a24, which sanitizes the href while collecting unique resources instead. A non-string or unsafe href is now treated as missing, so the resource falls back to its imagesrcset or is skipped, rather than being printed with an empty href.

Comment thread src/wp-includes/general-template.php Outdated
Comment on lines +4185 to +4187
$processor = new WP_HTML_Tag_Processor( '<link>' );
$processor->next_tag();
$processor->set_attribute( 'rel', 'preload' );

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Just review notes, no change requests.

If rel were allowed, it would need to be set at the end to ensure its value is applied. That's not an issue now.

As a static and pretty trivial attribute that can't be changed in the loop below, we could include rel in the initial HTML: <link rel="preload">.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

🤖 Comment by Claude Opus 5.5 (medium effort)

Done in 8c189b8: the processor now starts from <link rel="preload">.

* attribute may be printed without a value when it is supplied as `true`.
*/
$allowed_attributes = array(
'as' => false,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

My other notes apply to as here as well.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

🤖 Comment by Claude Opus 5.5 (medium effort)

For preloads, as is required by the spec rather than prohibited, so it stays in this allowlist. Enforcing its presence, and deciding what to do when it is missing, is left out of scope like the resource hints note.

$resource_value = sanitize_url( $resource_value, array( 'http', 'https' ) );
}

$processor->set_attribute( $resource_key, $resource_value );

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Same notes as above where setting without a get_attribute( $resource_key ) === null check first is a behavioral change from "first in the list wins" to "last in the list wins".

An early !== null check can continue the loop early and skip some processing in those cases.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

🤖 Comment by Claude Opus 5.5 (medium effort)

Covered by the same change in dde14f8. The get_attribute() check is made together with the allowlist check, so a duplicate skips the rest of the loop body.

*
* @return array<string, array{ hint: array<int|string, mixed>, expected: string }>
*/
public function data_boolean_attributes(): array {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Suggested change
public function data_boolean_attributes(): array {
public static function data_boolean_attributes(): array {

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

🤖 Comment by Claude Opus 5.5 (medium effort)

Applied in 0ef0d79, along with non-falsy-string keys in the return type.

* @since 4.6.0
* @since 4.7.0 The `$urls` parameter accepts arrays of specific HTML attributes
* as its child elements.
* @since 7.3.0 The obsolete `pr` attribute is no longer printed. The `crossorigin`

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

The Trac ticket says milestone: 7.2 ... was this intended for the 7.2 release? I assume not, since we're so close to 7.2 right now, but I wanted to make sure, just in case.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Good catch. I have re-milestoned for 7.3 in any case, as I'm running out of time.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

🤖 Comment by Claude Opus 5.5 (medium effort)

No, this is not meant for 7.2. The @since 7.3.0 tags reflect the intended release, so the ticket's milestone should be moved to match.

westonruter and others added 6 commits November 7, 2026 19:15
A preload whose `href` was not a string, or whose protocol was stripped
by sanitization (such as `javascript:`), was still printed, either with
an empty `href` or with none at all. Such a tag preloads nothing.

Sanitize the `href` while collecting unique resources instead of while
printing them. An unusable `href` is then treated as absent: the
resource falls back to its `imagesrcset` when it is an image, and is
skipped otherwise. The sanitized URL also becomes the key for removing
duplicates, so two spellings of the same URL yield one preload. A
non-string `imagesrcset` is likewise rejected instead of being used as
an array key.

Co-Authored-By: Claude Opus 5.5 (medium effort) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Tnky3GYbMqxozZLnF3PvQp
Claude-Code-Session-Id: 7f6d7eea-e6bc-4b05-9181-cad948a21000
The `@since 7.3.0` note did not mention that list items other than
`crossorigin` are no longer printed as valueless attributes. The note
on `wp_preload_resources` already says so.

Co-Authored-By: Claude Opus 5.5 (medium effort) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Tnky3GYbMqxozZLnF3PvQp
Claude-Code-Session-Id: 7f6d7eea-e6bc-4b05-9181-cad948a21000
Newer PHPUnit versions deprecate and then reject non-static data
providers, and the matching provider for the preload tests is already
static. Also type its keys as `non-falsy-string`.

Co-Authored-By: Claude Opus 5.5 (medium effort) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Tnky3GYbMqxozZLnF3PvQp
Claude-Code-Session-Id: 7f6d7eea-e6bc-4b05-9181-cad948a21000
An attribute can be supplied twice, as in
`array( 'crossorigin', 'crossorigin' => 'use-credentials' )`. The
hand-built markup printed both, and HTML applies the first. With the HTML
API, each `set_attribute()` call overwrites the previous one, so the last
would win. Skip an attribute the tag already has, as
`wp_get_inline_script_tag()` does, so the first one still wins.

Co-Authored-By: Claude Opus 5.5 (medium effort) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Tnky3GYbMqxozZLnF3PvQp
Claude-Code-Session-Id: 7f6d7eea-e6bc-4b05-9181-cad948a21000
The `rel` is fixed and cannot be supplied by the filter, so include it in
the initial HTML instead of setting it through the HTML API.

Co-Authored-By: Claude Opus 5.5 (medium effort) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Tnky3GYbMqxozZLnF3PvQp
Claude-Code-Session-Id: 7f6d7eea-e6bc-4b05-9181-cad948a21000
Spell out that a key's presence is what allows an attribute, and that a
`true` value allows it to also be printed as a boolean attribute.

Co-Authored-By: Claude Opus 5.5 (medium effort) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Tnky3GYbMqxozZLnF3PvQp
Claude-Code-Session-Id: 7f6d7eea-e6bc-4b05-9181-cad948a21000
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