From caf0fa0ab3d35be5f036ae0e6d2a1da0e8022147 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Thu, 20 Aug 2026 09:22:44 +0400 Subject: [PATCH 1/3] Editor: Refresh cached layout styles after invalidation --- src/wp-includes/block-supports/layout.php | 11 +++++--- .../class-wp-theme-json-resolver.php | 19 ++++++++++++++ tests/phpunit/tests/block-supports/layout.php | 25 +++++++++++++++++-- 3 files changed, 49 insertions(+), 6 deletions(-) diff --git a/src/wp-includes/block-supports/layout.php b/src/wp-includes/block-supports/layout.php index 3be4f07b055ea..654ea7fdca883 100644 --- a/src/wp-includes/block-supports/layout.php +++ b/src/wp-includes/block-supports/layout.php @@ -953,7 +953,8 @@ function wp_get_layout_style( $selector, $layout, $has_block_gap_support = false * @return string Filtered block content. */ function wp_render_layout_support_flag( $block_content, $block ) { - static $global_styles = null; + static $global_styles = null; + static $global_styles_cache_generation = -1; $block_type = WP_Block_Type_Registry::get_instance()->get_registered( $block['blockName'] ); $block_supports_layout = block_has_support( $block_type, 'layout', false ) || block_has_support( $block_type, '__experimentalLayout', false ); @@ -1167,9 +1168,11 @@ function wp_render_layout_support_flag( $block_content, $block ) { // Get default blockGap value from global styles for use in layouts like grid. // Check style variation first, then block-specific styles, then fall back to root styles. - $block_name = $block['blockName'] ?? ''; - if ( null === $global_styles ) { - $global_styles = wp_get_global_styles(); + $block_name = $block['blockName'] ?? ''; + $current_cache_generation = WP_Theme_JSON_Resolver::get_cache_generation(); + if ( $global_styles_cache_generation !== $current_cache_generation ) { + $global_styles = wp_get_global_styles(); + $global_styles_cache_generation = $current_cache_generation; } // Check if the block has an active style variation with a blockGap value. diff --git a/src/wp-includes/class-wp-theme-json-resolver.php b/src/wp-includes/class-wp-theme-json-resolver.php index 85124cf0e0cb6..0f5bcb2145d4b 100644 --- a/src/wp-includes/class-wp-theme-json-resolver.php +++ b/src/wp-includes/class-wp-theme-json-resolver.php @@ -19,6 +19,13 @@ */ #[AllowDynamicProperties] class WP_Theme_JSON_Resolver { + /** + * Generation of the currently cached theme JSON data. + * + * @since 7.0.0 + * @var int + */ + protected static $cache_generation = 0; /** * Container for keep track of registered blocks. @@ -745,6 +752,18 @@ public static function clean_cached_data() { static::$user = null; static::$user_custom_post_type_id = null; static::$i18n_schema = null; + ++static::$cache_generation; + } + + /** + * Returns the generation of the currently cached theme JSON data. + * + * @since 7.0.0 + * + * @return int Cache generation. + */ + public static function get_cache_generation() { + return static::$cache_generation; } /** diff --git a/tests/phpunit/tests/block-supports/layout.php b/tests/phpunit/tests/block-supports/layout.php index b196660de8ad3..779030f41f2ab 100644 --- a/tests/phpunit/tests/block-supports/layout.php +++ b/tests/phpunit/tests/block-supports/layout.php @@ -46,6 +46,14 @@ public function set_up() { /* * Register a style variation with a custom blockGap value for testing. */ + $this->register_custom_gap_style(); + WP_Theme_JSON_Resolver::clean_cached_data(); + } + + /** + * Registers the custom blockGap style variation used by the tests. + */ + private function register_custom_gap_style() { register_block_style( 'core/group', array( @@ -978,8 +986,6 @@ public function data_layout_classname_with_custom_blocks() { * * @ticket 64624 * @covers ::wp_render_layout_support_flag - * @runInSeparateProcess - * @preserveGlobalState disabled */ public function test_layout_support_flag_uses_variation_block_gap_value() { switch_theme( 'block-theme' ); @@ -1002,6 +1008,21 @@ public function test_layout_support_flag_uses_variation_block_gap_value() { ), ); + // Prime global styles without the custom variation. + unregister_block_style( 'core/group', 'custom-gap' ); + WP_Theme_JSON_Resolver::clean_cached_data(); + wp_render_layout_support_flag( $block_content, $block ); + + $initial_stylesheet = wp_style_engine_get_stylesheet_from_context( 'block-supports', array( 'prettify' => false ) ); + $this->assertStringContainsString( + 'grid-template-columns:repeat(auto-fill, minmax(max(min(12rem, 100%), (100% - (24px * (3 - 1))) /3), 1fr))', + $initial_stylesheet + ); + + WP_Style_Engine_CSS_Rules_Store::remove_all_stores(); + $this->register_custom_gap_style(); + WP_Theme_JSON_Resolver::clean_cached_data(); + wp_render_layout_support_flag( $block_content, $block ); // Get the generated CSS from the style engine. From 49100aac168812c9e2a0cfda93d76a63e0ab70d9 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Thu, 20 Aug 2026 10:29:41 +0400 Subject: [PATCH 2/3] Revert "Editor: Refresh cached layout styles after invalidation" This reverts commit caf0fa0ab3d35be5f036ae0e6d2a1da0e8022147. The generation-counter approach did not resolve the underlying problem. `WP_Theme_JSON_Resolver::$cache_generation` was only incremented by `clean_cached_data()`, which is reachable only through `wp_clean_theme_json_cache()` on `switch_theme` and `start_previewing_theme`. The resolver also invalidates itself through `has_same_registered_blocks()`, and that path never touched the counter, so the layout snapshot and the resolver could still disagree: register_block_type( 'test/gap', array( 'supports' => array( 'layout' => true, '__experimentalStyle' => array( 'spacing' => array( 'blockGap' => '77px' ), ), ), ) ); After a layout block had already rendered, `wp_get_global_styles()` returned the fresh `77px` while `wp_render_layout_support_flag()` continued to emit the stale fallback. Two caches with two invalidation rules remained, which is the defect the change set out to remove. It also added `WP_Theme_JSON_Resolver::get_cache_generation()` as permanent public API to work around a private caching detail, and required the regression test to call `clean_cached_data()` directly, which tested the new mechanism rather than the rendered output. The following commit fixes the root cause instead. --- src/wp-includes/block-supports/layout.php | 11 +++----- .../class-wp-theme-json-resolver.php | 19 -------------- tests/phpunit/tests/block-supports/layout.php | 25 ++----------------- 3 files changed, 6 insertions(+), 49 deletions(-) diff --git a/src/wp-includes/block-supports/layout.php b/src/wp-includes/block-supports/layout.php index 654ea7fdca883..3be4f07b055ea 100644 --- a/src/wp-includes/block-supports/layout.php +++ b/src/wp-includes/block-supports/layout.php @@ -953,8 +953,7 @@ function wp_get_layout_style( $selector, $layout, $has_block_gap_support = false * @return string Filtered block content. */ function wp_render_layout_support_flag( $block_content, $block ) { - static $global_styles = null; - static $global_styles_cache_generation = -1; + static $global_styles = null; $block_type = WP_Block_Type_Registry::get_instance()->get_registered( $block['blockName'] ); $block_supports_layout = block_has_support( $block_type, 'layout', false ) || block_has_support( $block_type, '__experimentalLayout', false ); @@ -1168,11 +1167,9 @@ function wp_render_layout_support_flag( $block_content, $block ) { // Get default blockGap value from global styles for use in layouts like grid. // Check style variation first, then block-specific styles, then fall back to root styles. - $block_name = $block['blockName'] ?? ''; - $current_cache_generation = WP_Theme_JSON_Resolver::get_cache_generation(); - if ( $global_styles_cache_generation !== $current_cache_generation ) { - $global_styles = wp_get_global_styles(); - $global_styles_cache_generation = $current_cache_generation; + $block_name = $block['blockName'] ?? ''; + if ( null === $global_styles ) { + $global_styles = wp_get_global_styles(); } // Check if the block has an active style variation with a blockGap value. diff --git a/src/wp-includes/class-wp-theme-json-resolver.php b/src/wp-includes/class-wp-theme-json-resolver.php index 0f5bcb2145d4b..85124cf0e0cb6 100644 --- a/src/wp-includes/class-wp-theme-json-resolver.php +++ b/src/wp-includes/class-wp-theme-json-resolver.php @@ -19,13 +19,6 @@ */ #[AllowDynamicProperties] class WP_Theme_JSON_Resolver { - /** - * Generation of the currently cached theme JSON data. - * - * @since 7.0.0 - * @var int - */ - protected static $cache_generation = 0; /** * Container for keep track of registered blocks. @@ -752,18 +745,6 @@ public static function clean_cached_data() { static::$user = null; static::$user_custom_post_type_id = null; static::$i18n_schema = null; - ++static::$cache_generation; - } - - /** - * Returns the generation of the currently cached theme JSON data. - * - * @since 7.0.0 - * - * @return int Cache generation. - */ - public static function get_cache_generation() { - return static::$cache_generation; } /** diff --git a/tests/phpunit/tests/block-supports/layout.php b/tests/phpunit/tests/block-supports/layout.php index 779030f41f2ab..b196660de8ad3 100644 --- a/tests/phpunit/tests/block-supports/layout.php +++ b/tests/phpunit/tests/block-supports/layout.php @@ -46,14 +46,6 @@ public function set_up() { /* * Register a style variation with a custom blockGap value for testing. */ - $this->register_custom_gap_style(); - WP_Theme_JSON_Resolver::clean_cached_data(); - } - - /** - * Registers the custom blockGap style variation used by the tests. - */ - private function register_custom_gap_style() { register_block_style( 'core/group', array( @@ -986,6 +978,8 @@ public function data_layout_classname_with_custom_blocks() { * * @ticket 64624 * @covers ::wp_render_layout_support_flag + * @runInSeparateProcess + * @preserveGlobalState disabled */ public function test_layout_support_flag_uses_variation_block_gap_value() { switch_theme( 'block-theme' ); @@ -1008,21 +1002,6 @@ public function test_layout_support_flag_uses_variation_block_gap_value() { ), ); - // Prime global styles without the custom variation. - unregister_block_style( 'core/group', 'custom-gap' ); - WP_Theme_JSON_Resolver::clean_cached_data(); - wp_render_layout_support_flag( $block_content, $block ); - - $initial_stylesheet = wp_style_engine_get_stylesheet_from_context( 'block-supports', array( 'prettify' => false ) ); - $this->assertStringContainsString( - 'grid-template-columns:repeat(auto-fill, minmax(max(min(12rem, 100%), (100% - (24px * (3 - 1))) /3), 1fr))', - $initial_stylesheet - ); - - WP_Style_Engine_CSS_Rules_Store::remove_all_stores(); - $this->register_custom_gap_style(); - WP_Theme_JSON_Resolver::clean_cached_data(); - wp_render_layout_support_flag( $block_content, $block ); // Get the generated CSS from the style engine. From 54293ed06c0f801c30214229615425879fe62988 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Thu, 20 Aug 2026 10:35:38 +0400 Subject: [PATCH 3/3] Editor: Cache merged global styles in the theme_json group `wp_get_global_styles()` is the only `theme_json` accessor without an object cache. Every call builds a fresh `WP_Theme_JSON` and merges four origins through `WP_Theme_JSON_Resolver::get_merged_data()`, at roughly 0.3ms per call. `wp_render_layout_support_flag()` worked around that cost with a function-static snapshot that nothing could invalidate, so a layout rendered after a theme change kept the previous `blockGap` value for the remaining lifetime of the PHP process. That is what forced the block style variation test to run in a separate process. Cache the merged styles in the non-persistent `theme_json` group, keyed by origin and by the `resolve-variables` transform, and clear those keys in `wp_clean_theme_json_cache()`, exactly as `wp_get_global_settings()` already does. The function-static in `wp_render_layout_support_flag()` is then unnecessary and is removed, so `switch_theme` refreshes layout styles and `@runInSeparateProcess` can be dropped from the test. `gallery.php` and `image.php` also call `wp_get_global_styles()` on every render with no cache of their own, and benefit as well. The function-static additionally ignored `wp_is_development_mode( 'theme' )`, so theme developers saw stale block gaps in layout output. The object cache respects that check. Measured on this branch, 1,000 iterations, median of five runs: wp_get_global_styles() before 311.61ms after 0.23ms wp_render_layout_support_flag() before 15.19ms after 15.73ms Trac ticket: https://core.trac.wordpress.org/ticket/65893 --- src/wp-includes/block-supports/layout.php | 8 +--- .../global-styles-and-settings.php | 46 +++++++++++++++++-- tests/phpunit/tests/block-supports/layout.php | 2 - 3 files changed, 44 insertions(+), 12 deletions(-) diff --git a/src/wp-includes/block-supports/layout.php b/src/wp-includes/block-supports/layout.php index 3be4f07b055ea..b4f3f91ec1848 100644 --- a/src/wp-includes/block-supports/layout.php +++ b/src/wp-includes/block-supports/layout.php @@ -953,8 +953,6 @@ function wp_get_layout_style( $selector, $layout, $has_block_gap_support = false * @return string Filtered block content. */ function wp_render_layout_support_flag( $block_content, $block ) { - static $global_styles = null; - $block_type = WP_Block_Type_Registry::get_instance()->get_registered( $block['blockName'] ); $block_supports_layout = block_has_support( $block_type, 'layout', false ) || block_has_support( $block_type, '__experimentalLayout', false ); $style_attr = $block['attrs']['style'] ?? array(); @@ -1167,10 +1165,8 @@ function wp_render_layout_support_flag( $block_content, $block ) { // Get default blockGap value from global styles for use in layouts like grid. // Check style variation first, then block-specific styles, then fall back to root styles. - $block_name = $block['blockName'] ?? ''; - if ( null === $global_styles ) { - $global_styles = wp_get_global_styles(); - } + $block_name = $block['blockName'] ?? ''; + $global_styles = wp_get_global_styles(); // Check if the block has an active style variation with a blockGap value. // Only check the registry if the className contains a variation class to avoid unnecessary lookups. diff --git a/src/wp-includes/global-styles-and-settings.php b/src/wp-includes/global-styles-and-settings.php index 0534d8b0dee58..ec124d1f61afc 100644 --- a/src/wp-includes/global-styles-and-settings.php +++ b/src/wp-includes/global-styles-and-settings.php @@ -91,6 +91,8 @@ function wp_get_global_settings( $path = array(), $context = array() ) { * to "var(--wp--preset--font-size--small)" so consumers don't have to. * @since 6.3.0 `transforms` is now usable in the `context` parameter. In case [`transforms`]['resolve_variables'] * is defined, variables are resolved to their value in the styles. + * @since 7.2.0 The merged styles are cached in the non-persistent `theme_json` group, + * matching wp_get_global_settings(). * * @param array $path Path to the specific style to retrieve. Optional. * If empty, will return all styles. @@ -122,11 +124,43 @@ function wp_get_global_styles( $path = array(), $context = array() ) { && is_array( $context['transforms'] ) && in_array( 'resolve-variables', $context['transforms'], true ); - $merged_data = WP_Theme_JSON_Resolver::get_merged_data( $origin ); - if ( $resolve_variables ) { - $merged_data = WP_Theme_JSON::resolve_variables( $merged_data ); + /* + * By using the 'theme_json' group, this data is marked to be non-persistent across requests. + * See `wp_cache_add_non_persistent_groups` in src/wp-includes/load.php and other places. + * + * The rationale is the same as for wp_get_global_settings(): derived theme.json data is + * always rebuilt for each request so that hooks using dynamic data are honored. + * + * The $origin and the resolve-variables transform are part of the cache key. Changes here + * need to account for clearing the cache appropriately in wp_clean_theme_json_cache(). + */ + $cache_group = 'theme_json'; + $cache_key = $resolve_variables + ? 'wp_get_global_styles_' . $origin . '_resolved' + : 'wp_get_global_styles_' . $origin; + + /* + * Ignore cache when the development mode is set to 'theme', so it doesn't interfere with the theme + * developer's workflow. + */ + $can_use_cached = ! wp_is_development_mode( 'theme' ); + + $styles = false; + if ( $can_use_cached ) { + $styles = wp_cache_get( $cache_key, $cache_group ); + } + + if ( false === $styles ) { + $merged_data = WP_Theme_JSON_Resolver::get_merged_data( $origin ); + if ( $resolve_variables ) { + $merged_data = WP_Theme_JSON::resolve_variables( $merged_data ); + } + $styles = $merged_data->get_raw_data()['styles'] ?? array(); + if ( $can_use_cached ) { + wp_cache_set( $cache_key, $styles, $cache_group ); + } } - $styles = $merged_data->get_raw_data()['styles']; + return _wp_array_get( $styles, $path, $styles ); } @@ -432,6 +466,10 @@ function wp_clean_theme_json_cache() { wp_cache_delete( 'wp_get_global_styles_svg_filters', 'theme_json' ); wp_cache_delete( 'wp_get_global_settings_custom', 'theme_json' ); wp_cache_delete( 'wp_get_global_settings_theme', 'theme_json' ); + wp_cache_delete( 'wp_get_global_styles_custom', 'theme_json' ); + wp_cache_delete( 'wp_get_global_styles_theme', 'theme_json' ); + wp_cache_delete( 'wp_get_global_styles_custom_resolved', 'theme_json' ); + wp_cache_delete( 'wp_get_global_styles_theme_resolved', 'theme_json' ); wp_cache_delete( 'wp_get_global_styles_custom_css', 'theme_json' ); wp_cache_delete( 'wp_get_theme_data_template_parts', 'theme_json' ); WP_Theme_JSON_Resolver::clean_cached_data(); diff --git a/tests/phpunit/tests/block-supports/layout.php b/tests/phpunit/tests/block-supports/layout.php index b196660de8ad3..7ad00f7cc364c 100644 --- a/tests/phpunit/tests/block-supports/layout.php +++ b/tests/phpunit/tests/block-supports/layout.php @@ -978,8 +978,6 @@ public function data_layout_classname_with_custom_blocks() { * * @ticket 64624 * @covers ::wp_render_layout_support_flag - * @runInSeparateProcess - * @preserveGlobalState disabled */ public function test_layout_support_flag_uses_variation_block_gap_value() { switch_theme( 'block-theme' );