| @@ -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 | * |