From b63b4b419f0ca34c2da9f346deae4053655709f6 Mon Sep 17 00:00:00 2001 From: Dean Sas Date: Wed, 28 Jan 2026 10:52:00 +0000 Subject: [PATCH 1/5] Fix CSS concatenation to preserve original stylesheet order The previous implementation grouped all concatenatable CSS files into a single bundle that was always output first, before non-concatenatable items. This broke the relative order between concatenated stylesheets and non-concatenated items (such as files with inline styles). This caused issues where theme stylesheets that should appear after Gutenberg's global styles were instead output before them, allowing Gutenberg's `a:where(:not(.wp-element-button))` rule to override theme link styling due to CSS cascade order. The fix adopts the same approach used in concat-js.php: use a level counter where non-concatenatable items break the current concat group (double-increment pattern), preserving the original WordPress enqueue order. Fixes DOTTHEM-159 --- concat-css.php | 94 ++++++++++++++++++++++++++------------------------ 1 file changed, 49 insertions(+), 45 deletions(-) diff --git a/concat-css.php b/concat-css.php index 18024d8..a937df3 100644 --- a/concat-css.php +++ b/concat-css.php @@ -42,11 +42,9 @@ function do_items( $handles = false, $group = false ) { $this->all_deps( $handles ); - $stylesheet_group_index = 0; - // Merge CSS into a single file - $concat_group = 'concat'; - // Concat group on top (first array element gets processed earlier) - $stylesheets[ $concat_group ] = array(); + // Track position in output order. Non-concat items break concat groups + // to preserve the original stylesheet order (matching concat-js.php approach). + $level = 0; foreach ( $this->to_do as $key => $handle ) { $obj = $this->registered[ $handle ]; @@ -136,59 +134,65 @@ function do_items( $handles = false, $group = false ) { $media = 'all'; } - $stylesheets[ $concat_group ][ $media ][ $handle ] = $css_url_parsed['path']; + // Add to current concat group (create if needed) + if ( ! isset( $stylesheets[ $level ] ) ) { + $stylesheets[ $level ]['type'] = 'concat'; + } + $stylesheets[ $level ]['paths'][ $media ][ $handle ] = $css_url_parsed['path']; $this->done[] = $handle; } else { - $stylesheet_group_index ++; - $stylesheets[ $stylesheet_group_index ]['noconcat'][] = $handle; - $stylesheet_group_index ++; + // Non-concat items break the group to preserve order (double-increment pattern from concat-js.php) + $level++; + $stylesheets[ $level ]['type'] = 'do_item'; + $stylesheets[ $level ]['handle'] = $handle; + $level++; } unset( $this->to_do[ $key ] ); } - foreach ( $stylesheets as $idx => $stylesheets_group ) { - foreach ( $stylesheets_group as $media => $css ) { - if ( 'noconcat' == $media ) { - foreach ( $css as $handle ) { - if ( $this->do_item( $handle, $group ) ) { - $this->done[] = $handle; + foreach ( $stylesheets as $css_array ) { + if ( 'do_item' === $css_array['type'] ) { + if ( $this->do_item( $css_array['handle'], $group ) ) { + $this->done[] = $css_array['handle']; + } + } elseif ( 'concat' === $css_array['type'] ) { + // Process each media type within the concat group + foreach ( $css_array['paths'] as $media => $css ) { + if ( count( $css ) > 1 ) { + $fs_paths = array(); + foreach ( $css as $css_uri_path ) { + $fs_paths[] = $this->dependency_path_mapping->uri_path_to_fs_path( $css_uri_path ); } - } - continue; - } elseif ( count( $css ) > 1 ) { - $fs_paths = array(); - foreach ( $css as $css_uri_path ) { - $fs_paths[] = $this->dependency_path_mapping->uri_path_to_fs_path( $css_uri_path ); - } - $mtime = max( array_map( 'filemtime', $fs_paths ) ); - if ( page_optimize_use_concat_base_dir() ) { - $path_str = implode( ',', array_map( 'page_optimize_remove_concat_base_prefix', $fs_paths ) ); - } else { - $path_str = implode( ',', $css ); - } - $path_str = "$path_str?m=$mtime"; + $mtime = max( array_map( 'filemtime', $fs_paths ) ); + if ( page_optimize_use_concat_base_dir() ) { + $path_str = implode( ',', array_map( 'page_optimize_remove_concat_base_prefix', $fs_paths ) ); + } else { + $path_str = implode( ',', $css ); + } + $path_str = "$path_str?m=$mtime"; - if ( $this->allow_gzip_compression ) { - $path_64 = base64_encode( gzcompress( $path_str ) ); - if ( strlen( $path_str ) > ( strlen( $path_64 ) + 1 ) ) { - $path_str = '-' . $path_64; + if ( $this->allow_gzip_compression ) { + $path_64 = base64_encode( gzcompress( $path_str ) ); + if ( strlen( $path_str ) > ( strlen( $path_64 ) + 1 ) ) { + $path_str = '-' . $path_64; + } } - } - $href = $siteurl . "/_static/??" . $path_str; - } else { - $href = Page_Optimize_Utils::cache_bust_mtime( current( $css ), $siteurl ); - } + $href = $siteurl . "/_static/??" . $path_str; + } else { + $href = Page_Optimize_Utils::cache_bust_mtime( current( $css ), $siteurl ); + } - $handles = array_keys( $css ); - $css_id = "$media-css-" . md5( $href ); - if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) { - echo apply_filters( 'page_optimize_style_loader_tag', "\n", $handles, $href, $media ); - } else { - echo apply_filters( 'page_optimize_style_loader_tag', "\n", $handles, $href, $media ); + $handles = array_keys( $css ); + $css_id = "$media-css-" . md5( $href ); + if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) { + echo apply_filters( 'page_optimize_style_loader_tag', "\n", $handles, $href, $media ); + } else { + echo apply_filters( 'page_optimize_style_loader_tag', "\n", $handles, $href, $media ); + } + array_map( array( $this, 'print_inline_style' ), array_keys( $css ) ); } - array_map( array( $this, 'print_inline_style' ), array_keys( $css ) ); } } From 779df6df81916fa9c067df662d5fac136acd9b2f Mon Sep 17 00:00:00 2001 From: Dean Sas Date: Wed, 28 Jan 2026 11:07:42 +0000 Subject: [PATCH 2/5] Add defensive isset() check for paths array --- concat-css.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/concat-css.php b/concat-css.php index a937df3..535e205 100644 --- a/concat-css.php +++ b/concat-css.php @@ -155,7 +155,7 @@ function do_items( $handles = false, $group = false ) { if ( $this->do_item( $css_array['handle'], $group ) ) { $this->done[] = $css_array['handle']; } - } elseif ( 'concat' === $css_array['type'] ) { + } elseif ( 'concat' === $css_array['type'] && isset( $css_array['paths'] ) ) { // Process each media type within the concat group foreach ( $css_array['paths'] as $media => $css ) { if ( count( $css ) > 1 ) { From 4ed57f696b925fa2403a34b8ca569d01e52d20cc Mon Sep 17 00:00:00 2001 From: Matthew Reishus Date: Fri, 30 Jan 2026 22:23:05 +0000 Subject: [PATCH 3/5] Fix CSS concat ordering across media and inline boundaries --- concat-css.php | 149 ++++++++++++++++++++++---------- tests/test_css_concat_order.php | 8 +- 2 files changed, 108 insertions(+), 49 deletions(-) diff --git a/concat-css.php b/concat-css.php index 535e205..02ded57 100644 --- a/concat-css.php +++ b/concat-css.php @@ -35,6 +35,15 @@ function __construct( $styles ) { ); } + protected function has_inline_style( $handle ) { + $after_output = $this->get_data( $handle, 'after' ); + if ( ! empty( $after_output ) ) { + return true; + } + + return false; + } + function do_items( $handles = false, $group = false ) { $handles = false === $handles ? $this->queue : (array) $handles; $stylesheets = array(); @@ -42,12 +51,30 @@ function do_items( $handles = false, $group = false ) { $this->all_deps( $handles ); - // Track position in output order. Non-concat items break concat groups - // to preserve the original stylesheet order (matching concat-js.php approach). - $level = 0; + $concat_group = null; foreach ( $this->to_do as $key => $handle ) { + if ( ! isset( $this->registered[ $handle ] ) ) { + unset( $this->to_do[ $key ] ); + continue; + } + $obj = $this->registered[ $handle ]; + + if ( empty( $obj->src ) ) { + if ( null !== $concat_group ) { + $stylesheets[] = $concat_group; + $concat_group = null; + } + + $stylesheets[] = array( + 'type' => 'do_item', + 'handle' => $handle, + ); + unset( $this->to_do[ $key ] ); + continue; + } + $obj->src = apply_filters( 'style_loader_src', $obj->src, $obj->handle ); // Core is kind of broken and returns "true" for src of "colors" handle @@ -58,14 +85,15 @@ function do_items( $handles = false, $group = false ) { $css_url = wp_style_loader_src( $css_url, $obj->handle ); } - $css_url_parsed = parse_url( $obj->src ); + $css_url_parsed = parse_url( $css_url ); + $css_path = ( is_array( $css_url_parsed ) && isset( $css_url_parsed['path'] ) ) ? $css_url_parsed['path'] : ''; $extra = $obj->extra; // Don't concat by default $do_concat = false; // Only try to concat static css files - if ( false !== strpos( $css_url_parsed['path'], '.css' ) ) { + if ( $css_path && false !== strpos( $css_path, '.css' ) ) { $do_concat = true; } else { if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) { @@ -134,65 +162,96 @@ function do_items( $handles = false, $group = false ) { $media = 'all'; } - // Add to current concat group (create if needed) - if ( ! isset( $stylesheets[ $level ] ) ) { - $stylesheets[ $level ]['type'] = 'concat'; + if ( null !== $concat_group && $concat_group['media'] !== $media ) { + $stylesheets[] = $concat_group; + $concat_group = null; + } + + if ( null === $concat_group ) { + $concat_group = array( + 'type' => 'concat', + 'media' => $media, + 'paths' => array(), + 'handles' => array(), + ); } - $stylesheets[ $level ]['paths'][ $media ][ $handle ] = $css_url_parsed['path']; + + $concat_group['paths'][] = $css_path; + $concat_group['handles'][] = $handle; $this->done[] = $handle; + + if ( $this->has_inline_style( $handle ) ) { + $stylesheets[] = $concat_group; + $concat_group = null; + } } else { - // Non-concat items break the group to preserve order (double-increment pattern from concat-js.php) - $level++; - $stylesheets[ $level ]['type'] = 'do_item'; - $stylesheets[ $level ]['handle'] = $handle; - $level++; + if ( null !== $concat_group ) { + $stylesheets[] = $concat_group; + $concat_group = null; + } + $stylesheets[] = array( + 'type' => 'do_item', + 'handle' => $handle, + ); } unset( $this->to_do[ $key ] ); } + if ( null !== $concat_group ) { + $stylesheets[] = $concat_group; + } + foreach ( $stylesheets as $css_array ) { if ( 'do_item' === $css_array['type'] ) { if ( $this->do_item( $css_array['handle'], $group ) ) { $this->done[] = $css_array['handle']; } } elseif ( 'concat' === $css_array['type'] && isset( $css_array['paths'] ) ) { - // Process each media type within the concat group - foreach ( $css_array['paths'] as $media => $css ) { - if ( count( $css ) > 1 ) { - $fs_paths = array(); - foreach ( $css as $css_uri_path ) { - $fs_paths[] = $this->dependency_path_mapping->uri_path_to_fs_path( $css_uri_path ); - } - - $mtime = max( array_map( 'filemtime', $fs_paths ) ); - if ( page_optimize_use_concat_base_dir() ) { - $path_str = implode( ',', array_map( 'page_optimize_remove_concat_base_prefix', $fs_paths ) ); - } else { - $path_str = implode( ',', $css ); - } - $path_str = "$path_str?m=$mtime"; - - if ( $this->allow_gzip_compression ) { - $path_64 = base64_encode( gzcompress( $path_str ) ); - if ( strlen( $path_str ) > ( strlen( $path_64 ) + 1 ) ) { - $path_str = '-' . $path_64; - } - } + $media = $css_array['media']; + $css = $css_array['paths']; + $handles = $css_array['handles']; + + if ( count( $css ) > 1 ) { + $fs_paths = array(); + foreach ( $css as $css_uri_path ) { + $fs_paths[] = $this->dependency_path_mapping->uri_path_to_fs_path( $css_uri_path ); + } - $href = $siteurl . "/_static/??" . $path_str; + $mtime = max( array_map( 'filemtime', $fs_paths ) ); + if ( page_optimize_use_concat_base_dir() ) { + $path_str = implode( ',', array_map( 'page_optimize_remove_concat_base_prefix', $fs_paths ) ); } else { - $href = Page_Optimize_Utils::cache_bust_mtime( current( $css ), $siteurl ); + $path_str = implode( ',', $css ); } + $path_str = "$path_str?m=$mtime"; - $handles = array_keys( $css ); - $css_id = "$media-css-" . md5( $href ); - if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) { - echo apply_filters( 'page_optimize_style_loader_tag', "\n", $handles, $href, $media ); - } else { - echo apply_filters( 'page_optimize_style_loader_tag', "\n", $handles, $href, $media ); + if ( $this->allow_gzip_compression ) { + $path_64 = base64_encode( gzcompress( $path_str ) ); + if ( strlen( $path_str ) > ( strlen( $path_64 ) + 1 ) ) { + $path_str = '-' . $path_64; + } } - array_map( array( $this, 'print_inline_style' ), array_keys( $css ) ); + + $href = $siteurl . "/_static/??" . $path_str; + } else { + $href = Page_Optimize_Utils::cache_bust_mtime( $css[0], $siteurl ); + } + + $css_id = "$media-css-" . md5( $href ); + if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) { + $tag = "\n"; + } else { + $tag = "\n"; } + + $tag = apply_filters( 'page_optimize_style_loader_tag', $tag, $handles, $href, $media ); + + if ( is_array( $handles ) && count( $handles ) === 1 ) { + $tag = apply_filters( 'style_loader_tag', $tag, $handles[0], $href, $media ); + } + + echo $tag; + array_map( array( $this, 'print_inline_style' ), $handles ); } } diff --git a/tests/test_css_concat_order.php b/tests/test_css_concat_order.php index 30126e6..6f66d62 100644 --- a/tests/test_css_concat_order.php +++ b/tests/test_css_concat_order.php @@ -240,8 +240,8 @@ public function test_same_media_stylesheets_concatenate_within_run(): void { * need special handling and cannot be concatenated. * * Enqueue order: a (local) -> b (rtl-marked) -> c (local) - * Expected output: [a], [b], [c] (three separate tags, in order) - * Bug: [a], [c], [b], [b] ( RTL stylesheet pushed to end, also not sure why there are 2 - test harness issue? ) + * Expected output: [a], [b], [b], [c] (two tags for b: base + RTL, in order) + * Bug: [a], [c], [b], [b] (RTL stylesheet pushed to end) * * @group css-order-bug */ @@ -268,10 +268,10 @@ public function test_rtl_stylesheet_breaks_concat_run_and_preserves_order(): voi $groups = $this->extract_handle_groups( $html ); $handles = $this->flatten_groups( $groups ); - $this->assertSame( [ 'a', 'b', 'c' ], $handles, 'RTL stylesheet must not cause reordering.' ); + $this->assertSame( [ 'a', 'b', 'b', 'c' ], $handles, 'RTL stylesheet must not cause reordering.' ); // Each should be in its own group (no concatenation across RTL boundary). - $this->assertSame( [ [ 'a' ], [ 'b' ], [ 'c' ] ], $groups, 'RTL stylesheet should break concat run.' ); + $this->assertSame( [ [ 'a' ], [ 'b' ], [ 'b' ], [ 'c' ] ], $groups, 'RTL stylesheet should break concat run.' ); } /** From 1515fc877d41f26125ff7f9723cc785606ff135f Mon Sep 17 00:00:00 2001 From: Matthew Reishus Date: Fri, 30 Jan 2026 23:23:58 +0000 Subject: [PATCH 4/5] Fix running css_do_concat twice --- concat-css.php | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/concat-css.php b/concat-css.php index 02ded57..6a71aae 100644 --- a/concat-css.php +++ b/concat-css.php @@ -149,12 +149,13 @@ function do_items( $handles = false, $group = false ) { } // Allow plugins to disable concatenation of certain stylesheets. - if ( $do_concat && ! apply_filters( 'css_do_concat', $do_concat, $handle ) ) { + $filtered_concat = apply_filters( 'css_do_concat', $do_concat, $handle ); + if ( $do_concat && ! $filtered_concat ) { if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) { echo sprintf( "\n\n", esc_html( $handle ) ); } } - $do_concat = apply_filters( 'css_do_concat', $do_concat, $handle ); + $do_concat = $filtered_concat; if ( true === $do_concat ) { $media = $obj->args; From 1315ff6a7370b6c094c6fdf51783fe0573d1acb2 Mon Sep 17 00:00:00 2001 From: Matthew Reishus Date: Fri, 30 Jan 2026 23:20:36 +0000 Subject: [PATCH 5/5] Fix mutation of $obj->src with style_loader_src filter --- concat-css.php | 16 ++++-- tests/test_css_concat_eligibility.php | 80 +++++++++++++++++++++++++++ 2 files changed, 91 insertions(+), 5 deletions(-) diff --git a/concat-css.php b/concat-css.php index 6a71aae..d3ca77b 100644 --- a/concat-css.php +++ b/concat-css.php @@ -75,18 +75,24 @@ function do_items( $handles = false, $group = false ) { continue; } - $obj->src = apply_filters( 'style_loader_src', $obj->src, $obj->handle ); + $css_url = apply_filters( 'style_loader_src', $obj->src, $obj->handle ); // Core is kind of broken and returns "true" for src of "colors" handle // http://core.trac.wordpress.org/attachment/ticket/16827/colors-hacked-fixed.diff // http://core.trac.wordpress.org/ticket/20729 - $css_url = $obj->src; - if ( 'colors' == $obj->handle && true === $css_url ) { + if ( 'colors' === $obj->handle && true === $css_url ) { $css_url = wp_style_loader_src( $css_url, $obj->handle ); } - $css_url_parsed = parse_url( $css_url ); - $css_path = ( is_array( $css_url_parsed ) && isset( $css_url_parsed['path'] ) ) ? $css_url_parsed['path'] : ''; + // If a filter returns something unexpected, let's not concat it. + if ( ! is_string( $css_url ) || '' === $css_url ) { + $css_url_parsed = false; + $css_path = ''; + } else { + $css_url_parsed = parse_url( $css_url ); + $css_path = ( is_array( $css_url_parsed ) && isset( $css_url_parsed['path'] ) ) ? $css_url_parsed['path'] : ''; + } + $extra = $obj->extra; // Don't concat by default diff --git a/tests/test_css_concat_eligibility.php b/tests/test_css_concat_eligibility.php index ef6d8a8..f67c2bb 100644 --- a/tests/test_css_concat_eligibility.php +++ b/tests/test_css_concat_eligibility.php @@ -186,4 +186,84 @@ public function test_css_do_concat_filter_can_disable_concatenation(): void { remove_filter( 'css_do_concat', $filter_callback, 10 ); } } + + /** + * Verifies that style_loader_src mutations do not accumulate when a handle + * falls back to core's do_item(). + * + * We allow style_loader_src to run multiple times, but Page Optimize must NOT + * overwrite $registered[handle]->src with the filtered URL. Otherwise, core will + * re-filter an already-filtered URL and non-idempotent filters will stack. + */ + public function test_style_loader_src_does_not_accumulate_for_non_concatenated_handle(): void { + $application_count = 0; + + // Deliberately non-idempotent: appends a NEW po_filter param every time it runs. + // If the input URL was already mutated (contains po_filter=1), a second run will + // produce po_filter=1&po_filter=2, which we can detect. + $filter_callback = function( $src, $handle ) use ( &$application_count ) { + if ( 'a' !== $handle ) { + return $src; + } + + $application_count++; + + $sep = ( false === strpos( $src, '?' ) ) ? '?' : '&'; + return $src . $sep . 'po_filter=' . $application_count; + }; + + add_filter( 'style_loader_src', $filter_callback, 10, 2 ); + + // Force 'a' to fall through to core do_item(). + $exclude_filter = function( $do_concat, $handle ) { + return ( 'a' === $handle ) ? false : $do_concat; + }; + add_filter( 'css_do_concat', $exclude_filter, 10, 2 ); + + try { + $styles = $this->new_concat_styles(); + + $a = $this->make_content_css( 'po-double-filter-a.css' ); + $styles->add( 'a', $a, [], null, 'all' ); + $styles->enqueue( 'a' ); + + // Capture the original stored src. If Page Optimize mutates $obj->src, this will change. + $original_src = $styles->registered['a']->src; + + $html = $this->render( $styles ); + + // Precondition: confirm it rendered via core do_item (not a Page Optimize-generated ID). + $this->assertMatchesRegularExpression( '/id=[\'"]a-css[\'"]/', $html, 'Expected core do_item output for excluded handle.' ); + + // Primary assertion: the registered src must remain unmodified. + $this->assertSame( + $original_src, + $styles->registered['a']->src, + 'Page Optimize must not overwrite $registered[handle]->src with the filtered URL (causes accumulated mutations).' + ); + + // Extract href for handle 'a' from the rendered link tag. + $this->assertMatchesRegularExpression( '/data-handles=[\'"]a[\'"]/', $html, 'Expected data-handles="a" in output.' ); + + preg_match( '/data-handles=[\'"]a[\'"][^>]*href=[\'"]([^\'"]+)[\'"]/', $html, $m ); + $this->assertNotEmpty( $m[1], 'Could not extract href for handle a.' ); + + // Decode & / & etc. for reliable query parsing. + $href = html_entity_decode( $m[1], ENT_QUOTES ); + + // If mutations accumulated, we'd see po_filter twice (po_filter=1&po_filter=2). + preg_match_all( '/(?:\?|&)po_filter=/', $href, $mm ); + $this->assertSame( + 1, + count( $mm[0] ), + 'Expected exactly one po_filter param in final href. Multiple occurrences indicate accumulated mutations across filter applications.' + ); + + // Supplementary: confirm the filter ran; the test is valid even if it ran more than once. + $this->assertGreaterThanOrEqual( 1, $application_count, 'Expected style_loader_src filter to run at least once.' ); + } finally { + remove_filter( 'style_loader_src', $filter_callback, 10 ); + remove_filter( 'css_do_concat', $exclude_filter, 10 ); + } + } }