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 11 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 @@ function perflab_aea_enqueued_js_assets_test(): array {
}

$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 @@ function perflab_aea_enqueued_js_assets_test(): array {
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 @@ function perflab_aea_enqueued_js_assets_test(): array {
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 @@ function perflab_aea_enqueued_css_assets_test(): array {
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 @@ function perflab_aea_enqueued_css_assets_test(): array {
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 @@ function perflab_aea_enqueued_css_assets_test(): array {
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 @@ -293,6 +293,11 @@ function perflab_aea_get_path_from_resource_url( string $resource_url ): string
return '';
}

// Remove query string if present.
if ( false !== strpos( $resource_url, '?' ) ) {
$resource_url = substr( $resource_url, 0, strpos( $resource_url, '?' ) );
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 @@ function perflab_aea_get_path_from_resource_url( string $resource_url ): string
// 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|false Returns the content length in bytes or false if it cannot be determined.
*/
function perflab_aea_get_asset_content_length( string $resource_url ) {
$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 false;
}

$content_length = wp_remote_retrieve_header( $head_response, 'content-length' );
if ( is_array( $content_length ) && 0 < count( $content_length ) ) {
$content_length = $content_length[0];
}
if ( ! is_string( $content_length ) || '' === $content_length || ! ctype_digit( $content_length ) || 0 === (int) $content_length ) {
return false;
}

return (int) $content_length;
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,107 +13,109 @@
// @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_enqueued_front_page_scripts' ) && false !== get_transient( 'aea_enqueued_front_page_styles' ) )
Copy link
Member

Choose a reason for hiding this comment

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

I think it would make sense to combine these two transients into one aea_blocking_assets, or something like that

) {
return;
}

foreach ( $wp_scripts->done as $handle ) {
$script = $wp_scripts->registered[ $handle ];
$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;
}

$html = wp_remote_retrieve_body( $response );
if ( '' === $html ) {
return;
}

if ( ! $script->src || false !== strpos( $script->src, 'wp-includes' ) ) {
$assets = array(
'scripts' => array(),
'styles' => array(),
);

$processor = new WP_HTML_Tag_Processor( $html );

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

/**
* 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 ( false !== $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;
}

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

if ( 0 !== count( $assets['scripts'] ) ) {
set_transient( 'aea_enqueued_front_page_scripts', $assets['scripts'], 12 * HOUR_IN_SECONDS );
}
if ( 0 !== count( $assets['styles'] ) ) {
set_transient( 'aea_enqueued_front_page_styles', $assets['styles'], 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' );

/**
* Adds tests to site health.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ public static function return_added_test_info_site_health(): array {
*/
public static function return_aea_enqueued_js_assets_test_callback_less_than_threshold( int $enqueued_scripts = 1 ): array {
$result = array(
'label' => esc_html__( 'Enqueued scripts', 'performance-lab' ),
'label' => esc_html__( 'Blocking scripts', 'performance-lab' ),
'status' => 'good',
'badge' => array(
'label' => esc_html__( 'Performance', 'performance-lab' ),
Expand All @@ -49,8 +49,8 @@ public static function return_aea_enqueued_js_assets_test_callback_less_than_thr
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 @@ -80,8 +80,8 @@ public static function return_aea_enqueued_js_assets_test_callback_more_than_thr
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 @@ -109,7 +109,7 @@ public static function return_aea_enqueued_js_assets_test_callback_more_than_thr
*/
public static function return_aea_enqueued_css_assets_test_callback_less_than_threshold( int $enqueued_styles = 1 ): array {
$result = array(
'label' => esc_html__( 'Enqueued styles', 'performance-lab' ),
'label' => esc_html__( 'Blocking styles', 'performance-lab' ),
'status' => 'good',
'badge' => array(
'label' => esc_html__( 'Performance', 'performance-lab' ),
Expand All @@ -121,8 +121,8 @@ public static function return_aea_enqueued_css_assets_test_callback_less_than_th
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 @@ -152,8 +152,8 @@ public static function return_aea_enqueued_css_assets_test_callback_more_than_th
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
Loading
Loading