queue at priority 999 and rewrote handles: point one handle at the
* merged file, blank the rest. That cannot be made correct, because WordPress
* keeps editing the queue after we are done.
*
* The reported break (#195, WooCommerce + Kadence) was not a flaw in our
* bucketing or carrier choice. Traced on a live install:
*
* prio 998 kadence-global src='.../global.min.css'
* prio 999 kadence-global src=false <- us, blanking a non-carrier
*
* ...and then core's `wp_maybe_inline_styles()` runs. It inlines any queued
* handle carrying a `path` data key and sets `src = false` on it
* (wp-includes/script-loader.php:3188). Our carrier was
* `classic-theme-styles`, which core registers WITH a path — so core read that
* handle's ORIGINAL file, inlined it, and discarded the combined URL we had
* just written there. The merged never printed and the five sheets we
* had blanked were gone. Six stylesheets became one, and the site rendered
* unstyled.
*
* No carrier-selection rule survives that: core rewrites the handle after us.
* So combining moves to the finished HTML, where what we read is what shipped.
* This is the layer LiteSpeed combines at, for the same reason.
*
* What that buys, beyond fixing the break:
*
* - Document order is visible, so the cascade can be preserved exactly.
* - Sheets printed by plugins outside the queue are seen (they were
* invisible to a queue walker, and got duplicated).
* - `data-no-optimize` / `data-optimized` opt-outs work, matching what
* LiteSpeed and Autoptimize already honor.
* - The swap path is a pure string transform, so it is unit-testable —
* the enqueue version needed a full WP bootstrap and never had a test.
*
* The cascade rule: only CONTIGUOUS runs of same-media local sheets merge. A
* sheet we cannot combine (external, opted out, excluded) ends the run, and
* everything after it starts a new one. Nothing is ever hoisted past anything
* else, which is the property the old combiner could not offer.
*
* @package XSpeed
*/
declare(strict_types=1);
namespace XSpeed;
defined( 'ABSPATH' ) || exit;
final class Css_Combine_Buffer {
/** Minimum sheets in a run before merging is worth a request. */
private const MIN_RUN = 2;
/** Our own output-buffer nesting level, when we had to open one. */
private static ?int $buffer_level = null;
/** Set once we have transformed a page, so we never do it twice. */
private static bool $done = false;
/**
* Make sure SOMETHING will hand us the finished HTML.
*
* `xspeed_cache_final_html` is the preferred route — the page cache
* already buffers, so we transform once and the result is baked into the
* cache file. But that filter fires only on a cacheable MISS. With the
* page cache off, or on an excluded URL (`/cart`, `/checkout` — precisely
* where a WooCommerce layout break hurts most), it never fires at all and
* combining would silently stop working.
*
* So: open our own buffer when the cache is not going to give us one, and
* no-op when it is. `$done` guarantees a page is transformed once whichever
* path gets there first.
*/
public static function boot(): void {
add_action(
'template_redirect',
static function (): void {
if ( is_admin() || wp_doing_ajax() || wp_doing_cron()
|| ( defined( 'REST_REQUEST' ) && REST_REQUEST )
|| ( defined( 'WP_CLI' ) && WP_CLI )
|| ( defined( 'XMLRPC_REQUEST' ) && XMLRPC_REQUEST )
// Combining a builder editor's CSS reorders the cascade the
// editor's own UI depends on. (#281)
|| Builder_Editor::is_active() ) {
return;
}
// The page cache is buffering and will call us through its
// filter; a second buffer would just copy the page again.
if ( class_exists( '\\XSpeed\\Cache' ) && Cache::is_buffering() ) {
return;
}
ob_start( array( __CLASS__, 'filter_buffer' ) );
self::$buffer_level = ob_get_level();
add_action( 'shutdown', array( __CLASS__, 'close_buffer' ), 0 );
},
1
);
}
/** ob_start() callback — transform once, pass everything else through. */
public static function filter_buffer( string $buffer ): string {
return self::process( $buffer );
}
/**
* Clear the once-per-request guard.
*
* Only tests need this: a request is a fresh process, but a test run
* exercises many documents through one loaded class.
*/
public static function reset(): void {
self::$done = false;
}
/** Flush only the buffer we opened. */
public static function close_buffer(): void {
if ( null !== self::$buffer_level && ob_get_level() >= self::$buffer_level ) {
ob_end_flush();
self::$buffer_level = null;
}
}
/**
* Combine stylesheet links in a finished HTML document.
*
* Returns the input unchanged when there is nothing to gain, so a caller
* can hand us any page unconditionally.
*
* @param string $html Complete page HTML.
*/
public static function process( string $html ): string {
if ( '' === $html || false === stripos( $html, ' is in scope. A in the body is either a late
// plugin injection or markup we do not own, and moving it changes
// paint order for something that already chose to be there.
$head_end = stripos( $html, '' );
if ( false === $head_end ) {
return $html;
}
$head = substr( $html, 0, $head_end );
$runs = self::runs( $head );
if ( empty( $runs ) ) {
return $html;
}
$new_head = $head;
foreach ( $runs as $run ) {
$merged = self::merge_run( $run );
if ( null === $merged ) {
continue;
}
// Replace the FIRST tag of the run with the combined link and drop
// the rest. Reusing the first slot is what keeps the merged CSS
// exactly where the earliest sheet was, preserving the cascade.
$first = true;
foreach ( $run['tags'] as $tag ) {
$new_head = self::replace_once( $new_head, $tag, $first ? $merged : '' );
$first = false;
// Async CSS parks a