PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.3.3
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.3.3
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 1.1.5 1.1.6 1.1.7 1.1.8 All 29 releases
← All changes | includes/class-css-combine-buffer.php +257 -9 1.1.61.3.3 View file →
@@ -81,9 +81,12 @@
81 81 static function (): void {
82 82 if ( is_admin() || wp_doing_ajax() || wp_doing_cron()
83 83 || ( defined( 'REST_REQUEST' ) && REST_REQUEST )
84 84 || ( defined( 'WP_CLI' ) && WP_CLI )
85 - || ( defined( 'XMLRPC_REQUEST' ) && XMLRPC_REQUEST ) ) {
85 + || ( defined( 'XMLRPC_REQUEST' ) && XMLRPC_REQUEST )
86 + // Combining a builder editor's CSS reorders the cascade the
87 + // editor's own UI depends on. (#281)
88 + || Builder_Editor::is_active() ) {
86 89 return;
87 90 }
88 91 // The page cache is buffering and will call us through its
89 92 // filter; a second buffer would just copy the page again.
@@ -167,8 +170,15 @@
167 170 $first = true;
168 171 foreach ( $run['tags'] as $tag ) {
169 172 $new_head = self::replace_once( $new_head, $tag, $first ? $merged : '' );
170 173 $first = false;
174 + // Async CSS parks a <noscript> fallback immediately after each
175 + // sheet it defers. The sheet it points at is now inside the
176 + // combined file, so leaving the fallback behind would reload
177 + // every original for no-JS visitors — the combine undone for
178 + // exactly the audience least able to afford it. Drop it with
179 + // its sheet; merge_run() rebuilds one for the combined <link>.
180 + $new_head = self::drop_noscript_for( $new_head, $tag );
171 181 }
172 182 }
173 183
174 184 if ( $new_head === $head ) {
@@ -180,9 +190,9 @@
180 190
181 191 /**
182 192 * Split the head into contiguous runs of combinable same-media sheets.
183 193 *
184 - * @return array<int,array{media:string,tags:string[],urls:string[]}>
194 + * @return array<int,array{media:string,async:bool,tags:string[],urls:string[]}>
185 195 */
186 196 private static function runs( string $head ): array {
187 197 // Blank out conditional comments and inline <style> so neither is
188 198 // parsed into, and so an inline block BREAKS a run: it may carry
@@ -207,8 +217,9 @@
207 217 }
208 218
209 219 $url = self::attr( $tag, 'href' );
210 220 $media = self::media_of( $tag );
221 + $async = self::is_async_style( $tag );
211 222 $local = '' !== $url ? self::local_path( $url ) : null;
212 223
213 224 $combinable = null !== $local
214 225 && ! self::opted_out( $tag )
@@ -228,9 +239,15 @@
228 239 $open = -1; // an uncombinable sheet ends the run it sits in.
229 240 continue;
230 241 }
231 242
232 - $extend = $open >= 0 && ! $gap_breaks && $runs[ $open ]['media'] === $media;
243 + // Async'd and render-blocking sheets never share a run: merging
244 + // them would either make a blocking sheet non-blocking or drag an
245 + // async'd one back onto the critical path. Grouping on the flag
246 + // keeps each combined file honest about how it loads. (#330)
247 + $extend = $open >= 0 && ! $gap_breaks
248 + && $runs[ $open ]['media'] === $media
249 + && $runs[ $open ]['async'] === $async;
233 250 if ( $extend ) {
234 251 $runs[ $open ]['tags'][] = $tag;
235 252 $runs[ $open ]['urls'][] = $local;
236 253 continue;
@@ -237,8 +254,9 @@
237 254 }
238 255
239 256 $runs[] = array(
240 257 'media' => $media,
258 + 'async' => $async,
241 259 'tags' => array( $tag ),
242 260 'urls' => array( $local ),
243 261 );
244 262 $open = count( $runs ) - 1;
@@ -255,9 +273,9 @@
255 273 /**
256 274 * Build the combined file for one run and return its <link>, or null when
257 275 * nothing could be read.
258 276 *
259 - * @param array{media:string,tags:string[],urls:string[]} $run Run to merge.
277 + * @param array{media:string,async:bool,tags:string[],urls:string[]} $run Run to merge.
260 278 */
261 279 private static function merge_run( array $run ): ?string {
262 280 $key = md5( implode( '|', array_map( static fn( $p ) => $p . ':' . (int) @filemtime( $p ), $run['urls'] ) ) ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- a missing file contributes 0 to the key; handled below.
263 281 $dir = Asset_Combiner::cache_dir();
@@ -275,8 +293,20 @@
275 293 $src = self::path_to_url( $path );
276 294 $body = self::strip_file_prelude( $body );
277 295 $body = Asset_Combiner::resolve_imports( $body, $src, 0 );
278 296 $body = Asset_Combiner::rewrite_url_paths( $body, $src );
297 + // Close any comment this file left open BEFORE it can reach the
298 + // join. The `/* xspeed */` marker used to absorb this by
299 + // accident — an unterminated `/*` swallowed the marker instead
300 + // of the next stylesheet — but that made a debugging comment
301 + // load-bearing, and it stopped working the moment the join was
302 + // minified (issue #331). Neutralising it at the source is what
303 + // actually holds.
304 + $body = self::close_open_comment( $body );
305 + // The marker stays: it is the separator that keeps a file
306 + // ending mid-declaration from fusing its last selector onto the
307 + // next file's first one. The minifier strips it from the
308 + // artifact, so it costs nothing in the shipped bytes.
279 309 $css .= "/* xspeed */\n" . $body . "\n";
280 310 }
281 311 if ( '' === trim( $css ) ) {
282 312 return null;
@@ -281,8 +311,18 @@
281 311 if ( '' === trim( $css ) ) {
282 312 return null;
283 313 }
284 314 $css = self::hoist_imports( $css );
315 +
316 + // Minify AFTER hoisting: @import rules are only legal at the top
317 + // of a stylesheet, so hoist_imports() has to see the un-minified
318 + // text first. Minifying the join is what issue #331 was about —
319 + // the inputs arrive minified but the concatenation did not, and
320 + // Minifier::rewrite_style() deliberately skips anything under
321 + // /cache/xspeed/, so this file was the end of the line. One
322 + // failing file drops Lighthouse's near-binary `unminified-css`
323 + // audit to 0.5, and the only offender on the page was ours.
324 + $css = Asset_Combiner::minify_css_body( $css );
285 325 if ( ! is_dir( $dir ) ) {
286 326 wp_mkdir_p( $dir );
287 327 }
288 328 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_put_contents_file_put_contents -- WP_Filesystem requires admin context, unavailable on the frontend.
@@ -288,8 +328,24 @@
288 328 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_put_contents_file_put_contents -- WP_Filesystem requires admin context, unavailable on the frontend.
289 329 file_put_contents( $file, $css, LOCK_EX );
290 330 }
291 331
332 + // Carry Async CSS across the merge (issue #330). Every sheet in the
333 + // run was async'd — that is a condition of grouping them, enforced in
334 + // runs() — so the combined file has to be non-render-blocking too, or
335 + // combining would quietly cancel the other feature instead of the
336 + // other way round. Same media="print" + onload swap async_style_tag
337 + // emits, applied once to the one <link> that replaces them all.
338 + if ( ! empty( $run['async'] ) ) {
339 + $restore = 'all' === $run['media'] ? 'all' : $run['media'];
340 + // The <noscript> fallback is rebuilt for the combined file, so a
341 + // visitor without JS still gets the CSS — one request now instead
342 + // of one per original sheet.
343 + $async_markup = '<link rel="stylesheet" href="%1$s" data-optimized="1" media="print" onload="this.media=\'%2$s\'" data-xs-async="%2$s" />'
344 + . '<noscript><link rel="stylesheet" href="%1$s" media="%2$s" /></noscript>'; // phpcs:ignore WordPress.WP.EnqueuedResources.NonEnqueuedStylesheet -- see the note on the non-async return below; this replaces finished-HTML <link>s.
345 + return sprintf( $async_markup, esc_url( $url ), esc_attr( $restore ) );
346 + }
347 +
292 348 $media = 'all' === $run['media'] ? '' : sprintf( ' media="%s"', esc_attr( $run['media'] ) );
293 349
294 350 // data-optimized marks it ours, so a second pass — or another
295 351 // optimizer honoring the same convention — leaves it alone.
@@ -310,10 +366,29 @@
310 366 * same length, so offsets still line up with the original string.
311 367 */
312 368 private static function mask( string $head ): string {
313 369 return (string) preg_replace_callback(
314 - '#<!--\[if.*?\[endif\]-->|<style\b[^>]*>.*?</style>|<!--.*?-->#is',
315 - static fn( $m ) => str_repeat( "\0", strlen( $m[0] ) ),
370 + // <noscript> is masked for the same reason as the rest: the <link>
371 + // inside it is a FALLBACK, not a sheet the document loads. Async
372 + // CSS emits one after every sheet it defers, so leaving them
373 + // visible both doubled the link count and broke every run into
374 + // single sheets — which is why combining silently stopped the
375 + // moment async_css was switched on (#330). The combined <link>
376 + // gets its own fallback rebuilt in merge_run().
377 + '#<!--\[if.*?\[endif\]-->|<style\b[^>]*>.*?</style>|<noscript\b[^>]*>.*?</noscript>|<!--.*?-->#is',
378 + static function ( $m ) {
379 + // <noscript> is padded with SPACES, not NULs. Both are hidden
380 + // from the link scanner, but the gap test below treats a NUL as
381 + // "something load-bearing sits between these two sheets" and
382 + // ends the run. An Async CSS fallback is not an override — it
383 + // is a copy of the sheet we just read — so it must not break
384 + // contiguity, or every async'd sheet ends up alone in its own
385 + // run and nothing ever merges (#330).
386 + if ( 0 === stripos( $m[0], '<noscript' ) ) {
387 + return str_repeat( ' ', strlen( $m[0] ) );
388 + }
389 + return str_repeat( "\0", strlen( $m[0] ) );
390 + },
316 391 $head
317 392 );
318 393 }
319 394
@@ -327,14 +402,91 @@
327 402 }
328 403 return '';
329 404 }
330 405
331 - /** '' and 'screen' both mean the on-screen document. */
406 + /**
407 + * The media this sheet really applies to.
408 + *
409 + * '' and 'screen' both mean the on-screen document.
410 + *
411 + * An async'd sheet is the special case (issue #330). Async CSS rewrites
412 + * `media="all"` to `media="print"` and restores the original from an
413 + * onload handler, parking it in `data-xs-async`. Reading the literal
414 + * `media` attribute therefore filed every async'd sheet into a `print`
415 + * bucket of its own, no run ever reached MIN_RUN, and combining silently
416 + * stopped: the reported page went from 5 stylesheets to 22 while
417 + * `get_settings` still reported `combine_css: true` — two features that
418 + * the UI presents as independent, one quietly cancelling the other.
419 + *
420 + * `data-xs-async` holds the media the sheet will have a moment after
421 + * load, which is the one that decides whether two sheets belong together.
422 + *
423 + * Two spellings, one meaning. Free's Async CSS parks the media in
424 + * `data-xs-async`; Pro's Critical CSS defers the remaining sheets itself
425 + * and parks it in `data-xspeed-async`. Reading only Free's spelling filed
426 + * every Pro-deferred sheet as genuine `media="print"`, merged them into a
427 + * print-only bundle and dropped the swap — a bare, unstyled page from two
428 + * switches (#335 review, issue 1). Neither side owns the attribute name,
429 + * so both are read here.
430 + */
332 431 private static function media_of( string $tag ): string {
432 + $async = strtolower( self::async_media( $tag ) );
433 + if ( '' !== $async ) {
434 + return ( 'screen' === $async ) ? 'all' : $async;
435 + }
333 436 $media = strtolower( self::attr( $tag, 'media' ) );
334 437 return ( '' === $media || 'screen' === $media ) ? 'all' : $media;
335 438 }
336 439
440 + /**
441 + * The media parked on an async'd sheet, whichever attribute holds it.
442 + *
443 + * @return string '' when the sheet is not async'd.
444 + */
445 + private static function async_media( string $tag ): string {
446 + foreach ( self::async_attrs() as $attr ) {
447 + $value = self::attr( $tag, $attr );
448 + if ( '' !== $value ) {
449 + return $value;
450 + }
451 + }
452 +
453 + // No attribute of ours, but the swap handler is the technique itself
454 + // and says the same thing: this sheet is parked under `print` and
455 + // becomes something else on load. Any plugin using the standard
456 + // print/swap idiom is read correctly rather than merged into a
457 + // print-only bundle and stripped of its handler (#335 review, issue 3).
458 + if ( preg_match( '#\bonload\s*=\s*(["\'])\s*this\.media\s*=\s*(["\'])([^"\']*)\2#i', $tag, $m ) ) {
459 + return $m[3];
460 + }
461 +
462 + return '';
463 + }
464 +
465 + /**
466 + * Attributes that park a sheet's real media while it loads.
467 + *
468 + * `data-xs-async` is Free's; `data-xspeed-async` is Pro's Critical CSS.
469 + * Filterable so a third deferring layer can declare itself rather than
470 + * being merged into a print-only bundle.
471 + *
472 + * @return string[]
473 + */
474 + private static function async_attrs(): array {
475 + $attrs = apply_filters( 'xspeed_async_css_attributes', array( 'data-xs-async', 'data-xspeed-async' ) );
476 + return array_filter( array_map( 'strval', (array) $attrs ) );
477 + }
478 +
479 + /** True when an async layer — Free's or Pro's — has already transformed this link. */
480 + private static function is_async_style( string $tag ): bool {
481 + foreach ( self::async_attrs() as $attr ) {
482 + if ( preg_match( '#\b' . preg_quote( $attr, '#' ) . '\s*=#i', $tag ) ) {
483 + return true;
484 + }
485 + }
486 + return '' !== self::async_media( $tag );
487 + }
488 +
337 489 private static function opted_out( string $tag ): bool {
338 490 return (bool) preg_match( '#\bdata-(no-optimize|optimized)\b#i', $tag );
339 491 }
340 492
@@ -410,11 +562,33 @@
410 562 }
411 563
412 564 $imports = array();
413 565 $body = (string) preg_replace_callback(
414 - '#@import\s+[^;]+;#i',
566 + // A semicolon inside the rule does NOT end it. `[^;]+` stopped at
567 + // the first one, and a Google Fonts v2 URL puts semicolons in the
568 + // query string — `?family=Open+Sans:wght@400;500;600;700` is the
569 + // markup Google's own embed code hands you. The rule was cut in
570 + // half: a truncated @import got hoisted and the remainder was left
571 + // as loose garbage, so the browser dropped the import and the
572 + // webfont never loaded. (#277)
573 + //
574 + // So consume the parts an @import is actually made of — quoted
575 + // strings, url(...) including its own contents, and the media
576 + // query — and only then take the terminating `;`. An unterminated
577 + // @import at EOF is matched too, since browsers accept it.
578 + // The alternation covers, in order: a quoted string, a url(...)
579 + // with its contents, ANY other parenthesised group (a media
580 + // query's `(min-width:600px)`), and finally any character that is
581 + // none of those and not the terminator.
582 + '#@import\s+(?:"[^"]*"|\'[^\']*\'|url\(\s*(?:"[^"]*"|\'[^\']*\'|[^)]*)\s*\)|\([^)]*\)|[^;\'"()])+\s*;?#i',
415 583 static function ( $m ) use ( &$imports ) {
416 - $imports[] = trim( (string) $m[0] );
584 + $rule = trim( (string) $m[0] );
585 + // Normalise a missing terminator so the hoisted block is valid
586 + // even when the source relied on EOF to end the rule.
587 + if ( '' !== $rule && ';' !== substr( $rule, -1 ) ) {
588 + $rule .= ';';
589 + }
590 + $imports[] = $rule;
417 591 return '';
418 592 },
419 593 $css
420 594 );
@@ -445,8 +619,61 @@
445 619 * The combined file needs no `@charset` of its own: it is served with a
446 620 * `Content-Type: text/css` charset from the webserver, which outranks an
447 621 * in-file rule.
448 622 */
623 + /**
624 + * Close a comment the stylesheet left open.
625 + *
626 + * A `/*` with no closing `*​/` comments out everything after it. In a
627 + * combined file that is every subsequent stylesheet — one malformed vendor
628 + * file silently blanks the rest of the page's CSS.
629 + *
630 + * String literals are skipped, so `content: "/*"` is not mistaken for an
631 + * opener. Pure — unit-tested.
632 + */
633 + public static function close_open_comment( string $css ): string {
634 + $len = strlen( $css );
635 + $in_string = '';
636 + $i = 0;
637 +
638 + while ( $i < $len ) {
639 + $ch = $css[ $i ];
640 +
641 + if ( '' !== $in_string ) {
642 + if ( '\\' === $ch ) {
643 + $i += 2;
644 + continue;
645 + }
646 + if ( $ch === $in_string ) {
647 + $in_string = '';
648 + }
649 + ++$i;
650 + continue;
651 + }
652 +
653 + if ( '"' === $ch || "'" === $ch ) {
654 + $in_string = $ch;
655 + ++$i;
656 + continue;
657 + }
658 +
659 + if ( '/' === $ch && $i + 1 < $len && '*' === $css[ $i + 1 ] ) {
660 + $close = strpos( $css, '*/', $i + 2 );
661 + if ( false === $close ) {
662 + // Unterminated: close it at the end of this file so the
663 + // next one in the bundle is still parsed.
664 + return $css . '*/';
665 + }
666 + $i = $close + 2;
667 + continue;
668 + }
669 +
670 + ++$i;
671 + }
672 +
673 + return $css;
674 + }
675 +
449 676 private static function strip_file_prelude( string $css ): string {
450 677 // BOM first — an @charset can sit behind one.
451 678 if ( 0 === strncmp( $css, "\xEF\xBB\xBF", 3 ) ) {
452 679 $css = substr( $css, 3 );
@@ -456,8 +683,29 @@
456 683 return (string) preg_replace( '/^\s*@charset\s+["\'][^"\']*["\']\s*;/i', '', $css );
457 684 }
458 685
459 686 /** str_replace, but only the first occurrence. */
687 + /**
688 + * Remove the `<noscript>` fallback that Async CSS emitted for one sheet.
689 + *
690 + * Matched by the sheet's own href so only its fallback goes — a page can
691 + * carry many, and an unrelated one must survive. Whitespace between the
692 + * link and its noscript is tolerated; anything else means this is not the
693 + * pair we think it is, and nothing is removed.
694 + */
695 + private static function drop_noscript_for( string $head, string $tag ): string {
696 + $href = self::attr( $tag, 'href' );
697 + if ( '' === $href ) {
698 + return $head;
699 + }
700 + return (string) preg_replace(
701 + '#<noscript\b[^>]*>\s*<link\b[^>]*' . preg_quote( $href, '#' ) . '[^>]*>\s*</noscript>#i',
702 + '',
703 + $head,
704 + 1
705 + );
706 + }
707 +
460 708 private static function replace_once( string $haystack, string $needle, string $replace ): string {
461 709 $pos = strpos( $haystack, $needle );
462 710 if ( false === $pos ) {
463 711 return $haystack;