PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.3.7
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.3.7
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 1.1.3 1.1.4 All 33 releases
← All changes | includes/class-minify-filters.php +144 -10 1.3.6 → 1.3.7 View file →
@@ -1057,11 +1057,13 @@
1057 1057 if ( empty( $opts['delay_js'] ) ) {
1058 1058 return $html;
1059 1059 }
1060 1060
1061 + $handle_delayed = self::handle_delay_outcomes( $html );
1062 +
1061 1063 $out = preg_replace_callback(
1062 1064 '#<script\b([^>]*)>(.*?)</script>#is',
1063 - static function ( array $m ): string {
1065 + static function ( array $m ) use ( $handle_delayed ): string {
1064 1066 list( $whole, $attrs, $body ) = $m;
1065 1067
1066 1068 if ( '' === trim( $body ) ) {
1067 1069 return $whole;
@@ -1109,8 +1111,38 @@
1109 1111 if ( preg_match( '#(?<![-\w])id\s*=\s*(["\'])xspeed-#i', $attrs ) ) {
1110 1112 return $whole;
1111 1113 }
1112 1114
1115 + // A handle's own inline blocks follow the handle, not only their
1116 + // body text. The body rule below decided them alone, so a
1117 + // `-js-extra` whose data named a target was parked while its
1118 + // script, kept eager by a URL exclusion, ran first and read an
1119 + // undefined global. (#549)
1120 + //
1121 + // Ahead of the exclusion list on purpose. The handle's own tag
1122 + // was already weighed against it by handle and URL; a body
1123 + // match here would keep the after-code of a delayed script
1124 + // eager, running it before the script it calls.
1125 + if ( preg_match( '#(?<![-\w])id\s*=\s*(["\'])(.+?)-js-(extra|before|after)\1#i', $attrs, $own ) ) {
1126 + $part = strtolower( $own[3] );
1127 + // wp_localize_script data. Early is always safe: it only
1128 + // assigns, and its script cannot run before it.
1129 + if ( 'extra' === $part ) {
1130 + return $whole;
1131 + }
1132 + if ( isset( $handle_delayed[ $own[2] ] ) ) {
1133 + if ( $handle_delayed[ $own[2] ] ) {
1134 + return '<script' . self::park_type_attrs( $attrs ) . '>' . $body . '</script>';
1135 + }
1136 + // The script runs at load, so its `before` code must too.
1137 + // Its `after` code still runs after it if parked, so that
1138 + // one is left to the body rule, like any vendor loader.
1139 + if ( 'before' === $part ) {
1140 + return $whole;
1141 + }
1142 + }
1143 + }
1144 +
1113 1145 // The body stands in for the URL in the lists the src passes
1114 1146 // consult — but NOT via is_delay_target(), whose empty-list
1115 1147 // default is "delay everything". That default is right for a
1116 1148 // tag with a URL and catastrophic here: it would park every
@@ -1123,8 +1155,9 @@
1123 1155 // vendor's inline code eager.
1124 1156 if ( self::is_excluded_script( '', $body, true ) ) {
1125 1157 return $whole;
1126 1158 }
1159 +
1127 1160 if ( ! self::matches_known_third_party( $body ) && ! self::matches_user_targets( $body ) ) {
1128 1161 return $whole;
1129 1162 }
1130 1163
@@ -1143,8 +1176,33 @@
1143 1176 return null === $out ? $html : $out;
1144 1177 }
1145 1178
1146 1179 /**
1180 + * Whether each enqueued handle's external tag ended up delayed.
1181 + *
1182 + * Read from the finished HTML rather than recorded as tags are filtered:
1183 + * this runs after every pass that can delay a tag (script_loader_tag, the
1184 + * late opt-out revert, the raw-tag sweep), so the page itself is the only
1185 + * complete answer.
1186 + *
1187 + * @param string $html Complete page HTML.
1188 + * @return array<string,bool> Handle => delayed.
1189 + */
1190 + private static function handle_delay_outcomes( string $html ): array {
1191 + if ( ! preg_match_all( '#<script\b((?:"[^"]*"|\'[^\']*\'|[^>"\'])*)>#i', $html, $m ) ) {
1192 + return array();
1193 + }
1194 + $out = array();
1195 + foreach ( $m[1] as $attrs ) {
1196 + if ( ! preg_match( '#(?<![-\w])id\s*=\s*(["\'])(.+?)-js\1#i', $attrs, $id ) ) {
1197 + continue;
1198 + }
1199 + $out[ $id[2] ] = false !== stripos( $attrs, 'data-xs-delay' ) || false !== stripos( $attrs, 'data-xs-src' );
1200 + }
1201 + return $out;
1202 + }
1203 +
1204 + /**
1147 1205 * Inline bootstrap that flips delayed scripts on the first user
1148 1206 * interaction. Printed once on wp_footer priority 1000.
1149 1207 */
1150 1208 public static function print_delay_bootstrap(): void {
@@ -1226,8 +1284,21 @@
1226 1284 // want to fight with explicit author intent.
1227 1285 if ( false === stripos( $tag, 'rel=\'stylesheet\'' ) && false === stripos( $tag, 'rel="stylesheet"' ) ) {
1228 1286 return $tag;
1229 1287 }
1288 + // No critical CSS for this page: every stylesheet stays blocking.
1289 + //
1290 + // Deferring a stylesheet only helps when something already styles the
1291 + // first screen. Without that, the page paints unstyled and then jumps
1292 + // when the sheets arrive. Measured on the Templately Astoria pages
1293 + // (Elementor and a block theme): CLS 0.43-1.27 and 15-43 points lower
1294 + // on 7 of 8 pages than the same settings without async CSS. The guards
1295 + // below narrow the damage; this one removes it. WP Rocket, LiteSpeed,
1296 + // Jetpack Boost and FlyingPress likewise never defer CSS without
1297 + // critical CSS. (#588)
1298 + if ( ! self::page_has_critical_css() ) {
1299 + return $tag;
1300 + }
1230 1301 // The stylesheets that lay the page out stay render-blocking.
1231 1302 //
1232 1303 // This transform moves a sheet to AFTER first paint. That is the
1233 1304 // point of it — but a sheet the layout depends on is then missing
@@ -1534,12 +1605,19 @@
1534 1605 * than a hard-coded list.
1535 1606 * - WordPress' own BLOCK and layout sheets (`wp-block-library`,
1536 1607 * `global-styles`, `classic-theme-styles`). These style block
1537 1608 * content on the front end and are as structural as the theme's.
1609 + * - A page builder's GRID sheets: the rows, columns, sections and
1610 + * containers everything else sits in (`kadence-blocks-rowlayout`,
1611 + * `kadence-blocks-column`, `elementor-frontend`, `elementor-post-N`).
1612 + * On a builder page these lay out the hero, not the theme. Deferred,
1613 + * the hero painted as one stacked column and then snapped into its
1614 + * grid: CLS 0.665 on desktop, from one row.
1538 1615 *
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.
1616 + * Everything else — plugin sheets, icon fonts, buttons, forms, the
1617 + * builder's per-widget sheets, the long tail that makes async CSS worth
1618 + * having — is still deferred, so the optimization keeps most of its
1619 + * benefit.
1542 1620 *
1543 1621 * A site WITH critical CSS can defer these too; that is what the
1544 1622 * `xspeed_async_css_layout_critical` filter is for.
1545 1623 *
@@ -1556,19 +1634,21 @@
1556 1634 'wp-block-library-theme',
1557 1635 'global-styles',
1558 1636 'classic-theme-styles',
1559 1637 );
1560 - $critical = in_array( $handle, $core, true );
1638 + $critical = in_array( $handle, $core, true ) || self::is_builder_grid_style( $handle );
1561 1639
1562 1640 // The active theme's own sheets.
1563 1641 //
1564 1642 // Matched on the theme stem, but NOT as a bare prefix: a plugin from
1565 1643 // 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.
1644 + // `kadence-blocks-image` and `kadence-fonts-gfonts` come from the
1645 + // Kadence Blocks PLUGIN and a webfont loader). Treating every such
1646 + // sheet as layout-critical would leave almost nothing deferred and
1647 + // quietly undo the feature; the builder's grid sheets are caught
1648 + // above by what they do, not whose they are. So the stem must be
1649 + // followed by a recognised theme-area segment, which is how themes
1650 + // name their split sheets.
1571 1651 if ( ! $critical && function_exists( 'get_template' ) ) {
1572 1652 $areas = array(
1573 1653 'style',
1574 1654 'global',
@@ -1613,8 +1693,28 @@
1613 1693 return (bool) apply_filters( 'xspeed_async_css_layout_critical', $critical, $handle );
1614 1694 }
1615 1695
1616 1696 /**
1697 + * Whether a handle is a page builder's grid sheet.
1698 + *
1699 + * Matched on the last segment of the handle, so a builder that names its
1700 + * row sheet `acme-blocks-row-layout` is covered without being listed. The
1701 + * segments are the ones that only ever carry structure; a button, image
1702 + * or form sheet styles an element inside the grid, and the grid holds its
1703 + * place while that sheet loads.
1704 + *
1705 + * @param string $handle Lowercase stylesheet handle.
1706 + */
1707 + private static function is_builder_grid_style( string $handle ): bool {
1708 + if ( preg_match( '#(?:^|-)(?:rowlayout|row-layout|column|columns|container|section|grid)$#', $handle ) ) {
1709 + return true;
1710 + }
1711 + // Builders whose grid lives in a sheet named after the builder or the
1712 + // post, not after a structural element.
1713 + return (bool) preg_match( '#^(?:elementor-frontend|elementor-post-\d+|fl-builder-layout(?:-\d+)?|generateblocks)$#', $handle );
1714 + }
1715 +
1716 + /**
1617 1717 * Filter: `style_loader_src` + `script_loader_src` — strip the
1618 1718 * ?ver=X.Y query string that WP appends for cache busting. Some
1619 1719 * CDNs / reverse proxies cache better when the URL has no query.
1620 1720 *
@@ -2367,8 +2467,9 @@
2367 2467 self::$opts = null;
2368 2468 self::$uploads_base = null;
2369 2469 self::$delay_bootstrap_printed = false;
2370 2470 self::$js_measured_layout = null;
2471 + self::$has_critical_css = null;
2371 2472 self::$exclusion_floor = null;
2372 2473 self::$inline_bound_handles = null;
2373 2474 self::$pristine_tag = array();
2374 2475 self::$our_late_attrs = array();
@@ -2524,8 +2625,41 @@
2524 2625 *
2525 2626 * @var bool|null
2526 2627 */
2527 2628 private static $js_measured_layout = null;
2629 +
2630 + /**
2631 + * Per-request memo for page_has_critical_css(). Null = not resolved.
2632 + *
2633 + * @var bool|null
2634 + */
2635 + private static $has_critical_css = null;
2636 +
2637 + /**
2638 + * Does something inline critical CSS for the page being served?
2639 + *
2640 + * Free generates none, so the answer comes from the filter: an extension
2641 + * that inlines critical CSS for this page returns true, and a site whose
2642 + * theme ships its own can too. Resolved once per request, because every
2643 + * stylesheet tag asks.
2644 + */
2645 + public static function page_has_critical_css(): bool {
2646 + if ( null === self::$has_critical_css ) {
2647 + /**
2648 + * Whether the page being served has critical CSS inlined in its head.
2649 + *
2650 + * Async CSS defers stylesheets only when this is true; without
2651 + * critical CSS it leaves them render-blocking, because deferring
2652 + * them makes the page paint unstyled and shift. Return true when
2653 + * something inlines critical CSS for this page, or to keep deferring
2654 + * without it.
2655 + *
2656 + * @param bool $has_critical_css Default false.
2657 + */
2658 + self::$has_critical_css = (bool) apply_filters( 'xspeed_async_css_page_has_critical_css', false );
2659 + }
2660 + return self::$has_critical_css;
2661 + }
2528 2662
2529 2663 /**
2530 2664 * Scripts that lay out the page by measuring the DOM.
2531 2665 *