.js path long before
* `script_loader_tag` (priority 20/30) runs, so the delay + exclusion
* checks only ever see the hashed URL. A user targeting a script by
* URL substring — the obvious thing to do, and what the UI invites —
* would silently stop matching the moment minification was enabled.
* Minifier::rewrite_script() records the original here so those
* checks can test both. (FBS field report against 1.1.2)
*
* @var array
*/
private static $original_src = array();
/**
* Record a script's URL as it was BEFORE minification rewrote it.
* Called from Minifier::rewrite_script().
*
* @param string $handle Script handle.
* @param string $src Original (pre-minify) URL.
*/
public static function remember_original_src( string $handle, string $src ): void {
if ( '' !== $handle && '' !== $src ) {
self::$original_src[ $handle ] = $src;
}
}
/**
* The pre-minify URL for a handle, or '' when we never rewrote it
* (external script, minification off, or a handle we didn't touch).
*
* @param string $handle Script handle.
*/
public static function original_src( string $handle ): string {
return isset( self::$original_src[ $handle ] ) ? self::$original_src[ $handle ] : '';
}
/**
* Reset the remembered URLs. Test-only seam.
*/
public static function reset_original_src(): void {
self::$original_src = array();
}
/**
* Does a user-supplied target match this script?
*
* A target is either a script handle (exact) or a URL substring. The
* URL is checked against BOTH the current src and the pre-minify src,
* so a target written against the real asset path keeps working once
* minification starts rewriting URLs to hashed cache paths.
*
* @param string $needle Target from the user's list.
* @param string $handle Script handle.
* @param string $src Current (possibly rewritten) src.
*/
private static function target_matches( string $needle, string $handle, string $src ): bool {
if ( '' === $needle ) {
return false;
}
if ( $handle === $needle ) {
return true;
}
if ( '' !== $src && false !== stripos( $src, $needle ) ) {
return true;
}
$original = self::original_src( $handle );
return '' !== $original && false !== stripos( $original, $needle );
}
/**
* Filter: `script_loader_tag` — add defer="defer" to non-excluded
* scripts. WordPress passes the full
fallback so
* users with JS disabled still get styles applied (via media="all").
*
* @param string $tag
* @param string $handle
*/
public static function async_style_tag( $tag, $handle ): string {
if ( ! is_string( $tag ) || '' === $tag ) {
return (string) $tag;
}
if ( self::skip_in_non_frontend_context() ) {
return $tag;
}
// Only operate on with a media attribute
// we can swap. Skip anything custom (preload, etc.) — we don't
// want to fight with explicit author intent.
if ( false === stripos( $tag, 'rel=\'stylesheet\'' ) && false === stripos( $tag, 'rel="stylesheet"' ) ) {
return $tag;
}
// The stylesheets that lay the page out stay render-blocking.
//
// This transform moves a sheet to AFTER first paint. That is the
// point of it — but a sheet the layout depends on is then missing
// from the only paint the visitor sees, and the page renders as
// unstyled HTML (bulleted nav, underlined links) until the swap
// runs. The pattern is only safe when something already styles the
// above-the-fold area, i.e. critical CSS — which Free does not
// generate. Deferring EVERY sheet on a site without it guarantees
// the flash rather than risking it: on the reported Kadence site
// all 17 stylesheets were deferred and none was render-blocking,
// so there was nothing left to paint the page with. (#269)
if ( self::is_layout_critical_style( $handle ) ) {
return $tag;
}
// A JS-measured layout on this page makes deferral unsafe for EVERY
// sheet, not just the theme's.
//
// Masonry, isotope, packery and the slider libraries lay elements out
// by MEASURING them and then writing absolute positions. Deferring the
// stylesheet that sizes those elements means the script measures them
// unstyled — zero or full-width — computes positions from those wrong
// numbers, and commits them. The CSS arriving a moment later cannot
// undo it: the script has already run and does not re-measure. The
// result is a permanently broken grid (items overlapping, or stranded
// with a large gap), which is worse than the flash this feature's
// other guard prevents, because it never resolves itself.
//
// This is checked per PAGE rather than per handle deliberately. The
// script that measures is rarely the one whose handle matches the
// sheet — Kadence's gallery is styled by
// `kadence-blocks-advancedgallery` but laid out by core's `masonry` —
// so pairing handles misses it. Whether a measuring library is present
// at all is the signal that generalises. (#269)
if ( self::page_has_js_measured_layout() ) {
return $tag;
}
// Avoid double-wrapping.
if ( false !== stripos( $tag, 'data-xs-async' ) ) {
return $tag;
}
// Someone else already made this sheet non-render-blocking.
//
// Plugins that ship their own async-CSS handling apply the same
// media="print" + onload swap we do, and they run on the SAME
// filter — SureCookie's consent banner does it at style_loader_tag
// priority 10, ours is priority 20, so its finished tag arrives
// here looking like a plain stylesheet with no marker of ours.
//
// Transforming it again breaks the sheet two ways: the media we'd
// capture as "the original to restore" is already `print`, so we
// emit onload="this.media='print'" — a swap to itself that never
// activates the stylesheet — and we append a SECOND onload
// attribute, of which the parser honours only the first (ours),
// discarding the plugin's correct this.media='all'. The banner
// then mounts unstyled, in both logged-in and logged-out states.
//
// An onload handler or a print media on a stylesheet link is only
// ever this pattern; a genuinely print-only sheet is already off
// the critical path and gains nothing from us. Either way the
// right move is to leave the tag alone — the same "don't fight
// explicit author intent" rule the rel= check above applies. (#216)
if ( preg_match( '#\bonload\s*=#i', $tag ) ) {
return $tag;
}
if ( preg_match( '#\bmedia\s*=\s*(["\'])\s*print\s*\1#i', $tag ) ) {
return $tag;
}
$async = (string) preg_replace_callback(
'#\bmedia\s*=\s*(["\'])([^"\']*)\1#i',
static function ( $m ) {
$orig = $m[2];
return 'media="print" onload="this.media=\'' . esc_attr( $orig ) . '\'" data-xs-async="' . esc_attr( $orig ) . '"';
},
$tag,
1
);
// If no media= was present (rare), inject one.
if ( $async === $tag ) {
$async = (string) preg_replace(
'#.
return $async . '';
}
/**
* Whether a stylesheet handle carries the page's layout, and so must
* keep blocking the first paint.
*
* Two families qualify:
*
* - The ACTIVE THEME's own sheets. A theme stylesheet is the page's
* layout by definition; without it the document paints as unstyled
* HTML. Resolved from the live theme's stem (`kadence` →
* `kadence-global`, `kadence-header`, …) plus the handles WordPress
* itself registers for a theme, so this holds for any theme rather
* than a hard-coded list.
* - WordPress' own BLOCK and layout sheets (`wp-block-library`,
* `global-styles`, `classic-theme-styles`). These style block
* content on the front end and are as structural as the theme's.
*
* Everything else — plugin sheets, icon fonts, widget and page-builder
* add-ons, the long tail that makes async CSS worth having — is still
* deferred, so the optimization keeps most of its benefit.
*
* A site WITH critical CSS can defer these too; that is what the
* `xspeed_async_css_layout_critical` filter is for.
*
* Pure aside from the theme lookup — unit-tested via the filter.
*
* @param string $handle Stylesheet handle from `style_loader_tag`.
*/
public static function is_layout_critical_style( string $handle ): bool {
$handle = strtolower( $handle );
// Core's front-end block + global styles.
$core = array(
'wp-block-library',
'wp-block-library-theme',
'global-styles',
'classic-theme-styles',
);
$critical = in_array( $handle, $core, true );
// The active theme's own sheets.
//
// Matched on the theme stem, but NOT as a bare prefix: a plugin from
// the same vendor shares it (the Kadence theme is `kadence`, while
// `kadence-blocks-rowlayout` and `kadence-fonts-gfonts` come from the
// Kadence Blocks PLUGIN and a webfont loader). Treating those as
// layout-critical would leave almost nothing deferred and quietly
// undo the feature. So the stem must be followed by a recognised
// theme-area segment, which is how themes name their split sheets.
if ( ! $critical && function_exists( 'get_template' ) ) {
$areas = array(
'style',
'global',
'header',
'content',
'footer',
'main',
'layout',
'base',
'core',
'theme',
'woocommerce',
);
foreach ( array( get_template(), get_stylesheet() ) as $stem ) {
$stem = strtolower( (string) $stem );
if ( '' === $stem ) {
continue;
}
if ( $handle === $stem ) {
$critical = true;
break;
}
foreach ( $areas as $area ) {
if ( $handle === $stem . '-' . $area ) {
$critical = true;
break 2;
}
}
}
}
/**
* Whether this stylesheet must keep blocking the first paint.
*
* Return false for a handle to let async CSS defer it anyway — the
* right call on a site that ships critical CSS. Return true to
* protect an additional sheet the layout depends on.
*
* @param bool $critical Whether the sheet is treated as layout-critical.
* @param string $handle The stylesheet handle.
*/
return (bool) apply_filters( 'xspeed_async_css_layout_critical', $critical, $handle );
}
/**
* Filter: `style_loader_src` + `script_loader_src` — strip the
* ?ver=X.Y query string that WP appends for cache busting. Some
* CDNs / reverse proxies cache better when the URL has no query.
*
* Skip URLs whose query carries non-ver params — those might be
* intentional (e.g. a CDN providing per-image transforms).
*
* @param string $src
*/
public static function strip_version_query( $src ): string {
if ( ! is_string( $src ) || '' === $src ) {
return (string) $src;
}
if ( self::skip_in_non_frontend_context() ) {
return $src;
}
$parts = wp_parse_url( $src );
if ( ! is_array( $parts ) || empty( $parts['query'] ) ) {
return $src;
}
parse_str( $parts['query'], $query );
if ( ! is_array( $query ) ) {
return $src;
}
// Only strip 'ver' — keep anything else the asset URL needs.
unset( $query['ver'] );
$new_query = http_build_query( $query );
$new_url = ( $parts['scheme'] ?? 'http' ) . '://' . ( $parts['host'] ?? '' );
if ( isset( $parts['port'] ) ) {
$new_url .= ':' . $parts['port'];
}
$new_url .= $parts['path'] ?? '';
if ( '' !== $new_query ) {
$new_url .= '?' . $new_query;
}
if ( ! empty( $parts['fragment'] ) ) {
$new_url .= '#' . $parts['fragment'];
}
return $new_url;
}
/**
* Defensive context guard for filter callbacks. Mirrors the registration-
* time bail in Minifier::__construct() so a late context flip (admin page
* render kicked off mid-request, REST_REQUEST set after plugins_loaded,
* etc.) doesn't let frontend tag rewrites leak into wp-admin / AJAX /
* REST / cron responses.
*
* Specifically prevents the React admin bundle's