PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.4.2
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.4.2
1.4.2 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 All 36 releases
← All changes | includes/class-minify-filters.php +361 -2 1.3.7 → 1.4.2 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 ) {
@@ -1201,8 +1209,239 @@
1201 1209 return $out;
1202 1210 }
1203 1211
1204 1212 /**
1213 + * What a held async sheet runs once it has settled: mark itself and let
1214 + * the flip script check whether every held sheet has.
1215 + */
1216 + private const HOLD_SETTLED_JS = "this.setAttribute('data-xs-ready','1');window.xsCssFlip&&xsCssFlip()";
1217 +
1218 + /**
1219 + * A held sheet that fails (404, blocked, network error) never fires
1220 + * `load`, so with only an onload the flip waited for it and every other
1221 + * sheet stayed off until the 5-second fallback: an unstyled page. An
1222 + * error settles it as well. An onerror the tag already carries keeps
1223 + * running, after ours, so its throwing cannot hold the flip back.
1224 + *
1225 + * @param string $tag A held `<link>` tag, already carrying our onload.
1226 + */
1227 + private static function settle_held_sheet_on_error( string $tag ): string {
1228 + $merged = (string) preg_replace_callback(
1229 + '#\sonerror\s*=\s*(?:"([^"]*)"|\'([^\']*)\'|([^\s"\'>]+))#i',
1230 + static function ( array $e ): string {
1231 + $own = (string) ( $e[1] ?? '' ) . (string) ( $e[2] ?? '' ) . (string) ( $e[3] ?? '' );
1232 + $own = html_entity_decode( $own, ENT_QUOTES | ENT_HTML5, 'UTF-8' );
1233 + return ' onerror="' . self::HOLD_SETTLED_JS . ';' . esc_attr( $own ) . '"';
1234 + },
1235 + $tag,
1236 + 1,
1237 + $count
1238 + );
1239 + if ( 1 === $count ) {
1240 + return $merged;
1241 + }
1242 + return (string) preg_replace(
1243 + '#\sonload="' . preg_quote( self::HOLD_SETTLED_JS, '#' ) . '"#',
1244 + '$0 onerror="' . self::HOLD_SETTLED_JS . '"',
1245 + $tag,
1246 + 1
1247 + );
1248 + }
1249 +
1250 + /**
1251 + * Hosts whose stylesheets are the site's own for the transition hold:
1252 + * the site's host and, when the CDN rewrite is on, its CDN host. A sheet
1253 + * from any other host applies on its own load.
1254 + *
1255 + * @return string[] Lowercase hosts.
1256 + */
1257 + private static function first_party_hosts(): array {
1258 + $hosts = array( strtolower( (string) wp_parse_url( home_url( '/' ), PHP_URL_HOST ) ) );
1259 + $cdn = get_option( 'xspeed_module_cdn', array() );
1260 + if ( is_array( $cdn ) && ! empty( $cdn['enabled'] ) && ! empty( $cdn['cdn_url'] ) ) {
1261 + $hosts[] = (string) preg_replace( '#[/:].*$#', '', Cdn_Rewriter::normalize_host( (string) $cdn['cdn_url'] ) );
1262 + }
1263 + /**
1264 + * Filter: xspeed_async_css_first_party_hosts. Hosts whose async
1265 + * stylesheets wait for the transition hold's single flip. Add a
1266 + * static-asset host that serves the site's own CSS.
1267 + *
1268 + * @param string[] $hosts Lowercase hosts.
1269 + */
1270 + $hosts = (array) apply_filters( 'xspeed_async_css_first_party_hosts', $hosts );
1271 + return array_values( array_filter( array_map( static fn( $h ) => strtolower( trim( (string) $h ) ), $hosts ) ) );
1272 + }
1273 +
1274 + /**
1275 + * Hold CSS transitions while async stylesheets load.
1276 + *
1277 + * Critical CSS styles only the first screen. When the deferred
1278 + * stylesheets arrive, every element that has a `transition` and a value
1279 + * that changes animates at once: below the fold from the browser's
1280 + * defaults, above it wherever the critical CSS differs. On an Elementor
1281 + * page that was about 100 elements and seconds of main-thread work on a
1282 + * throttled phone. Transitions stay off until the last async sheet has
1283 + * applied, then come back two frames later.
1284 + *
1285 + * @param string $html Complete page HTML.
1286 + */
1287 + public static function hold_transitions_for_async_css( $html ): string {
1288 + if ( ! is_string( $html ) || '' === $html ) {
1289 + return (string) $html;
1290 + }
1291 + if ( self::skip_in_non_frontend_context() ) {
1292 + return $html;
1293 + }
1294 + /**
1295 + * Filter: xspeed_async_css_hold_transitions. Return false to let
1296 + * transitions run while async stylesheets load.
1297 + *
1298 + * @param bool $hold Whether to hold them. Default true.
1299 + */
1300 + if ( ! apply_filters( 'xspeed_async_css_hold_transitions', true ) ) {
1301 + return $html;
1302 + }
1303 + if ( false !== strpos( $html, 'id="xspeed-transition-hold-js"' ) ) {
1304 + return $html;
1305 + }
1306 + if ( ! preg_match( '#<link\b[^>]*\bdata-(?:xs|xspeed)-async\b#i', $html ) ) {
1307 + return $html;
1308 + }
1309 + // Async sheets used to switch themselves on as each finished, in
1310 + // download order. Between the first and the last the page ran on a
1311 + // partial cascade that matched neither the critical CSS nor the
1312 + // final layout, and the hero jumped (CLS 0.3 on a phone). Each sheet
1313 + // now only marks itself loaded; the script below switches them all
1314 + // on together, in document order, once every one has loaded.
1315 + // A cross-origin sheet (Google Fonts, a CDN font kit) keeps switching
1316 + // itself on: waiting for it let a slow third party hold back the
1317 + // site's own CSS. The flip script skips it too.
1318 + $own_hosts = self::first_party_hosts();
1319 + $rewritten = (string) preg_replace_callback(
1320 + '#<link\b[^>]*\bdata-(?:xs|xspeed)-async\b[^>]*>#i',
1321 + static function ( array $m ) use ( $own_hosts ): string {
1322 + if ( preg_match( '#\shref\s*=\s*["\']?(?:https?:)?//([^/"\'\s>:]+)#i', $m[0], $h ) && ! in_array( strtolower( $h[1] ), $own_hosts, true ) ) {
1323 + return $m[0];
1324 + }
1325 + $held = (string) preg_replace(
1326 + '#\sonload\s*=\s*"this\.media=\'[^\'"]*\'"#i',
1327 + ' data-xs-hold="1" onload="' . self::HOLD_SETTLED_JS . '"',
1328 + $m[0],
1329 + 1,
1330 + $count
1331 + );
1332 + if ( 1 !== $count ) {
1333 + return $m[0];
1334 + }
1335 + return self::settle_held_sheet_on_error( $held );
1336 + },
1337 + $html
1338 + );
1339 +
1340 + // The hold style is added by a script, so a visitor without
1341 + // JavaScript, whom the flip script never reaches, keeps transitions:
1342 + // a style in the markup stayed for good there. The no-JS page is the
1343 + // page without this pass, apart from the inert script tags.
1344 + $style = '<script id="xspeed-transition-hold-css">(function(){var s=document.createElement("style");s.id="xspeed-transition-hold";'
1345 + . 's.textContent="*,*::before,*::after{transition:none!important}";(document.head||document.documentElement).appendChild(s)})();</script>';
1346 + $script = '<script id="xspeed-transition-hold-js">(function(){var s=document.getElementById("xspeed-transition-hold"),d=0,dcl=0;'
1347 + . 'function links(){return document.querySelectorAll("link[data-xs-hold]")}'
1348 + . 'function flip(force){if(d)return;var l=links(),i;if(!force){if(!dcl)return;for(i=0;i<l.length;i++)if(l[i].media==="print"&&!l[i].hasAttribute("data-xs-ready"))return}'
1349 + . 'd=1;for(i=0;i<l.length;i++)if(l[i].media==="print")l[i].media=l[i].getAttribute("data-xs-async")||l[i].getAttribute("data-xspeed-async")||"all";'
1350 + . 'requestAnimationFrame(function(){requestAnimationFrame(function(){s&&s.parentNode&&s.parentNode.removeChild(s)})})}'
1351 + . 'window.xsCssFlip=function(){flip(0)};'
1352 + . 'document.addEventListener("DOMContentLoaded",function(){dcl=1;flip(0)});setTimeout(function(){flip(1)},5000)})();</script>';
1353 +
1354 + $out = preg_replace( '#<head\b[^>]*>#i', '$0' . $style, $rewritten, 1, $count );
1355 + if ( null === $out || 1 !== $count ) {
1356 + return $html;
1357 + }
1358 + $with_script = preg_replace( '#</body>#i', $script . '$0', $out, 1, $count );
1359 + return ( null === $with_script || 1 !== $count ) ? $html : $with_script;
1360 + }
1361 +
1362 + /**
1363 + * Action: `wp_head` (priority 1). Show Elementor's entrance-animation
1364 + * elements when Delay JS holds back the script that would show them.
1365 + *
1366 + * Elementor prints a widget with an entrance animation as
1367 + * `elementor-invisible` (visibility:hidden), and its frontend script
1368 + * removes the class as the widget scrolls into view. Delayed, that script
1369 + * runs on the first interaction, so a hero with an entrance animation
1370 + * stayed blank until then. Lighthouse never interacts: it measured an
1371 + * empty first screen, and its LCP and CLS described a page nobody sees.
1372 + *
1373 + * Printed from the head rather than the cached-page buffer, which never
1374 + * runs with the page cache off or on a bypassed page, where the script
1375 + * is delayed all the same. The decision is the one delay_script_tag()
1376 + * makes later in the request: Smart Delay delays the handle unless it is
1377 + * excluded; plain delay keeps it live (its config snippet reads it)
1378 + * unless the user named it. A rule left on a page with no such element
1379 + * matches nothing.
1380 + */
1381 + public static function print_entrance_animation_reveal(): void {
1382 + if ( self::skip_in_non_frontend_context() || ! self::delays_elementor_frontend() ) {
1383 + return;
1384 + }
1385 + /**
1386 + * Filter: xspeed_delay_js_reveal_entrance_animations. Return false to
1387 + * keep Elementor's entrance-animation elements hidden until the
1388 + * delayed frontend script runs.
1389 + *
1390 + * @param bool $reveal Whether to show them at load. Default true.
1391 + */
1392 + if ( ! apply_filters( 'xspeed_delay_js_reveal_entrance_animations', true ) ) {
1393 + return;
1394 + }
1395 + echo self::entrance_reveal_markup() . "\n"; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- fixed markup, no input.
1396 + }
1397 +
1398 + /**
1399 + * The reveal style, and the script that settles what the visitor has
1400 + * already seen.
1401 + *
1402 + * Shown by the style, an element still carries its entrance animation,
1403 + * and when the delayed frontend script runs Elementor plays it: the
1404 + * element drops to opacity 0 and fades back in. For the first screen that
1405 + * was the hero heading vanishing on the first mouse move. So when the
1406 + * replay starts (`xspeed:replaying`, before any delayed script is back),
1407 + * every animated element in the viewport has its animation set to
1408 + * `none` in `data-settings`, which Elementor reads as "show it, do not
1409 + * animate". Elements further down keep theirs and animate as they
1410 + * scroll in, as on a page without Delay JS.
1411 + */
1412 + public static function entrance_reveal_markup(): string {
1413 + return '<style id="xspeed-reveal-entrance">.elementor-invisible{visibility:visible!important}</style>'
1414 + . '<script id="xspeed-reveal-entrance-js" data-xs-nodelay>document.addEventListener("xspeed:replaying",function(){'
1415 + . 'var h=window.innerHeight,w=window.innerWidth,j=window.jQuery;'
1416 + . 'Array.prototype.forEach.call(document.querySelectorAll(".elementor-invisible[data-settings]"),function(e){'
1417 + . 'var r=e.getBoundingClientRect(),s,k;'
1418 + . 'if(!(r.width||r.height)||r.bottom<=0||r.top>=h||r.right<=0||r.left>=w)return;'
1419 + . 'try{s=JSON.parse(e.getAttribute("data-settings"))}catch(x){return}'
1420 + . 'if(!s||typeof s!=="object")return;'
1421 + . 'for(k in s)if(/^_?animation(_|$)/.test(k)&&!/(delay|duration)/.test(k))s[k]="none";'
1422 + . 'e.setAttribute("data-settings",JSON.stringify(s));e.classList.remove("elementor-invisible");'
1423 + . 'j&&j.removeData&&j.removeData(e,"settings")})},{once:true})</script>';
1424 + }
1425 +
1426 + /** Will Delay JS hold back Elementor's frontend script on this request? */
1427 + private static function delays_elementor_frontend(): bool {
1428 + $opts = self::opts();
1429 + if ( empty( $opts['delay_js'] ) ) {
1430 + return false;
1431 + }
1432 + $handle = 'elementor-frontend';
1433 + $src = self::registered_src( $handle );
1434 + if ( '' === $src ) {
1435 + return false; // Elementor is not loaded.
1436 + }
1437 + if ( self::smart_delay_enabled() ) {
1438 + return self::smart_delays_handle( $handle );
1439 + }
1440 + return ! self::is_excluded_script( $handle, $src, true ) && self::is_user_named_target( $handle, $src );
1441 + }
1442 +
1443 + /**
1205 1444 * Inline bootstrap that flips delayed scripts on the first user
1206 1445 * interaction. Printed once on wp_footer priority 1000.
1207 1446 */
1208 1447 public static function print_delay_bootstrap(): void {
@@ -2128,9 +2367,9 @@
2128 2367 // has a stored list that knows nothing about consent managers, and
2129 2368 // one that cleared the textarea has no list at all. Neither may
2130 2369 // hide the banner. (#275)
2131 2370 foreach ( self::exclusion_floor() as $needle ) {
2132 - if ( self::target_matches( $needle, $handle, $src ) ) {
2371 + if ( self::floor_matches( $needle, $handle, $src ) ) {
2133 2372 // `continue`, not `break`: a second floor token matching the
2134 2373 // same tag has to be named too, or a filter-added token
2135 2374 // would be lifted by an entry that names only the first.
2136 2375 if ( $named_lifts_floor && self::user_named_consent_manager( $needle, $handle, $src ) ) {
@@ -2155,8 +2394,56 @@
2155 2394 return false;
2156 2395 }
2157 2396
2158 2397 /**
2398 + * target_matches() for a floor token, with the site's own host removed
2399 + * from the URL first.
2400 + *
2401 + * Floor tokens are plugin names, and a plugin's own website is often
2402 + * named after the plugin. On notificationx.com the `notificationx` token
2403 + * matched every same-origin script URL, so Defer JS and Delay JS skipped
2404 + * every script on the site. The path still matches, so a script under
2405 + * /plugins/notificationx/ keeps its protection, and a third-party host
2406 + * such as consent.cookiebot.com still matches in full.
2407 + *
2408 + * @param string $needle Floor token.
2409 + * @param string $handle Script handle.
2410 + * @param string $src Script URL.
2411 + */
2412 + private static function floor_matches( string $needle, string $handle, string $src ): bool {
2413 + if ( '' === $needle ) {
2414 + return false;
2415 + }
2416 + if ( $handle === $needle ) {
2417 + return true;
2418 + }
2419 + foreach ( array( $src, self::original_src( $handle ) ) as $url ) {
2420 + $url = self::without_own_host( $url );
2421 + if ( '' !== $url && false !== stripos( $url, $needle ) ) {
2422 + return true;
2423 + }
2424 + }
2425 + return false;
2426 + }
2427 +
2428 + /**
2429 + * The URL without its scheme and host when the host is the site's own.
2430 + * Any other URL comes back unchanged.
2431 + *
2432 + * @param string $url Script URL.
2433 + */
2434 + private static function without_own_host( string $url ): string {
2435 + if ( '' === $url || ! function_exists( 'home_url' ) ) {
2436 + return $url;
2437 + }
2438 + $host = (string) wp_parse_url( home_url(), PHP_URL_HOST );
2439 + if ( '' === $host ) {
2440 + return $url;
2441 + }
2442 + return (string) preg_replace( '#^(?:https?:)?//' . preg_quote( $host, '#' ) . '(?::\d+)?(?=[/?\#]|$)#i', '', $url );
2443 + }
2444 +
2445 + /**
2159 2446 * Include-list targeting for delay (issue #36): when delay_js_targets
2160 2447 * is non-empty, ONLY matching scripts are delayed — a heavy
2161 2448 * third-party embed can be postponed without delaying the whole
2162 2449 * page's JS. Empty targets = historical behavior (delay everything
@@ -2277,13 +2564,85 @@
2277 2564 private static function smart_delays_handle( string $handle ): bool {
2278 2565 if ( '' === $handle ) {
2279 2566 return false;
2280 2567 }
2568 + // Check the URL delay_script_tag() checks. original_src() is set
2569 + // only when Minify JS rewrote the URL, so with Minify JS off it was
2570 + // '' here. A URL exclusion then passed here and failed there, and
2571 + // the snippets were parked while the script stayed live.
2281 2572 $src = self::original_src( $handle );
2573 + if ( '' === $src ) {
2574 + $src = self::registered_src( $handle );
2575 + }
2282 2576 if ( self::is_excluded_script( $handle, $src, true ) ) {
2283 2577 return false;
2284 2578 }
2285 2579 return self::is_delay_target( $handle, $src );
2580 + }
2581 +
2582 + /**
2583 + * A handle's registered URL, made absolute the way WP_Scripts prints it,
2584 + * without the version query. '' when the handle has no file.
2585 + *
2586 + * @param string $handle Script handle.
2587 + */
2588 + private static function registered_src( string $handle ): string {
2589 + if ( ! function_exists( 'wp_scripts' ) ) {
2590 + return '';
2591 + }
2592 + $scripts = wp_scripts();
2593 + if ( ! $scripts instanceof \WP_Scripts ) {
2594 + return '';
2595 + }
2596 + $reg = $scripts->registered[ $handle ] ?? null;
2597 + $src = ( is_object( $reg ) && is_string( $reg->src ) ) ? $reg->src : '';
2598 + if ( '' !== $src && ! preg_match( '#^(?:https?:)?//#i', $src ) ) {
2599 + $src = (string) ( $scripts->base_url ?? '' ) . $src;
2600 + }
2601 + return $src;
2602 + }
2603 +
2604 + /**
2605 + * Filter: `script_loader_tag`, after every other xSpeed pass. Un-park a
2606 + * handle's before/after snippets when its external tag was not delayed.
2607 + *
2608 + * park_smart_inline() decides before the tag exists, so a later rule
2609 + * that keeps the tag live (an opt-out attribute, a non-executable type,
2610 + * the late opt-out revert, another plugin's filter) left the snippets
2611 + * parked and the script live. The script then ran without the config
2612 + * its `before` snippet sets, which is how Elementor's frontend lost
2613 + * elementorFrontendConfig. This filter makes that state impossible.
2614 + *
2615 + * @param string $tag
2616 + * @param string $handle
2617 + * @param string $src
2618 + */
2619 + public static function unpark_orphaned_smart_inline( $tag, $handle, $src ): string {
2620 + if ( ! is_string( $tag ) || '' === $tag || '' === (string) $handle || false === stripos( $tag, 'text/xspeed-delayed' ) ) {
2621 + return (string) $tag;
2622 + }
2623 + // Only the external tag in this string can carry data-xs-src. Its
2624 + // `src` is gone once delayed, so open_tag_offsets() cannot find it.
2625 + if ( preg_match( '#<script\b[^>]*(?<![-\w])data-xs-src\s*=#i', $tag ) ) {
2626 + return $tag;
2627 + }
2628 + return (string) preg_replace_callback(
2629 + '#<script\b([^>]*\sid\s*=\s*(["\'])' . preg_quote( (string) $handle, '#' ) . '-js-(?:before|after)\2[^>]*)>#i',
2630 + static function ( array $m ): string {
2631 + $attrs = $m[1];
2632 + if ( 'text/xspeed-delayed' !== self::extract_type( $attrs ) ) {
2633 + return $m[0];
2634 + }
2635 + $orig = preg_match( '#\sdata-xs-type\s*=\s*(["\'])([^"\']*)\1#i', $attrs, $t ) ? $t[2] : '';
2636 + $attrs = (string) preg_replace( self::TYPE_ATTR_RE, '', $attrs );
2637 + $attrs = (string) preg_replace( '#\sdata-xs-(?:delay|type)\s*=\s*(["\'])[^"\']*\1#i', '', $attrs );
2638 + if ( '' !== $orig ) {
2639 + $attrs .= ' type="' . esc_attr( $orig ) . '"';
2640 + }
2641 + return '<script' . $attrs . '>';
2642 + },
2643 + $tag
2644 + );
2286 2645 }
2287 2646
2288 2647 /**
2289 2648 * Park a delayed handle's own before/after snippet, in Smart Delay mode.