queue, partition into
* local + external. External (full http(s):// to other origins,
* data: URIs, protocol-relative pointing elsewhere) stay enqueued
* as-is; local handles get pulled out of the queue.
* 2. Build cache key = md5(JSON({handle => [src, mtime]})). When the
* combined file already exists for that key, skip generation.
* 3. Otherwise: read each source body, resolve recursive @import
* statements (depth-limited), rewrite url(...) paths to absolute,
* concat with a small `/* xspeed: HANDLE */` header per chunk for
* debug-traceability, write to XSPEED_CACHE_DIR/min/combined/.
* 4. Register the combined file as a single new handle
* `xspeed-combined-css` and re-add it to the queue. The original
* handles stay registered (so other plugins that look them up
* still find their metadata) but are pulled from the queue —
* they won't print tags.
*
* JS path is the same, minus @import (no JS analogue) and url()
* rewriting (JS strings are too varied to safely rewrite). External
* + async + deferred scripts (deferred via WP_Scripts->add_data
* 'strategy' OR the script_loader_tag filter from Minify_Filters)
* stay un-combined.
*
* Cache lives in {$min_dir}/combined/ — separate from the per-file
* minify cache so purge can target them independently if needed.
*
* @package XSpeed
*/
declare(strict_types=1);
namespace XSpeed;
defined( 'ABSPATH' ) || exit;
final class Asset_Combiner {
public const MAX_IMPORT_DEPTH = 3;
/**
* Path to the combine cache dir. Created on first write.
*/
public static function cache_dir(): string {
return trailingslashit( XSPEED_CACHE_DIR ) . 'min/combined';
}
/**
* URL prefix matching cache_dir(). Built from content_url, not by
* string-replacing filesystem paths (see class-minifier.php for the
* same rationale).
*
* The scheme is forced to match the page's — `content_url()` derives
* its scheme from `is_ssl()`, which returns false behind a TLS-
* terminating reverse proxy / load balancer (common on managed hosts),
* so it can hand back an `http://` URL on an `https` page. The browser
* then blocks the combined stylesheet as mixed content and the whole
* page renders unstyled. Re-scheme the URL to the site's actual scheme
* so the always matches the page. (FBS-83633)
*/
public static function cache_url(): string {
$url = trailingslashit( content_url( 'cache/xspeed' ) ) . 'min/combined';
// Match the site's registered scheme (home_url), NOT is_ssl() —
// which set_url_scheme() would consult with no explicit scheme, and
// which is the very signal that misreports behind a proxy.
$scheme = wp_parse_url( home_url(), PHP_URL_SCHEME ) ?: 'https';
return set_url_scheme( $url, $scheme );
}
/**
* Combine local enqueued styles into one file.
*
* @deprecated Superseded by Css_Combine_Buffer, which combines the
* finished HTML instead of the enqueue queue. This path is no longer
* hooked: whatever it wrote at priority 999, WordPress edited afterwards —
* core's wp_maybe_inline_styles() inlines any queued handle with a `path`
* and blanks its src, which discarded the combined URL and took the sheets
* this method had already blanked with it. See Css_Combine_Buffer's header
* for the live trace. (#195)
*
* Kept callable because tests/e2e/48- and 49- drive it directly to pin the
* FBS-83114/83116/83633/83653 regressions. Remove once those specs are
* ported onto the buffer engine.
*/
public static function combine_styles(): void {
global $wp_styles;
if ( ! $wp_styles instanceof \WP_Styles || empty( $wp_styles->queue ) ) {
return;
}
// Group combinable handles by media type. Historically every sheet
// whose media wasn't all/screen was dropped from combining — but on
// page-builder sites (Elementor + Essential Addons + BetterDocs) a large
// share of the stylesheets carry responsive/print media, so dropping
// them starved the `all` bucket below the 2-handle floor and the whole
// combine step silently no-op'd (the page shipped 60 separate s
// even with combine_css ON). Instead we bucket PER media type and emit
// one combined file per group with the correct `media` attribute, so
// nothing is dropped and the combinable majority always merges. (FBS-83653)
$buckets = self::collect_local_handles( $wp_styles );
foreach ( $buckets as $media => $bucket ) {
if ( count( $bucket ) < 2 ) {
continue; // nothing to gain from combining a single file in this group.
}
self::combine_media_group( $wp_styles, $media, $bucket );
}
}
/**
* Combine one media group's handles into a single stylesheet and wire it
* onto the group's carrier handle.
*
* @param string $media The media attribute for this group ('all', 'print', …).
* @param array> $bucket handle => info map.
*/
private static function combine_media_group( \WP_Styles $wp_styles, string $media, array $bucket ): void {
$key = self::cache_key( $bucket );
$dir = self::cache_dir();
$out_file = $dir . '/combined-' . $key . '.css';
$out_url = self::cache_url() . '/combined-' . $key . '.css';
if ( ! file_exists( $out_file ) ) {
self::ensure_dir( $dir );
$contents = '';
foreach ( $bucket as $handle => $info ) {
$body = self::read_local_file( $info['path'] );
if ( '' === $body ) {
continue;
}
$body = self::resolve_imports( $body, $info['url'], 0 );
$body = self::rewrite_url_paths( $body, $info['url'] );
// No per-handle banner comment: it is a debugging aid with no
// runtime value, and the minifier below preserves a comment
// that opens a chunk, so each one survived as a `/* xspeed */`
// stub that Lighthouse still counts as removable bytes.
// The handle list lives in the cache key, not in the payload.
$contents .= $body . "\n";
}
// Minify the JOIN, not just the parts (issue #331).
//
// Every input arrives here already minified, but the join itself
// is not: a `/* xspeed: */` banner per part, a newline
// after each, and whatever non-bang comments the sources kept.
// Nothing downstream removes them — Minifier::rewrite_style()
// deliberately skips anything under /cache/xspeed/ (re-minifying
// our own output produced a second hash whose URL 404'd after a
// purge), so this file was the end of the line and shipped as-is.
//
// The visible cost was small (~2 KB) but the scoring cost was not:
// Lighthouse's `unminified-css` is near-binary, so one failing
// file drops the audit to 0.5 — and the only offender on the page
// was the artifact we generated, which then docked the site on
// xSpeed Scan's own A2 check while the UI reported minify as on.
$contents = self::minify_css_body( $contents );
// Atomic write: file_put_contents with LOCK_EX so concurrent
// renders don't race.
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_put_contents_file_put_contents -- WP_Filesystem requires admin context, unavailable on frontend.
file_put_contents( $out_file, $contents, LOCK_EX );
} else {
self::mark_in_use( $out_file );
}
// Point the FIRST combined handle at the combined file and blank the
// rest. This is deliberate — we do NOT enqueue a fresh
// `xspeed-combined-css` handle, because WordPress would print it at
// the tail of the queue, AFTER any non-combinable stylesheets
// (media-query sheets like woocommerce-smallscreen, wc-blocks-*,
// external fonts) that originally sat between/after the combined
// handles. That reorders the cascade and breaks layout — e.g. the
// WooCommerce/Astra grid + sidebar widths get overridden by rules
// that should have lower priority. By reusing the first combined
// handle's own queue slot for the combined , the merged CSS
// prints exactly where the earliest source stylesheet used to be,
// preserving cascade order. (FBS-83114/83116)
//
// The remaining combined handles keep their registration + queue
// membership (src blanked) so their wp_add_inline_style() data still
// prints — WordPress only emits inline data for handles still in the
// print queue, and some themes (Astra) attach that dynamic CSS on a
// hook LATER than this priority-999 pass, so we can't harvest it now.
// Dropping it is what made "combine CSS break the site".
// The carrier is the FIRST bucket handle that WordPress hasn't already
// printed. A block theme (Twenty Twenty-Five, etc.) prints some of its
// per-block style handles BEFORE this priority-999 pass, marking them
// `done`; pointing a done handle at the combined file emits no
// at all — the merged CSS silently vanishes and the whole site renders
// unstyled. Skipping done handles guarantees the carrier still prints.
// If every bucket handle is already done, register a dedicated combined
// handle so the CSS is never lost (cascade tail is far better than no
// styles). (FBS-83633)
$done = (array) $wp_styles->done;
$carrier_set = false;
foreach ( $bucket as $handle => $info ) {
$reg = $wp_styles->registered[ $handle ] ?? null;
if ( ! $reg instanceof \_WP_Dependency ) {
continue;
}
if ( ! $carrier_set && ! in_array( $handle, $done, true ) ) {
// Carry the combined file on this (not-yet-printed) handle's slot.
$reg->src = $out_url;
$reg->ver = $key;
$reg->args = $media;
$carrier_set = true;
} else {
// Inline-only carrier: no , keep inline CSS printable.
$reg->src = false;
$reg->ver = null;
}
}
// Fallback: every bucket handle was already printed, so no carrier
// could emit the combined . Register + enqueue a dedicated
// handle so the merged CSS still loads (appended at the tail — not
// cascade-ideal, but infinitely better than a fully unstyled page).
if ( ! $carrier_set ) {
$combined_handle = 'xspeed-combined-css-' . $media;
wp_register_style( $combined_handle, $out_url, array(), $key, $media );
wp_enqueue_style( $combined_handle );
}
}
/**
* Combine local enqueued scripts into one file.
*/
public static function combine_scripts(): void {
global $wp_scripts;
if ( ! $wp_scripts instanceof \WP_Scripts || empty( $wp_scripts->queue ) ) {
return;
}
$bucket = self::collect_local_script_handles( $wp_scripts );
if ( count( $bucket ) < 2 ) {
return;
}
// Split by print group — head (0) and footer (1) get their own bundle.
//
// Carrying everything on ONE carrier meant the whole bucket inherited
// that handle's placement, and the first handle in dependency order is
// almost always jquery-core, which WordPress registers with no group
// data at all — i.e. the HEAD. Every footer script absorbed alongside
// it was therefore hoisted into the head and executed as one
// synchronous blob before first paint: correctness-safer than the old
// forced footer, but render-blocking, and the exact inverse of what a
// speed plugin should ship. Bucketing by group is the same move
// combine_styles() already makes for media types. (#289, PR #290 review)
foreach ( self::split_by_group( $wp_scripts, $bucket ) as $group => $group_bucket ) {
if ( count( $group_bucket ) < 2 ) {
continue; // nothing to gain from combining a single file.
}
self::combine_script_group( $wp_scripts, (int) $group, $group_bucket );
}
}
/**
* Partition a bucket into WordPress's print groups: 0 = head, 1 = footer.
*
* Reads $wp_scripts->groups, NOT the declared `extra['group']`, because
* the declared value is not authoritative: WordPress promotes a
* footer-registered dependency of a head script into the head. all_deps()
* populates the effective values and prints nothing, so resolving them
* here keeps our split consistent with what WordPress would have done on
* its own. (PR #290 review)
*
* @param array> $bucket handle => info map.
* @return array>> group => bucket.
*/
private static function split_by_group( \WP_Scripts $wp_scripts, array $bucket ): array {
// Resolve effective groups for everything queued. Safe to call at
// wp_enqueue_scripts: it walks dependencies and fills ->groups
// without emitting a single tag.
$wp_scripts->all_deps( $wp_scripts->queue, false );
$groups = array();
foreach ( $bucket as $handle => $info ) {
$group = isset( $wp_scripts->groups[ $handle ] ) ? (int) $wp_scripts->groups[ $handle ] : 0;
$groups[ $group ][ $handle ] = $info;
}
return $groups;
}
/**
* Build and attach one combined file for a single print group.
*
* @param int $group 0 = head, 1 = footer.
* @param array> $bucket handle => info map for this group.
*/
private static function combine_script_group( \WP_Scripts $wp_scripts, int $group, array $bucket ): void {
$key = self::cache_key( $bucket );
$dir = self::cache_dir();
// Group in the filename so a head and a footer bundle can never
// collide on one cache key.
$out_file = $dir . '/combined-g' . $group . '-' . $key . '.js';
$out_url = self::cache_url() . '/combined-g' . $group . '-' . $key . '.js';
if ( ! file_exists( $out_file ) ) {
self::ensure_dir( $dir );
$contents = '';
foreach ( $bucket as $handle => $info ) {
$body = self::read_local_file( $info['path'] );
if ( '' === $body ) {
continue;
}
$contents .= "/* xspeed: $handle */\n" . $body . "\n;\n";
}
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_put_contents_file_put_contents -- WP_Filesystem unavailable on frontend.
file_put_contents( $out_file, $contents, LOCK_EX );
} else {
self::mark_in_use( $out_file );
}
self::attach_to_carrier( $wp_scripts, $bucket, $out_url, $key );
}
/**
* carrier handle => the handles whose src it now serves.
*
* @var array
*/
private static $carriers = array();
/** Whether the print-time sweep is hooked. */
private static $late_sweep_hooked = false;
/** Payload fingerprints already re-homed, so a second sweep is a no-op. */
private static $rehomed = array();
/**
* Point the combined file at the FIRST not-yet-printed bucket handle and
* blank the rest, instead of dequeuing everything and appending a fresh
* handle.
*
* The old approach registered `xspeed-combined-js` with `array()` deps and
* a hard-coded `$in_footer = true`, then dequeued the originals. Three
* things went wrong with that:
*
* 1. No dependency edges. The bundle declared no relationship to the
* handles that stayed in the queue (external, async/deferred,
* localized), so WordPress was free to print it in any order relative
* to them.
* 2. Forced to the footer. Every head script in the bucket was relocated
* behind any inline