.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 `