.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 this tag (or attribute string) opt out of optimization?
*
* `data-no-optimize` / `data-no-minify` are the de-facto convention
* consent managers and other plugins print so optimizers keep hands
* off (Borlabs Cookie stamps both on its config script). The CSS
* combine buffer has honored `data-no-optimize` from the start; the
* JS paths did not, so a marked consent script was still minified
* into a hashed cache file — and a stale copy of a legally relevant
* consent config is a correctness problem, not a cosmetic one. (#456)
*
* @param string $tag A full tag, or just its attribute string.
*/
public static function tag_opts_out( string $tag ): bool {
return (bool) preg_match( '#\sdata-no-(?:optimize|minify)\b#i', $tag );
}
/**
* Filter: `script_loader_tag`, priority 15 — undo the minify-cache
* rewrite for a script whose printed tag opts out.
*
* The src rewrite happens on `script_loader_src` (priority 10), long
* before any plugin's own `script_loader_tag` filter can stamp
* `data-no-minify` onto the tag — so the marker arrived too late to
* prevent the rewrite. This runs after those filters had their say
* (they typically hook at default priority 10; we're at 15, before
* defer at 20 and delay at 30) and swaps the hashed cache URL back to
* the recorded original.
*
* @param string $tag
* @param string $handle
* @param string $src
*/
public static function restore_marked_script_src( $tag, $handle, $src ): string {
if ( ! is_string( $tag ) || '' === $tag || ! self::tag_opts_out( $tag ) ) {
return (string) $tag;
}
$original = self::original_src( (string) $handle );
if ( '' === $original || '' === (string) $src || false === strpos( $tag, (string) $src ) ) {
return $tag;
}
return str_replace( (string) $src, $original, $tag );
}
/**
* 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 #is',
static function ( array $m ): string {
list( $whole, $attrs, $body ) = $m;
if ( '' === trim( $body ) ) {
return $whole;
}
// The tag itself asked to be left alone. (#456)
if ( self::tag_opts_out( $attrs ) ) {
return $whole;
}
// Our own replay bootstrap. Its body quotes the delay
// machinery's own strings, so a pathological user target
// fragment could match it — and a parked bootstrap means
// nothing on the page ever replays.
if ( false !== stripos( $attrs, 'xspeed-delay-bootstrap' ) ) {
return $whole;
}
// Already marked, or a real src= — the src passes own those.
// `(?' . $body . '';
},
$html
);
// A PCRE failure (backtrack limit on a huge inline body) returns
// null — and casting that to '' would serve AND cache a blank page.
// The unrewritten original is always the safe fallback.
return null === $out ? $html : $out;
}
/**
* Inline bootstrap that flips delayed scripts on the first user
* interaction. Printed once on wp_footer priority 1000.
*/
public static function print_delay_bootstrap(): void {
if ( self::skip_in_non_frontend_context() ) {
return;
}
if ( self::$delay_bootstrap_printed ) {
return;
}
self::$delay_bootstrap_printed = true;
// Failsafe timer for visitors who never interact. 0 disables it
// entirely (interaction-only), which is what lab tools measure
// best: a timer that fires inside Lighthouse's / GTmetrix's
// measurement window loads the "delayed" scripts anyway and
// inflates the reported TTI, so the delay looks ineffective.
$opts = self::opts();
$timeout = isset( $opts['delay_js_timeout'] ) ? (int) $opts['delay_js_timeout'] : 8000;
$timeout = max( 0, min( 60000, $timeout ) );
// Tiny vanilla bootstrap; keep it self-contained so the page
// has no JS dependencies before the first interaction.
?>
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;
}
return self::async_link_markup( $tag );
}
/**
* The one place the async-CSS output shape lives: swap the link's media
* to `print`, restore the original media onload, record it in
* `data-xs-async`, and re-emit the untouched tag inside `