.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