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