Skip to content

Use HTML Tag Processor to audit blocking scripts & styles in Site Health’s enqueued-assets test #2059

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 16 commits into
base: trunk
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from 15 commits
Commits
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
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@
}

$result = array(
'label' => __( 'Enqueued scripts', 'performance-lab' ),
'label' => __( 'Blocking scripts', 'performance-lab' ),
'status' => 'good',
'badge' => array(
'label' => __( 'Performance', 'performance-lab' ),
Expand All @@ -45,8 +45,8 @@
sprintf(
/* translators: 1: Number of enqueued styles. 2.Styles size. */
_n(
'The amount of %1$s enqueued script (size: %2$s) is acceptable.',
'The amount of %1$s enqueued scripts (size: %2$s) is acceptable.',
'The amount of %1$s blocking script (size: %2$s) is acceptable.',
'The amount of %1$s blocking scripts (size: %2$s) is acceptable.',
$enqueued_scripts,
'performance-lab'
),
Expand Down Expand Up @@ -86,8 +86,8 @@
sprintf(
/* translators: 1: Number of enqueued styles. 2.Styles size. */
_n(
'Your website enqueues %1$s script (size: %2$s). Try to reduce the number or to concatenate them.',
'Your website enqueues %1$s scripts (size: %2$s). Try to reduce the number or to concatenate them.',
'Your website has %1$s blocking script (size: %2$s). Try to reduce the number or to concatenate them.',
'Your website has %1$s blocking scripts (size: %2$s). Try to reduce the number or to concatenate them.',
$enqueued_scripts,
'performance-lab'
),
Expand Down Expand Up @@ -128,7 +128,7 @@
return array( 'omitted' => true );
}
$result = array(
'label' => __( 'Enqueued styles', 'performance-lab' ),
'label' => __( 'Blocking styles', 'performance-lab' ),
'status' => 'good',
'badge' => array(
'label' => __( 'Performance', 'performance-lab' ),
Expand All @@ -140,8 +140,8 @@
sprintf(
/* translators: 1: Number of enqueued styles. 2.Styles size. */
_n(
'The amount of %1$s enqueued style (size: %2$s) is acceptable.',
'The amount of %1$s enqueued styles (size: %2$s) is acceptable.',
'The amount of %1$s blocking style (size: %2$s) is acceptable.',
'The amount of %1$s blocking styles (size: %2$s) is acceptable.',
$enqueued_styles,
'performance-lab'
),
Expand Down Expand Up @@ -180,8 +180,8 @@
sprintf(
/* translators: 1: Number of enqueued styles. 2.Styles size. */
_n(
'Your website enqueues %1$s style (size: %2$s). Try to reduce the number or to concatenate them.',
'Your website enqueues %1$s styles (size: %2$s). Try to reduce the number or to concatenate them.',
'Your website has %1$s blocking style (size: %2$s). Try to reduce the number or to concatenate them.',
'Your website has %1$s blocking styles (size: %2$s). Try to reduce the number or to concatenate them.',
$enqueued_styles,
'performance-lab'
),
Expand Down Expand Up @@ -212,10 +212,10 @@
* @return int|false Number of total scripts or false if transient hasn't been set.
*/
function perflab_aea_get_total_enqueued_scripts() {
$enqueued_scripts = false;
$list_enqueued_scripts = get_transient( 'aea_enqueued_front_page_scripts' );
if ( is_array( $list_enqueued_scripts ) ) {
$enqueued_scripts = count( $list_enqueued_scripts );
$enqueued_scripts = false;
$blocking_assets = get_transient( 'aea_blocking_assets' );
if ( is_array( $blocking_assets ) && is_array( $blocking_assets['scripts'] ) ) {
$enqueued_scripts = count( $blocking_assets['scripts'] );
}
return $enqueued_scripts;
}
Expand All @@ -228,11 +228,11 @@
* @return int|false Byte Total size or false if transient hasn't been set.
*/
function perflab_aea_get_total_size_bytes_enqueued_scripts() {
$total_size = false;
$list_enqueued_scripts = get_transient( 'aea_enqueued_front_page_scripts' );
if ( is_array( $list_enqueued_scripts ) ) {
$total_size = false;
$blocking_assets = get_transient( 'aea_blocking_assets' );
if ( is_array( $blocking_assets ) && isset( $blocking_assets['scripts'] ) && is_array( $blocking_assets['scripts'] ) ) {
$total_size = 0;
foreach ( $list_enqueued_scripts as $enqueued_script ) {
foreach ( $blocking_assets['scripts'] as $enqueued_script ) {
if ( is_array( $enqueued_script ) && array_key_exists( 'size', $enqueued_script ) && is_int( $enqueued_script['size'] ) ) {
$total_size += $enqueued_script['size'];
}
Expand All @@ -249,10 +249,10 @@
* @return int|false Number of total styles or false if transient hasn't been set.
*/
function perflab_aea_get_total_enqueued_styles() {
$enqueued_styles = false;
$list_enqueued_styles = get_transient( 'aea_enqueued_front_page_styles' );
if ( is_array( $list_enqueued_styles ) ) {
$enqueued_styles = count( $list_enqueued_styles );
$enqueued_styles = false;
$blocking_assets = get_transient( 'aea_blocking_assets' );
if ( is_array( $blocking_assets ) && isset( $blocking_assets['styles'] ) && is_array( $blocking_assets['styles'] ) ) {
$enqueued_styles = count( $blocking_assets['styles'] );
}
return $enqueued_styles;
}
Expand All @@ -265,11 +265,11 @@
* @return int|false Byte Total size or false if transient hasn't been set.
*/
function perflab_aea_get_total_size_bytes_enqueued_styles() {
$total_size = false;
$list_enqueued_styles = get_transient( 'aea_enqueued_front_page_styles' );
if ( is_array( $list_enqueued_styles ) ) {
$total_size = false;
$blocking_assets = get_transient( 'aea_blocking_assets' );
if ( is_array( $blocking_assets ) && isset( $blocking_assets['styles'] ) && is_array( $blocking_assets['styles'] ) ) {
$total_size = 0;
foreach ( $list_enqueued_styles as $enqueued_style ) {
foreach ( $blocking_assets['styles'] as $enqueued_style ) {
if ( is_array( $enqueued_style ) && array_key_exists( 'size', $enqueued_style ) && is_int( $enqueued_style['size'] ) ) {
$total_size += $enqueued_style['size'];
}
Expand All @@ -293,6 +293,11 @@
return '';
}

// Remove query string if present.
if ( false !== strpos( $resource_url, '?' ) ) {
$resource_url = substr( $resource_url, 0, strpos( $resource_url, '?' ) );

Check warning on line 298 in plugins/performance-lab/includes/site-health/audit-enqueued-assets/helper.php

View check run for this annotation

Codecov / codecov/patch

plugins/performance-lab/includes/site-health/audit-enqueued-assets/helper.php#L298

Added line #L298 was not covered by tests
Comment on lines +296 to +298
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Does it make sense to remove the function perflab_aea_get_path_from_resource_url entirely, as a HEAD request is now made for local assets as well?

/**
* Convert full URL paths to absolute paths.
* Covers Standard WP configuration, wp-content outside WP directories and subdirectories.
* Ex: https://example.com/content/themes/, https://example.com/wp/wp-includes/
*
* @since 1.0.0
*
* @param string $resource_url URl resource link.
* @return string Returns absolute path to the resource.
*/
function perflab_aea_get_path_from_resource_url( string $resource_url ): string {

Copy link
Member

Choose a reason for hiding this comment

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

Yes, I think the function can be removed.

}
Comment on lines +297 to +299
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
if ( false !== strpos( $resource_url, '?' ) ) {
$resource_url = substr( $resource_url, 0, strpos( $resource_url, '?' ) );
}
$resource_url = preg_replace( '/\?.*/', '', $resource_url );

Nevertheless, the perflab_aea_get_path_from_resource_url() function isn't being used now anymore, right?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yes its not being used anymore I just put comment about it here #2059 (comment).


// Different content folder ex. /content/.
if ( 0 === strpos( $resource_url, content_url() ) ) {
return WP_CONTENT_DIR . substr( $resource_url, strlen( content_url() ) );
Expand All @@ -307,3 +312,28 @@
// Standard wp-content configuration.
return untrailingslashit( ABSPATH ) . wp_make_link_relative( $resource_url );
}

/**
* Gets the content length of an asset.
*
* @since n.e.x.t
*
* @param string $resource_url URL of the resource.
* @return int|null Returns the content length in bytes or null if it cannot be determined.
*/
function perflab_aea_get_asset_content_length( string $resource_url ): ?int {
$head_response = wp_remote_head( $resource_url, array( 'timeout' => 10 ) );
if ( is_wp_error( $head_response ) || 200 !== wp_remote_retrieve_response_code( $head_response ) ) {
return null;

Check warning on line 327 in plugins/performance-lab/includes/site-health/audit-enqueued-assets/helper.php

View check run for this annotation

Codecov / codecov/patch

plugins/performance-lab/includes/site-health/audit-enqueued-assets/helper.php#L327

Added line #L327 was not covered by tests
}

$content_length = wp_remote_retrieve_header( $head_response, 'content-length' );
if ( is_array( $content_length ) && 0 < count( $content_length ) ) {
$content_length = $content_length[0];

Check warning on line 332 in plugins/performance-lab/includes/site-health/audit-enqueued-assets/helper.php

View check run for this annotation

Codecov / codecov/patch

plugins/performance-lab/includes/site-health/audit-enqueued-assets/helper.php#L332

Added line #L332 was not covered by tests
}
if ( ! is_string( $content_length ) || '' === $content_length || ! ctype_digit( $content_length ) || 0 === (int) $content_length ) {
return null;

Check warning on line 335 in plugins/performance-lab/includes/site-health/audit-enqueued-assets/helper.php

View check run for this annotation

Codecov / codecov/patch

plugins/performance-lab/includes/site-health/audit-enqueued-assets/helper.php#L335

Added line #L335 was not covered by tests
}

return (int) $content_length;
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,107 +13,104 @@
// @codeCoverageIgnoreEnd

/**
* Audit enqueued and printed scripts in is_front_page(). Ignore /wp-includes scripts.
* Audit blocking assets on the front page.
*
* It will save information in a transient for 12 hours.
*
* @since 1.0.0
*
* @global WP_Scripts $wp_scripts
* @since n.e.x.t
*/
function perflab_aea_audit_enqueued_scripts(): void {
if ( ! is_admin() && is_front_page() && current_user_can( 'view_site_health_checks' ) && false === get_transient( 'aea_enqueued_front_page_scripts' ) ) {
global $wp_scripts;
$enqueued_scripts = array();
function perflab_aea_audit_blocking_assets(): void {
if (
! is_admin() ||
! current_user_can( 'view_site_health_checks' ) ||
false !== get_transient( 'aea_blocking_assets' )
) {
return;
}

$response = wp_remote_get(
home_url( '/' ),
array(
'timeout' => 10,
'headers' => array(
'Accept' => 'text/html',
'Cache-Control' => 'no-cache',
),
Comment on lines +33 to +36
Copy link
Member

Choose a reason for hiding this comment

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

If the site has HTTP Basic auth, then these can be copied in the request headers. This is done in core in the plugin/theme file editors.

)
);

if ( is_wp_error( $response ) || 200 !== wp_remote_retrieve_response_code( $response ) ) {
return;

Check warning on line 41 in plugins/performance-lab/includes/site-health/audit-enqueued-assets/hooks.php

View check run for this annotation

Codecov / codecov/patch

plugins/performance-lab/includes/site-health/audit-enqueued-assets/hooks.php#L41

Added line #L41 was not covered by tests
}

foreach ( $wp_scripts->done as $handle ) {
$script = $wp_scripts->registered[ $handle ];
$html = wp_remote_retrieve_body( $response );
if ( '' === $html ) {
return;

Check warning on line 46 in plugins/performance-lab/includes/site-health/audit-enqueued-assets/hooks.php

View check run for this annotation

Codecov / codecov/patch

plugins/performance-lab/includes/site-health/audit-enqueued-assets/hooks.php#L46

Added line #L46 was not covered by tests
}

$assets = array(
'scripts' => array(),
'styles' => array(),
);

$processor = new WP_HTML_Tag_Processor( $html );

if ( ! $script->src || false !== strpos( $script->src, 'wp-includes' ) ) {
while ( $processor->next_tag() ) {
$tag = $processor->get_tag();

if ( 'SCRIPT' === $tag ) {
$src = $processor->get_attribute( 'src' );
if ( ! is_string( $src ) ) {
continue;
}

// Add any extra data (inlined) that was passed with the script.
$inline_size = 0;
if (
isset( $script->extra['after'] ) &&
is_array( $script->extra['after'] )
) {
foreach ( $script->extra['after'] as $extra ) {
$inline_size += ( is_string( $extra ) ) ? mb_strlen( $extra, '8bit' ) : 0;
}
}
// Note that when the "type" attribute is absent or empty, the element is treated as a classic JavaScript script.
$type = $processor->get_attribute( 'type' );

$path = perflab_aea_get_path_from_resource_url( $script->src );
if ( '' === $path ) {
// Skip external script with "async" or "defer" attributes.
if ( null !== $processor->get_attribute( 'async' ) || null !== $processor->get_attribute( 'defer' ) ) {
continue;
}

$enqueued_scripts[] = array(
'src' => $script->src,
'size' => wp_filesize( $path ) + $inline_size,
);

}
set_transient( 'aea_enqueued_front_page_scripts', $enqueued_scripts, 12 * HOUR_IN_SECONDS );
}
}
add_action( 'wp_footer', 'perflab_aea_audit_enqueued_scripts', PHP_INT_MAX );
// Skip external script with a "type" attribute set to "module" as they are deferred by default.
if ( is_string( $type ) && '' !== $type && 'module' === strtolower( $type ) ) {
continue;

Check warning on line 75 in plugins/performance-lab/includes/site-health/audit-enqueued-assets/hooks.php

View check run for this annotation

Codecov / codecov/patch

plugins/performance-lab/includes/site-health/audit-enqueued-assets/hooks.php#L75

Added line #L75 was not covered by tests
}

/**
* Audit enqueued and printed styles in the frontend. Ignore /wp-includes styles.
*
* It will save information in a transient for 12 hours.
*
* @since 1.0.0
*
* @global WP_Styles $wp_styles The WP_Styles current instance.
*/
function perflab_aea_audit_enqueued_styles(): void {
if ( ! is_admin() && is_front_page() && current_user_can( 'view_site_health_checks' ) && false === get_transient( 'aea_enqueued_front_page_styles' ) ) {
global $wp_styles;
$enqueued_styles = array();
foreach ( $wp_styles->done as $handle ) {
$style = $wp_styles->registered[ $handle ];

if ( ! $style->src || false !== strpos( $style->src, 'wp-includes' ) ) {
// Skip external script with a "type" attribute that is not JavaScript.
if ( is_string( $type ) && '' !== $type && 'text/javascript' !== strtolower( $type ) ) {
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
if ( is_string( $type ) && '' !== $type && 'text/javascript' !== strtolower( $type ) ) {
if ( 'text/javascript' !== strtolower( (string) $type ) ) {

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Here, it is necessary to keep '' !== $type since an empty string value for type is considered valid in JavaScript.

Then it can be done like this:

Suggested change
if ( is_string( $type ) && '' !== $type && 'text/javascript' !== strtolower( $type ) ) {
if ( '' !== $type && 'text/javascript' !== strtolower( (string) $type ) ) {

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Okay, this change breaks things because the get_attribute method returns null when the attribute is not present. Therefore, a null !== $type check or the previous is_string( $type ) check needs to be added explicitly.

which check would be better?

Copy link
Member

Choose a reason for hiding this comment

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

Here, it is necessary to keep '' !== $type since an empty string value for type is considered valid in JavaScript.

Right, this is executed:

<script type>document.write('hello');</script>

There's also this code in core which is relevant here:

https://github.com/WordPress/wordpress-develop/blob/27233a0a3b00cc5d683080722711bca15970fe8c/src/wp-includes/script-loader.php#L2948-L2958

With the HTML API, the tag would be JavaScript if:

  1. The type attribute is absent ($type === null)
  2. The type attribute is boolean and has no value ($type === true)
  3. The type attribute is an empty string ($type === ''), but not if the value has whitespace in which case it is not executed.
  4. The type attribute contains javascript, ecmascript, jscript, or livescript.

continue;
}

// Check if we already have the style's path ( part of a refactor for block styles from 5.9 ).
if (
isset( $style->extra['path'] ) &&
is_string( $style->extra['path'] ) &&
'' !== $style->extra['path']
) {
$path = $style->extra['path'];
} else { // Fallback to getting the path from the style's src.
$path = perflab_aea_get_path_from_resource_url( $style->src );
if ( '' === $path ) {
continue;
}
$size = perflab_aea_get_asset_content_length( $src );
if ( null !== $size ) {
$assets['scripts'][] = array(
'src' => $src,
'size' => $size,
);
}
} elseif ( 'LINK' === $tag ) {
$rel = $processor->get_attribute( 'rel' );
if ( ! is_string( $rel ) || 'stylesheet' !== strtolower( $rel ) ) {
Copy link
Member

@westonruter westonruter Jun 26, 2025

Choose a reason for hiding this comment

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

There is another special case to consider here, and that is a classic hack for non-blocking stylesheets.

This is commonly implemented by adding media="print" and onload="this.media = 'screen'":

https://www.filamentgroup.com/lab/load-css-simpler/
https://scottjehl.com/posts/async-css-already/

So this case can be accounted for as well. Namely, this can just skip considering any stylesheets which have a media attribute that begins with anything other than all or screen.

continue;
}

// Add any extra data (inlined) that was passed with the style.
$inline_size = 0;
if (
isset( $style->extra['after'] ) &&
is_array( $style->extra['after'] )
) {
foreach ( $style->extra['after'] as $extra ) {
$inline_size += ( is_string( $extra ) ) ? mb_strlen( $extra, '8bit' ) : 0;
}
$href = $processor->get_attribute( 'href' );
if ( ! is_string( $href ) ) {
continue;

Check warning on line 98 in plugins/performance-lab/includes/site-health/audit-enqueued-assets/hooks.php

View check run for this annotation

Codecov / codecov/patch

plugins/performance-lab/includes/site-health/audit-enqueued-assets/hooks.php#L98

Added line #L98 was not covered by tests
}

$enqueued_styles[] = array(
'src' => $style->src,
'size' => wp_filesize( $path ) + $inline_size,
);
$size = perflab_aea_get_asset_content_length( $href );
if ( null !== $size ) {
$assets['styles'][] = array(
'src' => $href,
'size' => $size,
);
}
}
set_transient( 'aea_enqueued_front_page_styles', $enqueued_styles, 12 * HOUR_IN_SECONDS );
}

set_transient( 'aea_blocking_assets', $assets, 12 * HOUR_IN_SECONDS );
}
add_action( 'wp_footer', 'perflab_aea_audit_enqueued_styles', PHP_INT_MAX );
add_action( 'admin_init', 'perflab_aea_audit_blocking_assets' );

Check warning on line 113 in plugins/performance-lab/includes/site-health/audit-enqueued-assets/hooks.php

View check run for this annotation

Codecov / codecov/patch

plugins/performance-lab/includes/site-health/audit-enqueued-assets/hooks.php#L113

Added line #L113 was not covered by tests

/**
* Adds tests to site health.
Expand Down Expand Up @@ -158,6 +155,8 @@
* @since 1.0.0
*/
function perflab_aea_invalidate_cache_transients(): void {
delete_transient( 'aea_blocking_assets' );
// Keeping legacy transients deletion for backward compatibility.
delete_transient( 'aea_enqueued_front_page_scripts' );
delete_transient( 'aea_enqueued_front_page_styles' );
}
Expand Down
Loading