PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.4.0
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.4.0
1.4.1 1.4.0 1.3.7 1.3.6 1.3.5 1.3.4 1.3.3 1.3.2 1.3.1 1.3.0 1.2.4 trunk 1.0.0 1.0.1 1.0.2 1.0.3 1.0.4 1.0.5 1.0.6 1.0.7 1.0.8 1.0.9 1.1.0 1.1.1 1.1.2 All 35 releases
← All changes | includes/class-minify-filters.php +274 -12 1.3.6 → 1.4.0 View file →
@@ -622,9 +622,17 @@
622 622 // A non-executable type means this tag is data, or is being held by
623 623 // somebody else on purpose. The buffer pass has always checked this;
624 624 // the enqueue path did not, so a consent-blocked or JSON-carrying
625 625 // handle could still be rewritten here. (#274)
626 - if ( in_array( self::extract_type( $tag ), self::NON_EXECUTABLE_TYPES, true ) ) {
626 + //
627 + // Read the type from the tag that carries the src. $tag is before +
628 + // external + after, and Smart Delay parks the before/after snippets
629 + // as text/xspeed-delayed before this filter runs. Reading the first
630 + // `type=` in the whole string found the parked snippet, so every
631 + // handle with its own snippets kept a live src behind parked
632 + // snippets.
633 + $type_open = '' !== (string) $src ? self::open_tag_offsets( $tag ) : null;
634 + if ( in_array( self::extract_type( null !== $type_open ? $type_open['attrs'] : $tag ), self::NON_EXECUTABLE_TYPES, true ) ) {
627 635 return $tag;
628 636 }
629 637 // src= variant: swap src → data-xs-src and add data-xs-delay marker.
630 638 if ( '' !== (string) $src ) {
@@ -1057,11 +1065,13 @@
1057 1065 if ( empty( $opts['delay_js'] ) ) {
1058 1066 return $html;
1059 1067 }
1060 1068
1069 + $handle_delayed = self::handle_delay_outcomes( $html );
1070 +
1061 1071 $out = preg_replace_callback(
1062 1072 '#<script\b([^>]*)>(.*?)</script>#is',
1063 - static function ( array $m ): string {
1073 + static function ( array $m ) use ( $handle_delayed ): string {
1064 1074 list( $whole, $attrs, $body ) = $m;
1065 1075
1066 1076 if ( '' === trim( $body ) ) {
1067 1077 return $whole;
@@ -1109,8 +1119,38 @@
1109 1119 if ( preg_match( '#(?<![-\w])id\s*=\s*(["\'])xspeed-#i', $attrs ) ) {
1110 1120 return $whole;
1111 1121 }
1112 1122
1123 + // A handle's own inline blocks follow the handle, not only their
1124 + // body text. The body rule below decided them alone, so a
1125 + // `-js-extra` whose data named a target was parked while its
1126 + // script, kept eager by a URL exclusion, ran first and read an
1127 + // undefined global. (#549)
1128 + //
1129 + // Ahead of the exclusion list on purpose. The handle's own tag
1130 + // was already weighed against it by handle and URL; a body
1131 + // match here would keep the after-code of a delayed script
1132 + // eager, running it before the script it calls.
1133 + if ( preg_match( '#(?<![-\w])id\s*=\s*(["\'])(.+?)-js-(extra|before|after)\1#i', $attrs, $own ) ) {
1134 + $part = strtolower( $own[3] );
1135 + // wp_localize_script data. Early is always safe: it only
1136 + // assigns, and its script cannot run before it.
1137 + if ( 'extra' === $part ) {
1138 + return $whole;
1139 + }
1140 + if ( isset( $handle_delayed[ $own[2] ] ) ) {
1141 + if ( $handle_delayed[ $own[2] ] ) {
1142 + return '<script' . self::park_type_attrs( $attrs ) . '>' . $body . '</script>';
1143 + }
1144 + // The script runs at load, so its `before` code must too.
1145 + // Its `after` code still runs after it if parked, so that
1146 + // one is left to the body rule, like any vendor loader.
1147 + if ( 'before' === $part ) {
1148 + return $whole;
1149 + }
1150 + }
1151 + }
1152 +
1113 1153 // The body stands in for the URL in the lists the src passes
1114 1154 // consult — but NOT via is_delay_target(), whose empty-list
1115 1155 // default is "delay everything". That default is right for a
1116 1156 // tag with a URL and catastrophic here: it would park every
@@ -1123,8 +1163,9 @@
1123 1163 // vendor's inline code eager.
1124 1164 if ( self::is_excluded_script( '', $body, true ) ) {
1125 1165 return $whole;
1126 1166 }
1167 +
1127 1168 if ( ! self::matches_known_third_party( $body ) && ! self::matches_user_targets( $body ) ) {
1128 1169 return $whole;
1129 1170 }
1130 1171
@@ -1143,8 +1184,33 @@
1143 1184 return null === $out ? $html : $out;
1144 1185 }
1145 1186
1146 1187 /**
1188 + * Whether each enqueued handle's external tag ended up delayed.
1189 + *
1190 + * Read from the finished HTML rather than recorded as tags are filtered:
1191 + * this runs after every pass that can delay a tag (script_loader_tag, the
1192 + * late opt-out revert, the raw-tag sweep), so the page itself is the only
1193 + * complete answer.
1194 + *
1195 + * @param string $html Complete page HTML.
1196 + * @return array<string,bool> Handle => delayed.
1197 + */
1198 + private static function handle_delay_outcomes( string $html ): array {
1199 + if ( ! preg_match_all( '#<script\b((?:"[^"]*"|\'[^\']*\'|[^>"\'])*)>#i', $html, $m ) ) {
1200 + return array();
1201 + }
1202 + $out = array();
1203 + foreach ( $m[1] as $attrs ) {
1204 + if ( ! preg_match( '#(?<![-\w])id\s*=\s*(["\'])(.+?)-js\1#i', $attrs, $id ) ) {
1205 + continue;
1206 + }
1207 + $out[ $id[2] ] = false !== stripos( $attrs, 'data-xs-delay' ) || false !== stripos( $attrs, 'data-xs-src' );
1208 + }
1209 + return $out;
1210 + }
1211 +
1212 + /**
1147 1213 * Inline bootstrap that flips delayed scripts on the first user
1148 1214 * interaction. Printed once on wp_footer priority 1000.
1149 1215 */
1150 1216 public static function print_delay_bootstrap(): void {
@@ -1226,8 +1292,21 @@
1226 1292 // want to fight with explicit author intent.
1227 1293 if ( false === stripos( $tag, 'rel=\'stylesheet\'' ) && false === stripos( $tag, 'rel="stylesheet"' ) ) {
1228 1294 return $tag;
1229 1295 }
1296 + // No critical CSS for this page: every stylesheet stays blocking.
1297 + //
1298 + // Deferring a stylesheet only helps when something already styles the
1299 + // first screen. Without that, the page paints unstyled and then jumps
1300 + // when the sheets arrive. Measured on the Templately Astoria pages
1301 + // (Elementor and a block theme): CLS 0.43-1.27 and 15-43 points lower
1302 + // on 7 of 8 pages than the same settings without async CSS. The guards
1303 + // below narrow the damage; this one removes it. WP Rocket, LiteSpeed,
1304 + // Jetpack Boost and FlyingPress likewise never defer CSS without
1305 + // critical CSS. (#588)
1306 + if ( ! self::page_has_critical_css() ) {
1307 + return $tag;
1308 + }
1230 1309 // The stylesheets that lay the page out stay render-blocking.
1231 1310 //
1232 1311 // This transform moves a sheet to AFTER first paint. That is the
1233 1312 // point of it — but a sheet the layout depends on is then missing
@@ -1534,12 +1613,19 @@
1534 1613 * than a hard-coded list.
1535 1614 * - WordPress' own BLOCK and layout sheets (`wp-block-library`,
1536 1615 * `global-styles`, `classic-theme-styles`). These style block
1537 1616 * content on the front end and are as structural as the theme's.
1617 + * - A page builder's GRID sheets: the rows, columns, sections and
1618 + * containers everything else sits in (`kadence-blocks-rowlayout`,
1619 + * `kadence-blocks-column`, `elementor-frontend`, `elementor-post-N`).
1620 + * On a builder page these lay out the hero, not the theme. Deferred,
1621 + * the hero painted as one stacked column and then snapped into its
1622 + * grid: CLS 0.665 on desktop, from one row.
1538 1623 *
1539 - * Everything else — plugin sheets, icon fonts, widget and page-builder
1540 - * add-ons, the long tail that makes async CSS worth having — is still
1541 - * deferred, so the optimization keeps most of its benefit.
1624 + * Everything else — plugin sheets, icon fonts, buttons, forms, the
1625 + * builder's per-widget sheets, the long tail that makes async CSS worth
1626 + * having — is still deferred, so the optimization keeps most of its
1627 + * benefit.
1542 1628 *
1543 1629 * A site WITH critical CSS can defer these too; that is what the
1544 1630 * `xspeed_async_css_layout_critical` filter is for.
1545 1631 *
@@ -1556,19 +1642,21 @@
1556 1642 'wp-block-library-theme',
1557 1643 'global-styles',
1558 1644 'classic-theme-styles',
1559 1645 );
1560 - $critical = in_array( $handle, $core, true );
1646 + $critical = in_array( $handle, $core, true ) || self::is_builder_grid_style( $handle );
1561 1647
1562 1648 // The active theme's own sheets.
1563 1649 //
1564 1650 // Matched on the theme stem, but NOT as a bare prefix: a plugin from
1565 1651 // the same vendor shares it (the Kadence theme is `kadence`, while
1566 - // `kadence-blocks-rowlayout` and `kadence-fonts-gfonts` come from the
1567 - // Kadence Blocks PLUGIN and a webfont loader). Treating those as
1568 - // layout-critical would leave almost nothing deferred and quietly
1569 - // undo the feature. So the stem must be followed by a recognised
1570 - // theme-area segment, which is how themes name their split sheets.
1652 + // `kadence-blocks-image` and `kadence-fonts-gfonts` come from the
1653 + // Kadence Blocks PLUGIN and a webfont loader). Treating every such
1654 + // sheet as layout-critical would leave almost nothing deferred and
1655 + // quietly undo the feature; the builder's grid sheets are caught
1656 + // above by what they do, not whose they are. So the stem must be
1657 + // followed by a recognised theme-area segment, which is how themes
1658 + // name their split sheets.
1571 1659 if ( ! $critical && function_exists( 'get_template' ) ) {
1572 1660 $areas = array(
1573 1661 'style',
1574 1662 'global',
@@ -1613,8 +1701,28 @@
1613 1701 return (bool) apply_filters( 'xspeed_async_css_layout_critical', $critical, $handle );
1614 1702 }
1615 1703
1616 1704 /**
1705 + * Whether a handle is a page builder's grid sheet.
1706 + *
1707 + * Matched on the last segment of the handle, so a builder that names its
1708 + * row sheet `acme-blocks-row-layout` is covered without being listed. The
1709 + * segments are the ones that only ever carry structure; a button, image
1710 + * or form sheet styles an element inside the grid, and the grid holds its
1711 + * place while that sheet loads.
1712 + *
1713 + * @param string $handle Lowercase stylesheet handle.
1714 + */
1715 + private static function is_builder_grid_style( string $handle ): bool {
1716 + if ( preg_match( '#(?:^|-)(?:rowlayout|row-layout|column|columns|container|section|grid)$#', $handle ) ) {
1717 + return true;
1718 + }
1719 + // Builders whose grid lives in a sheet named after the builder or the
1720 + // post, not after a structural element.
1721 + return (bool) preg_match( '#^(?:elementor-frontend|elementor-post-\d+|fl-builder-layout(?:-\d+)?|generateblocks)$#', $handle );
1722 + }
1723 +
1724 + /**
1617 1725 * Filter: `style_loader_src` + `script_loader_src` — strip the
1618 1726 * ?ver=X.Y query string that WP appends for cache busting. Some
1619 1727 * CDNs / reverse proxies cache better when the URL has no query.
1620 1728 *
@@ -2028,9 +2136,9 @@
2028 2136 // has a stored list that knows nothing about consent managers, and
2029 2137 // one that cleared the textarea has no list at all. Neither may
2030 2138 // hide the banner. (#275)
2031 2139 foreach ( self::exclusion_floor() as $needle ) {
2032 - if ( self::target_matches( $needle, $handle, $src ) ) {
2140 + if ( self::floor_matches( $needle, $handle, $src ) ) {
2033 2141 // `continue`, not `break`: a second floor token matching the
2034 2142 // same tag has to be named too, or a filter-added token
2035 2143 // would be lifted by an entry that names only the first.
2036 2144 if ( $named_lifts_floor && self::user_named_consent_manager( $needle, $handle, $src ) ) {
@@ -2055,8 +2163,56 @@
2055 2163 return false;
2056 2164 }
2057 2165
2058 2166 /**
2167 + * target_matches() for a floor token, with the site's own host removed
2168 + * from the URL first.
2169 + *
2170 + * Floor tokens are plugin names, and a plugin's own website is often
2171 + * named after the plugin. On notificationx.com the `notificationx` token
2172 + * matched every same-origin script URL, so Defer JS and Delay JS skipped
2173 + * every script on the site. The path still matches, so a script under
2174 + * /plugins/notificationx/ keeps its protection, and a third-party host
2175 + * such as consent.cookiebot.com still matches in full.
2176 + *
2177 + * @param string $needle Floor token.
2178 + * @param string $handle Script handle.
2179 + * @param string $src Script URL.
2180 + */
2181 + private static function floor_matches( string $needle, string $handle, string $src ): bool {
2182 + if ( '' === $needle ) {
2183 + return false;
2184 + }
2185 + if ( $handle === $needle ) {
2186 + return true;
2187 + }
2188 + foreach ( array( $src, self::original_src( $handle ) ) as $url ) {
2189 + $url = self::without_own_host( $url );
2190 + if ( '' !== $url && false !== stripos( $url, $needle ) ) {
2191 + return true;
2192 + }
2193 + }
2194 + return false;
2195 + }
2196 +
2197 + /**
2198 + * The URL without its scheme and host when the host is the site's own.
2199 + * Any other URL comes back unchanged.
2200 + *
2201 + * @param string $url Script URL.
2202 + */
2203 + private static function without_own_host( string $url ): string {
2204 + if ( '' === $url || ! function_exists( 'home_url' ) ) {
2205 + return $url;
2206 + }
2207 + $host = (string) wp_parse_url( home_url(), PHP_URL_HOST );
2208 + if ( '' === $host ) {
2209 + return $url;
2210 + }
2211 + return (string) preg_replace( '#^(?:https?:)?//' . preg_quote( $host, '#' ) . '(?::\d+)?(?=[/?\#]|$)#i', '', $url );
2212 + }
2213 +
2214 + /**
2059 2215 * Include-list targeting for delay (issue #36): when delay_js_targets
2060 2216 * is non-empty, ONLY matching scripts are delayed — a heavy
2061 2217 * third-party embed can be postponed without delaying the whole
2062 2218 * page's JS. Empty targets = historical behavior (delay everything
@@ -2177,9 +2333,16 @@
2177 2333 private static function smart_delays_handle( string $handle ): bool {
2178 2334 if ( '' === $handle ) {
2179 2335 return false;
2180 2336 }
2337 + // Check the URL delay_script_tag() checks. original_src() is set
2338 + // only when Minify JS rewrote the URL, so with Minify JS off it was
2339 + // '' here. A URL exclusion then passed here and failed there, and
2340 + // the snippets were parked while the script stayed live.
2181 2341 $src = self::original_src( $handle );
2342 + if ( '' === $src ) {
2343 + $src = self::registered_src( $handle );
2344 + }
2182 2345 if ( self::is_excluded_script( $handle, $src, true ) ) {
2183 2346 return false;
2184 2347 }
2185 2348 return self::is_delay_target( $handle, $src );
@@ -2185,8 +2348,73 @@
2185 2348 return self::is_delay_target( $handle, $src );
2186 2349 }
2187 2350
2188 2351 /**
2352 + * A handle's registered URL, made absolute the way WP_Scripts prints it,
2353 + * without the version query. '' when the handle has no file.
2354 + *
2355 + * @param string $handle Script handle.
2356 + */
2357 + private static function registered_src( string $handle ): string {
2358 + if ( ! function_exists( 'wp_scripts' ) ) {
2359 + return '';
2360 + }
2361 + $scripts = wp_scripts();
2362 + if ( ! $scripts instanceof \WP_Scripts ) {
2363 + return '';
2364 + }
2365 + $reg = $scripts->registered[ $handle ] ?? null;
2366 + $src = ( is_object( $reg ) && is_string( $reg->src ) ) ? $reg->src : '';
2367 + if ( '' !== $src && ! preg_match( '#^(?:https?:)?//#i', $src ) ) {
2368 + $src = (string) ( $scripts->base_url ?? '' ) . $src;
2369 + }
2370 + return $src;
2371 + }
2372 +
2373 + /**
2374 + * Filter: `script_loader_tag`, after every other xSpeed pass. Un-park a
2375 + * handle's before/after snippets when its external tag was not delayed.
2376 + *
2377 + * park_smart_inline() decides before the tag exists, so a later rule
2378 + * that keeps the tag live (an opt-out attribute, a non-executable type,
2379 + * the late opt-out revert, another plugin's filter) left the snippets
2380 + * parked and the script live. The script then ran without the config
2381 + * its `before` snippet sets, which is how Elementor's frontend lost
2382 + * elementorFrontendConfig. This filter makes that state impossible.
2383 + *
2384 + * @param string $tag
2385 + * @param string $handle
2386 + * @param string $src
2387 + */
2388 + public static function unpark_orphaned_smart_inline( $tag, $handle, $src ): string {
2389 + if ( ! is_string( $tag ) || '' === $tag || '' === (string) $handle || false === stripos( $tag, 'text/xspeed-delayed' ) ) {
2390 + return (string) $tag;
2391 + }
2392 + // Only the external tag in this string can carry data-xs-src. Its
2393 + // `src` is gone once delayed, so open_tag_offsets() cannot find it.
2394 + if ( preg_match( '#<script\b[^>]*(?<![-\w])data-xs-src\s*=#i', $tag ) ) {
2395 + return $tag;
2396 + }
2397 + return (string) preg_replace_callback(
2398 + '#<script\b([^>]*\sid\s*=\s*(["\'])' . preg_quote( (string) $handle, '#' ) . '-js-(?:before|after)\2[^>]*)>#i',
2399 + static function ( array $m ): string {
2400 + $attrs = $m[1];
2401 + if ( 'text/xspeed-delayed' !== self::extract_type( $attrs ) ) {
2402 + return $m[0];
2403 + }
2404 + $orig = preg_match( '#\sdata-xs-type\s*=\s*(["\'])([^"\']*)\1#i', $attrs, $t ) ? $t[2] : '';
2405 + $attrs = (string) preg_replace( self::TYPE_ATTR_RE, '', $attrs );
2406 + $attrs = (string) preg_replace( '#\sdata-xs-(?:delay|type)\s*=\s*(["\'])[^"\']*\1#i', '', $attrs );
2407 + if ( '' !== $orig ) {
2408 + $attrs .= ' type="' . esc_attr( $orig ) . '"';
2409 + }
2410 + return '<script' . $attrs . '>';
2411 + },
2412 + $tag
2413 + );
2414 + }
2415 +
2416 + /**
2189 2417 * Park a delayed handle's own before/after snippet, in Smart Delay mode.
2190 2418 *
2191 2419 * Runs on `wp_inline_script_attributes`, which fires for every inline
2192 2420 * script WordPress prints itself — so it works on pages the HTML buffer
@@ -2367,8 +2595,9 @@
2367 2595 self::$opts = null;
2368 2596 self::$uploads_base = null;
2369 2597 self::$delay_bootstrap_printed = false;
2370 2598 self::$js_measured_layout = null;
2599 + self::$has_critical_css = null;
2371 2600 self::$exclusion_floor = null;
2372 2601 self::$inline_bound_handles = null;
2373 2602 self::$pristine_tag = array();
2374 2603 self::$our_late_attrs = array();
@@ -2524,8 +2753,41 @@
2524 2753 *
2525 2754 * @var bool|null
2526 2755 */
2527 2756 private static $js_measured_layout = null;
2757 +
2758 + /**
2759 + * Per-request memo for page_has_critical_css(). Null = not resolved.
2760 + *
2761 + * @var bool|null
2762 + */
2763 + private static $has_critical_css = null;
2764 +
2765 + /**
2766 + * Does something inline critical CSS for the page being served?
2767 + *
2768 + * Free generates none, so the answer comes from the filter: an extension
2769 + * that inlines critical CSS for this page returns true, and a site whose
2770 + * theme ships its own can too. Resolved once per request, because every
2771 + * stylesheet tag asks.
2772 + */
2773 + public static function page_has_critical_css(): bool {
2774 + if ( null === self::$has_critical_css ) {
2775 + /**
2776 + * Whether the page being served has critical CSS inlined in its head.
2777 + *
2778 + * Async CSS defers stylesheets only when this is true; without
2779 + * critical CSS it leaves them render-blocking, because deferring
2780 + * them makes the page paint unstyled and shift. Return true when
2781 + * something inlines critical CSS for this page, or to keep deferring
2782 + * without it.
2783 + *
2784 + * @param bool $has_critical_css Default false.
2785 + */
2786 + self::$has_critical_css = (bool) apply_filters( 'xspeed_async_css_page_has_critical_css', false );
2787 + }
2788 + return self::$has_critical_css;
2789 + }
2528 2790
2529 2791 /**
2530 2792 * Scripts that lay out the page by measuring the DOM.
2531 2793 *