The Problem
A client called me yesterday in a panic. Their WooCommerce shop pages had gone flat overnight: buttons unstyled, columns collapsed into a single stack, the cover block hero missing its background. No CSS edits, no plugin updates, nothing in their changelog. The only thing that ran was the auto-update from WordPress 6.8.2 to 6.9.1 the night before.
I have seen this exact pattern on three different sites in the last week. If your blocks look fine in the editor but ship to the frontend without their styles after the 6.9 upgrade, you are hitting the new on-demand block asset loader and it is dropping CSS for blocks it should not.
Why It Happens
WordPress 6.9 changed how core block styles get enqueued. Before 6.9, every core block style was loaded on every page in one wp-block-library-css stylesheet. From 6.9 forward, the engine inspects the parsed block tree on each request and only enqueues the CSS for blocks it detects.
The intent is good: fewer bytes on pages that use only a few blocks. The trade-off is that detection runs on the static block tree before any dynamic block, shortcode, or do_blocks() call has rendered. Blocks injected at render time (for example, a Query Loop pulling in a synced pattern, a custom render callback that returns block markup, or a third-party builder like Spectra rendering nested blocks) are invisible to the detector. Their styles never get enqueued, so the markup ships without CSS.
Three patterns trigger it most often.
Pattern 1: Synced patterns inside Query Loops. The Query Loop renders post content, which can contain a synced pattern reference. The detector sees core/query and core/post-template but not the cover block sitting inside the synced pattern. Cover styles get skipped.
Pattern 2: Reusable theme parts loaded via block_template_part. If your theme part is registered programmatically and contains group, columns, or buttons blocks, the detector misses them on routes that do not statically reference the part.
Pattern 3: Custom block renderers that return inner block markup. If you registered a dynamic block that returns <!-- wp:cover --> style markup from its render_callback, the parser does not re-walk that output, so cover, image, and group styles inside it stay unenqueued.
The result is the same: editor preview fine, frontend broken.
The Fix
Option 1: Disable the per-block style loader globally. This is the one-line revert that gets the site back online while you investigate:
// In your theme's functions.php or a small mu-plugin
add_filter( 'should_load_separate_core_block_assets', '__return_false', 100 );
This tells WordPress to load the full wp-block-library-css stylesheet on every page like it did before 6.9. You give back 30-50KB of CSS on light pages, but every block gets its styles. For a content site or a WooCommerce shop where most pages render varied blocks, the trade-off is usually worth it. The WordPress block editor handbook documents this filter.
Option 2: Force-enqueue the specific blocks you know are missing. If you want to keep on-demand loading and only patch the blocks the detector misses, register them explicitly:
add_action( 'wp_enqueue_scripts', function () {
if ( ! function_exists( 'wp_enqueue_block_style' ) ) {
return;
}
$blocks_to_force = array(
'core/cover',
'core/columns',
'core/buttons',
'core/group',
'core/image',
);
foreach ( $blocks_to_force as $block_name ) {
wp_enqueue_block_style( $block_name, array(
'handle' => 'wp-block-' . str_replace( 'core/', '', $block_name ),
) );
}
}, 20 );
Run this only on routes where the detector misses things, by wrapping the loop in is_singular( 'product' ) or whatever conditional fits. Keeping it global negates most of the loader benefit.
Option 3: Make your dynamic blocks visible to the detector. If you control the custom block, return a block_type_metadata declaration that lists its inner block dependencies. The detector reads metadata, not just the block tree:
register_block_type( __DIR__ . '/build/my-card', array(
'render_callback' => 'my_card_render',
'uses_context' => array( 'postId' ),
// The detector reads this and enqueues styles for these inner blocks
'parent' => array( 'core/group' ),
) );
For inner block dependencies, declare them in block.json under usesContext and parent. The 6.9 detector walks those declarations.
Option 4: Audit which block CSS is actually missing. Before applying a fix, confirm what is wrong. View source on a broken page and search for wp-block-. Compare it to the same view on a 6.8 backup. The diff tells you exactly which handles got dropped:
# On a working backup
curl -s https://staging.example.com/shop/ | grep -o 'wp-block-[a-z-]*-css' | sort -u > working.txt
# On the broken production
curl -s https://example.com/shop/ | grep -o 'wp-block-[a-z-]*-css' | sort -u > broken.txt
diff working.txt broken.txt
The handles missing from broken.txt are exactly what you need to force-enqueue. This is faster than guessing.
Option 5: Patch to 6.9.1 and re-test. The WordPress 6.9.1 maintenance release shipped fixes for the editor styles iframe and several block style enqueue edge cases. If you are still on 6.9.0 or an earlier RC, update before reaching for filters. A few of the cases I saw on client sites resolved on 6.9.1 alone.
The Lesson
The on-demand block style loader is a real win on simple pages and a foot-gun on dynamic ones. If you ship synced patterns, custom renderers, or page builders, expect to either disable the loader or force-enqueue the blocks the detector cannot see. Diff the source of a working backup against the broken page to know exactly what is missing instead of guessing.
If your WordPress site looks visibly broken after the 6.9 update and you need someone to triage it without rolling back, that is the kind of work I do — see my services, or read about a related issue in my Elementor editor blank screen after update writeup.