.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 ); } /** * Pristine tags as they looked before any of our transforms, keyed by * handle. See snapshot_tag() / revert_late_marked_tag(). * * @var array */ private static $pristine_tag = array(); /** * Priority for the late opt-out re-check. Past Borlabs' ScriptBlocker * at 999 — the highest stamper we have seen in the wild — so the * marker has certainly landed by the time we look. (#469) */ private const LATE_OPT_OUT_PRIORITY = 1000; /** * The priority the late opt-out re-check runs at. * * A site whose stamper hooks even later can move ours past it. */ public static function late_opt_out_priority(): int { /** * Filter the priority of xSpeed's late data-no-optimize re-check. * * @param int $priority Default 1000. */ return (int) apply_filters( 'xspeed_late_opt_out_priority', self::LATE_OPT_OUT_PRIORITY ); } /** * Filter: `script_loader_tag`, priority 9 — remember the tag before we * touch it, so a marker stamped later can still be honored. * * Our three opt-out-aware transforms run at 15/20/30. A plugin that * stamps `data-no-optimize` AFTER them is invisible to all three: * Borlabs Cookie stamps at priority 100, so its consent config was * still minified into a hashed cache file AND delayed — the script * that has to run before anything else on the page ran only on first * interaction. Snapshotting here is what lets the late pass put the * original back verbatim, rather than trying to unpick each transform * in reverse. (#469) * * @param string $tag * @param string $handle * @param string $src */ public static function snapshot_tag( $tag, $handle, $src ): string { if ( is_string( $tag ) && '' !== $tag && '' !== (string) $handle ) { self::$pristine_tag[ (string) $handle ] = $tag; } return (string) $tag; } /** * Filter: `script_loader_tag`, priority `LATE_OPT_OUT_PRIORITY` — hand * back the untouched tag when a late filter stamped an opt-out marker * after our transforms had already run. * * The priority has to clear the stamper, not merely the transforms: * Borlabs stamps at 100 and Borlabs' own script blocker at 999, so an * earlier hook reads a tag whose marker has not landed yet. PHP_INT_MAX * would be unfriendly to a site that legitimately wants the last word, * so this sits just past the highest stamper we know of and is * filterable. Reverting to the snapshot is deliberate: undoing * a delay rewrite in place would mean re-deriving `src` from * `data-xs-src` and stripping markers, and #273 is a standing reminder * that regex-editing these attributes in reverse goes wrong quietly. * * The pristine tag still carries whatever priority-10 filters did to * it, so only OUR changes are dropped. (#469) * * @param string $tag * @param string $handle * @param string $src */ public static function revert_late_marked_tag( $tag, $handle, $src ): string { if ( ! is_string( $tag ) || '' === $tag || ! self::tag_opts_out( $tag ) ) { return (string) $tag; } $handle = (string) $handle; $pristine = isset( self::$pristine_tag[ $handle ] ) ? self::$pristine_tag[ $handle ] : ''; if ( '' !== $pristine && $pristine !== $tag ) { // The marker is on the tag we were handed, not on the snapshot, // so carry it — and everything else the late filter set in the // same pass — over. A consumer reading the rendered HTML (or // our own buffer passes) must still see the opt-out it asked // for. $tag = self::copy_late_attributes( $tag, $pristine ); } // The snapshot was taken on `script_loader_tag`, by which point // `script_loader_src` (priority 10) had ALREADY swapped in the // hashed cache URL — so reverting the tag alone still leaves the // minified src behind, which is the half the client actually // reported. Undo that here too, using the URL rewrite_script() // recorded. (#469) return self::restore_marked_script_src( $tag, $handle, self::current_src( $tag, (string) $src ) ); } /** * The src currently on a tag, falling back to the one WordPress passed. * * After a revert the tag carries the snapshot's src, which is not * necessarily the `$src` argument this late in the chain. * * @param string $tag Tag to read. * @param string $fallback Value to use when the tag has no src. */ private static function current_src( string $tag, string $fallback ): string { $open = self::open_tag_offsets( $tag ); if ( null !== $open && preg_match( '#(?= 0; $i-- ) { $add = self::late_attribute_delta( $late_tags[ $i ]['attrs'], $to_tags[ $i ]['attrs'] ); if ( '' !== $add ) { $out = substr_replace( $out, $add, $to_tags[ $i ]['attrs_end'], 0 ); } } return $out; } /** * Fallback when the late tag and the snapshot cannot be paired * positionally: copy only the attributes that protect the script from * optimizers — the opt-out markers plus `data-cfasync` — onto every * snapshot tag missing them. Values are taken as the stamper wrote * them on the late tag. (#470) * * @param string $from Tag as the late filter left it. * @param string $to Snapshot tag to stamp onto. * @param array $to_tags open_tags() result for $to. */ private static function stamp_protective_attributes( string $from, string $to, array $to_tags ): string { $protect = array(); foreach ( array( 'data-no-optimize', 'data-no-minify', 'data-cfasync' ) as $name ) { if ( preg_match( '#\s(' . preg_quote( $name, '#' ) . ')(\s*=\s*(?:"[^"]*"|\'[^\']*\'|[^\s>]*))?#i', $from, $m ) ) { $protect[ $name ] = ' ' . $name . ( isset( $m[2] ) ? $m[2] : '' ); } } if ( empty( $protect ) ) { return $to; } $out = $to; // Right to left: an earlier splice would shift every later offset. for ( $i = count( $to_tags ) - 1; $i >= 0; $i-- ) { $add = ''; foreach ( $protect as $name => $attr ) { if ( ! preg_match( '#\s' . preg_quote( $name, '#' ) . '\b#i', $to_tags[ $i ]['attrs'] ) ) { $add .= $attr; } } if ( '' !== $add ) { $out = substr_replace( $out, $add, $to_tags[ $i ]['attrs_end'], 0 ); } } return $out; } /** * The attributes present on the late tag but not the snapshot, minus * the ones our own transforms add. * * @param string $late_attrs Attribute string from the transformed tag. * @param string $to_attrs Attribute string from the snapshot tag. */ private static function late_attribute_delta( string $late_attrs, string $to_attrs ): string { $pattern = '#\s([-\w:]+)(?:\s*=\s*(?:"[^"]*"|\'[^\']*\'|[^\s>]*))?#'; if ( ! preg_match_all( $pattern, $late_attrs, $late, PREG_SET_ORDER ) ) { return ''; } $have = array(); if ( preg_match_all( $pattern, $to_attrs, $existing, PREG_SET_ORDER ) ) { foreach ( $existing as $attr ) { $have[ strtolower( $attr[1] ) ] = true; } } $add = ''; foreach ( $late as $attr ) { $name = strtolower( $attr[1] ); if ( isset( $have[ $name ] ) || in_array( $name, self::OUR_TRANSFORM_ATTRS, true ) ) { continue; } if ( 0 === strpos( $name, 'data-xs-' ) ) { continue; } $add .= $attr[0]; } return $add; } /** * Attributes our own transforms add. Copying any of these from the * transformed tag back onto the snapshot would re-apply the very * transform we are undoing: * * defer/async — defer_script_tag() * type — delay_script_tag() parks an inline block as * text/xspeed-delayed; a type the author set is on the * snapshot already and matches by name before we get here * src — belongs to the snapshot, never to the late tag * * `data-xs-*` is handled by prefix separately. (#469) */ private const OUR_TRANSFORM_ATTRS = array( 'defer', 'async', 'type', 'src' ); /** * Locate the opening `#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 `