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 |#is', static function ( $m ) { //