From 0af082b26b3c3b0a880e7a76f92efb4fc729645e Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Wed, 7 Oct 2026 18:09:03 +0400 Subject: [PATCH 01/11] Editor: Cache the theme.json data merged from all origins. WP_Theme_JSON_Resolver::get_merged_data() merged the four origins on every call. Block style variations call it once per block carrying an is-style-* class, and the stylesheet, settings, duotone and REST callers add more merges per request. Cache the merged data per origin for the request, return a clone so callers can modify the result, discard it when new blocks are registered, and clear it in clean_cached_data(). --- .../class-wp-theme-json-resolver.php | 65 +++++- .../tests/theme/wpThemeJsonResolver.php | 194 ++++++++++++++++++ 2 files changed, 248 insertions(+), 11 deletions(-) diff --git a/src/wp-includes/class-wp-theme-json-resolver.php b/src/wp-includes/class-wp-theme-json-resolver.php index 05eedbcd40397..b2dcfa4d3e02e 100644 --- a/src/wp-includes/class-wp-theme-json-resolver.php +++ b/src/wp-includes/class-wp-theme-json-resolver.php @@ -24,6 +24,7 @@ class WP_Theme_JSON_Resolver { * Container for keep track of registered blocks. * * @since 6.1.0 + * @since 7.2.0 Added the `merged` entry. * @var array */ protected static $blocks_cache = array( @@ -31,8 +32,17 @@ class WP_Theme_JSON_Resolver { 'blocks' => array(), 'theme' => array(), 'user' => array(), + 'merged' => array(), ); + /** + * Merged data keyed by origin. + * + * @since 7.2.0 + * @var array + */ + protected static $merged = array(); + /** * Container for data coming from core. * @@ -192,9 +202,11 @@ public static function get_core_data() { * Checks whether the registered blocks were already processed for this origin. * * @since 6.1.0 + * @since 7.2.0 Added the 'merged' origin. * * @param string $origin Data source for which to cache the blocks. - * Valid values are 'core', 'blocks', 'theme', and 'user'. + * Valid values are 'core', 'blocks', 'theme', 'user', + * and 'merged'. * @return bool True on success, false otherwise. */ protected static function has_same_registered_blocks( $origin ) { @@ -638,6 +650,7 @@ public static function get_user_data() { * added the `$origin` parameter. * @since 6.1.0 Added block data and generation of spacingSizes array. * @since 6.2.0 Changed ' $origin' parameter values to 'default', 'blocks', 'theme' or 'custom'. + * @since 7.2.0 Caches the merged data per origin for the request and returns a copy of it. * * @param string $origin Optional. To what level should we merge data: 'default', 'blocks', 'theme' or 'custom'. * 'custom' is used as default value as well as fallback value if the origin is unknown. @@ -648,25 +661,58 @@ public static function get_merged_data( $origin = 'custom' ) { _deprecated_argument( __FUNCTION__, '5.9.0' ); } + if ( ! in_array( $origin, array( 'default', 'blocks', 'theme', 'custom' ), true ) ) { + $origin = 'custom'; + } + + /* + * Discard the merged data when blocks were registered after the last merge. + * + * This check detects new blocks, not theme support changes. Call + * clean_cached_data() after changing theme supports to refresh the merged data. + */ + if ( ! static::has_same_registered_blocks( 'merged' ) ) { + static::$merged = array(); + } + if ( isset( static::$merged[ $origin ] ) ) { + return clone static::$merged[ $origin ]; + } + $result = new WP_Theme_JSON(); $result->merge( static::get_core_data() ); if ( 'default' === $origin ) { - return $result; + return static::cache_merged_data( $origin, $result ); } $result->merge( static::get_block_data() ); if ( 'blocks' === $origin ) { - return $result; + return static::cache_merged_data( $origin, $result ); } $result->merge( static::get_theme_data() ); if ( 'theme' === $origin ) { - return $result; + return static::cache_merged_data( $origin, $result ); } $result->merge( static::get_user_data() ); + return static::cache_merged_data( $origin, $result ); + } + + /** + * Stores the merged data for an origin and returns a copy of it. + * + * Return a clone so callers can modify the result without changing the cache. + * + * @since 7.2.0 + * + * @param string $origin Origin the data was merged for. + * @param WP_Theme_JSON $theme_json Merged data. + * @return WP_Theme_JSON Copy of the merged data. + */ + protected static function cache_merged_data( $origin, $theme_json ) { + static::$merged[ $origin ] = $theme_json; - return $result; + return clone $theme_json; } /** @@ -733,16 +779,13 @@ protected static function get_file_path_from_theme( $file_name, $template = fals * and `$i18n_schema` variables to reset. * @since 6.1.0 Added the `$blocks` and `$blocks_cache` variables * to reset. + * @since 7.2.0 Added the `$merged` variable to reset. */ public static function clean_cached_data() { static::$core = null; static::$blocks = null; - static::$blocks_cache = array( - 'core' => array(), - 'blocks' => array(), - 'theme' => array(), - 'user' => array(), - ); + static::$blocks_cache = array_fill_keys( array_keys( static::$blocks_cache ), array() ); + static::$merged = array(); static::$theme = null; static::$user = null; static::$user_custom_post_type_id = null; diff --git a/tests/phpunit/tests/theme/wpThemeJsonResolver.php b/tests/phpunit/tests/theme/wpThemeJsonResolver.php index 50a240982715b..9b9547f230318 100644 --- a/tests/phpunit/tests/theme/wpThemeJsonResolver.php +++ b/tests/phpunit/tests/theme/wpThemeJsonResolver.php @@ -47,6 +47,13 @@ class Tests_Theme_wpThemeJsonResolver extends WP_UnitTestCase { */ private static $property_core_orig_value; + /** + * WP_Theme_JSON_Resolver::$merged property. + * + * @var ReflectionProperty + */ + private static $property_merged; + /** * Theme root directory. * @@ -82,6 +89,11 @@ public static function set_up_before_class() { static::$property_core->setAccessible( true ); } static::$property_core_orig_value = static::$property_core->getValue(); + + static::$property_merged = new ReflectionProperty( WP_Theme_JSON_Resolver::class, 'merged' ); + if ( PHP_VERSION_ID < 80100 ) { + static::$property_merged->setAccessible( true ); + } } public static function tear_down_after_class() { @@ -1527,4 +1539,186 @@ public function test_block_style_variation_merge_order() { $this->assertSameSetsWithIndex( $expected, $actual, 'Merged variation styles do not match.' ); } + + /** + * @covers WP_Theme_JSON_Resolver::get_merged_data + */ + public function test_get_merged_data_returns_a_separate_instance_per_call() { + $first = WP_Theme_JSON_Resolver::get_merged_data(); + $second = WP_Theme_JSON_Resolver::get_merged_data(); + + $this->assertNotSame( $first, $second, 'Each call should return its own instance.' ); + $this->assertSameSetsWithIndex( + $first->get_raw_data(), + $second->get_raw_data(), + 'Repeated calls should return equivalent data.' + ); + } + + /** + * @dataProvider data_merged_data_origins + * @covers WP_Theme_JSON_Resolver::get_merged_data + * + * @param string $origin Origin to request. + */ + public function test_get_merged_data_returns_the_memoized_data( $origin ) { + WP_Theme_JSON_Resolver::get_merged_data( $origin ); + + $sentinel = $this->replace_merged_data_with_sentinel( $origin ); + $result = WP_Theme_JSON_Resolver::get_merged_data( $origin ); + + $this->assertSame( + 'sentinel', + $result->get_raw_data()['styles']['color']['text'] ?? null, + 'The second call should return the data memoized by the first.' + ); + $this->assertNotSame( $sentinel, $result, 'A copy of the memoized data should be returned, not the memoized instance.' ); + } + + /** + * Callers are free to modify the returned data. WP_Theme_JSON::resolve_variables() + * does exactly that, so a modification must not be observable by the next caller. + * + * @covers WP_Theme_JSON_Resolver::get_merged_data + */ + public function test_get_merged_data_is_not_affected_by_modifying_the_result() { + // A style referencing a preset is injected because no bundled theme fixture has one that resolves. + $filter = static function ( $theme_json ) { + return $theme_json->update_with( + array( + 'version' => WP_Theme_JSON::LATEST_SCHEMA, + 'settings' => array( + 'typography' => array( + 'fontFamilies' => array( + array( + 'slug' => 'test-font', + 'name' => 'Test Font', + 'fontFamily' => 'Test Font, sans-serif', + ), + ), + ), + ), + 'styles' => array( + 'typography' => array( 'fontFamily' => 'var(--wp--preset--font-family--test-font)' ), + ), + ) + ); + }; + add_filter( 'wp_theme_json_data_theme', $filter ); + $path = array( 'typography', 'fontFamily' ); + + try { + $before = _wp_array_get( WP_Theme_JSON_Resolver::get_merged_data()->get_raw_data()['styles'], $path ); + $resolved = _wp_array_get( + WP_Theme_JSON::resolve_variables( WP_Theme_JSON_Resolver::get_merged_data() )->get_raw_data()['styles'], + $path + ); + $after = _wp_array_get( WP_Theme_JSON_Resolver::get_merged_data()->get_raw_data()['styles'], $path ); + } finally { + remove_filter( 'wp_theme_json_data_theme', $filter ); + } + + $this->assertSame( 'var(--wp--preset--font-family--test-font)', $before, 'The theme data should reference a preset.' ); + $this->assertSame( 'Test Font, sans-serif', $resolved, 'Expected the preset reference to resolve.' ); + $this->assertSame( $before, $after, 'Resolving variables should not affect subsequent calls.' ); + } + + /** + * @covers WP_Theme_JSON_Resolver::get_merged_data + */ + public function test_get_merged_data_reflects_blocks_registered_after_a_previous_call() { + // The first call records the registered blocks and stores the data; the second returns it. + WP_Theme_JSON_Resolver::get_merged_data(); + WP_Theme_JSON_Resolver::get_merged_data(); + + register_block_type( + 'test/block-gap', + array( + 'supports' => array( + '__experimentalStyle' => array( + 'spacing' => array( 'blockGap' => '77px' ), + ), + ), + ) + ); + + $styles = WP_Theme_JSON_Resolver::get_merged_data()->get_raw_data()['styles']; + + unregister_block_type( 'test/block-gap' ); + + $this->assertSame( + '77px', + $styles['blocks']['test/block-gap']['spacing']['blockGap'] ?? null, + 'Data for a block registered after a previous call should be present.' + ); + } + + /** + * @dataProvider data_merged_data_origins + * @covers WP_Theme_JSON_Resolver::clean_cached_data + * + * @param string $origin Origin to request. + */ + public function test_clean_cached_data_refreshes_merged_data( $origin ) { + WP_Theme_JSON_Resolver::get_merged_data( $origin ); + $this->replace_merged_data_with_sentinel( $origin ); + + WP_Theme_JSON_Resolver::clean_cached_data(); + + $this->assertNotSame( + 'sentinel', + WP_Theme_JSON_Resolver::get_merged_data( $origin )->get_raw_data()['styles']['color']['text'] ?? null, + 'Cleaning the cached data should discard the memoized merged data.' + ); + } + + /** + * Data provider. + * + * @return array[] + */ + public function data_merged_data_origins() { + return array( + 'default' => array( 'default' ), + 'blocks' => array( 'blocks' ), + 'theme' => array( 'theme' ), + 'custom' => array( 'custom' ), + ); + } + + /** + * @covers WP_Theme_JSON_Resolver::get_merged_data + */ + public function test_switching_theme_refreshes_merged_data() { + switch_theme( 'block-theme' ); + $before = wp_list_pluck( WP_Theme_JSON_Resolver::get_merged_data()->get_settings()['color']['palette']['theme'] ?? array(), 'slug' ); + + switch_theme( 'default' ); + $after = wp_list_pluck( WP_Theme_JSON_Resolver::get_merged_data()->get_settings()['color']['palette']['theme'] ?? array(), 'slug' ); + + $this->assertContains( 'light', $before, 'The block theme palette should be present while it is active.' ); + $this->assertNotContains( 'light', $after, 'Switching themes should refresh the merged data.' ); + } + + /** + * Replaces the memoized merged data for an origin with a recognizable instance. + * + * @param string $origin Origin to replace the data for. + * @return WP_Theme_JSON The instance now memoized for the origin. + */ + private function replace_merged_data_with_sentinel( $origin ) { + $sentinel = new WP_Theme_JSON( + array( + 'version' => WP_Theme_JSON::LATEST_SCHEMA, + 'styles' => array( 'color' => array( 'text' => 'sentinel' ) ), + ) + ); + + $merged = static::$property_merged->getValue(); + $this->assertArrayHasKey( $origin, $merged, 'The merged data should be stored for the origin before it is replaced.' ); + $merged[ $origin ] = $sentinel; + static::$property_merged->setValue( null, $merged ); + + return $sentinel; + } } From b1f380622cbfbbbd1fab4d83c014fe72e0442e45 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Wed, 7 Oct 2026 18:09:03 +0400 Subject: [PATCH 02/11] Editor: Remove the object cache from wp_get_global_settings(). The resolver now caches the merged data, so this cache saved one array read per call. Read through to the resolver on every call and drop the two cache keys from wp_clean_theme_json_cache(). --- .../global-styles-and-settings.php | 47 +-------------- .../tests/theme/wpGetGlobalSettings.php | 60 +++++++++++++++++++ 2 files changed, 63 insertions(+), 44 deletions(-) create mode 100644 tests/phpunit/tests/theme/wpGetGlobalSettings.php diff --git a/src/wp-includes/global-styles-and-settings.php b/src/wp-includes/global-styles-and-settings.php index 2315eef1ad5d5..8c9affa79a575 100644 --- a/src/wp-includes/global-styles-and-settings.php +++ b/src/wp-includes/global-styles-and-settings.php @@ -9,6 +9,7 @@ * Gets the settings resulting of merging core, theme, and user data. * * @since 5.9.0 + * @since 7.2.0 Removed the per-request cache; WP_Theme_JSON_Resolver::get_merged_data() caches the merged data. * * @param array $path Path to the specific setting to retrieve. Optional. * If empty, will return all settings. @@ -32,53 +33,13 @@ function wp_get_global_settings( $path = array(), $context = array() ) { $path = $new_path; } - /* - * This is the default value when no origin is provided or when it is 'all'. - * - * The $origin is used as part of the cache key. Changes here need to account - * for clearing the cache appropriately. - */ + // This is the default value when no origin is provided or when it is 'all'. $origin = 'custom'; if ( isset( $context['origin'] ) && 'base' === $context['origin'] ) { $origin = 'theme'; } - /* - * 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 for this is to make sure derived data from theme.json - * is always fresh from the potential modifications done via hooks - * that can use dynamic data (modify the stylesheet depending on some option, - * settings depending on user permissions, etc.). - * See some of the existing hooks to modify theme.json behavior: - * https://make.wordpress.org/core/2022/10/10/filters-for-theme-json-data/ - * - * A different alternative considered was to invalidate the cache upon certain - * events such as options add/update/delete, user meta, etc. - * It was judged not enough, hence this approach. - * See https://github.com/WordPress/gutenberg/pull/45372 - */ - $cache_group = 'theme_json'; - $cache_key = 'wp_get_global_settings_' . $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' ); - - $settings = false; - if ( $can_use_cached ) { - $settings = wp_cache_get( $cache_key, $cache_group ); - } - - if ( false === $settings ) { - $settings = WP_Theme_JSON_Resolver::get_merged_data( $origin )->get_settings(); - if ( $can_use_cached ) { - wp_cache_set( $cache_key, $settings, $cache_group ); - } - } + $settings = WP_Theme_JSON_Resolver::get_merged_data( $origin )->get_settings(); return _wp_array_get( $settings, $path, $settings ); } @@ -452,8 +413,6 @@ function wp_theme_has_theme_json() { function wp_clean_theme_json_cache() { wp_cache_delete( 'wp_get_global_stylesheet', 'theme_json' ); 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_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/theme/wpGetGlobalSettings.php b/tests/phpunit/tests/theme/wpGetGlobalSettings.php new file mode 100644 index 0000000000000..17bcbd5c3b803 --- /dev/null +++ b/tests/phpunit/tests/theme/wpGetGlobalSettings.php @@ -0,0 +1,60 @@ + array(), + 'wp_get_global_settings_theme' => array( 'origin' => 'base' ), + ); + + foreach ( $contexts as $context ) { + wp_get_global_settings( array(), $context ); + } + + foreach ( array_keys( $contexts ) as $cache_key ) { + $this->assertFalse( + wp_cache_get( $cache_key, 'theme_json' ), + "The merged settings should not be cached under $cache_key." + ); + } + + // Block registration adds no settings, so inject one through the theme data + // filter: registering a block refreshes the theme data, which reapplies the filter. + $filter = static function ( $theme_json ) { + return $theme_json->update_with( + array( + 'version' => WP_Theme_JSON::LATEST_SCHEMA, + 'settings' => array( + 'custom' => array( 'cacheProbe' => 'fresh' ), + ), + ) + ); + }; + add_filter( 'wp_theme_json_data_theme', $filter ); + register_block_type( 'test/block-settings' ); + + $settings = wp_get_global_settings(); + + unregister_block_type( 'test/block-settings' ); + remove_filter( 'wp_theme_json_data_theme', $filter ); + + $this->assertSame( + 'fresh', + $settings['custom']['cacheProbe'] ?? null, + 'Settings changed after a block registration should be present on the next call.' + ); + } +} From 75a6e42fff94d5b1a34acc66bfb88ef278042e8a Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Wed, 7 Oct 2026 18:34:01 +0400 Subject: [PATCH 03/11] Editor: Store and clone the merged data at one exit of get_merged_data(). Merge each origin behind a condition so the method stores the result once and clones once, matching the Gutenberg pull request. --- .../class-wp-theme-json-resolver.php | 37 +++++-------------- 1 file changed, 9 insertions(+), 28 deletions(-) diff --git a/src/wp-includes/class-wp-theme-json-resolver.php b/src/wp-includes/class-wp-theme-json-resolver.php index b2dcfa4d3e02e..c8129e1ebc864 100644 --- a/src/wp-includes/class-wp-theme-json-resolver.php +++ b/src/wp-includes/class-wp-theme-json-resolver.php @@ -680,39 +680,20 @@ public static function get_merged_data( $origin = 'custom' ) { $result = new WP_Theme_JSON(); $result->merge( static::get_core_data() ); - if ( 'default' === $origin ) { - return static::cache_merged_data( $origin, $result ); + if ( 'default' !== $origin ) { + $result->merge( static::get_block_data() ); } - - $result->merge( static::get_block_data() ); - if ( 'blocks' === $origin ) { - return static::cache_merged_data( $origin, $result ); + if ( 'theme' === $origin || 'custom' === $origin ) { + $result->merge( static::get_theme_data() ); } - - $result->merge( static::get_theme_data() ); - if ( 'theme' === $origin ) { - return static::cache_merged_data( $origin, $result ); + if ( 'custom' === $origin ) { + $result->merge( static::get_user_data() ); } - $result->merge( static::get_user_data() ); - return static::cache_merged_data( $origin, $result ); - } - - /** - * Stores the merged data for an origin and returns a copy of it. - * - * Return a clone so callers can modify the result without changing the cache. - * - * @since 7.2.0 - * - * @param string $origin Origin the data was merged for. - * @param WP_Theme_JSON $theme_json Merged data. - * @return WP_Theme_JSON Copy of the merged data. - */ - protected static function cache_merged_data( $origin, $theme_json ) { - static::$merged[ $origin ] = $theme_json; + static::$merged[ $origin ] = $result; - return clone $theme_json; + // Return a clone so callers can modify the result without changing the cache. + return clone $result; } /** From 6cbc2807a6124d2ae9f74d90f1650307ce2897d8 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Wed, 7 Oct 2026 19:22:44 +0400 Subject: [PATCH 04/11] Editor: Test the settings accessor's behavior, not the absence of the old cache keys. Match the Gutenberg test from WordPress/gutenberg#81979: the key assertions forbid one past implementation by name and fail only if those exact keys return. Keep the assertion that the accessor reflects resolver data refreshed after a previous call. --- .../tests/theme/wpGetGlobalSettings.php | 20 +++---------------- 1 file changed, 3 insertions(+), 17 deletions(-) diff --git a/tests/phpunit/tests/theme/wpGetGlobalSettings.php b/tests/phpunit/tests/theme/wpGetGlobalSettings.php index 17bcbd5c3b803..a5cc7e1ddad41 100644 --- a/tests/phpunit/tests/theme/wpGetGlobalSettings.php +++ b/tests/phpunit/tests/theme/wpGetGlobalSettings.php @@ -14,22 +14,8 @@ public function tear_down() { parent::tear_down(); } - public function test_reads_through_to_the_resolver() { - $contexts = array( - 'wp_get_global_settings_custom' => array(), - 'wp_get_global_settings_theme' => array( 'origin' => 'base' ), - ); - - foreach ( $contexts as $context ) { - wp_get_global_settings( array(), $context ); - } - - foreach ( array_keys( $contexts ) as $cache_key ) { - $this->assertFalse( - wp_cache_get( $cache_key, 'theme_json' ), - "The merged settings should not be cached under $cache_key." - ); - } + public function test_has_no_cache_in_front_of_the_resolver() { + wp_get_global_settings(); // Block registration adds no settings, so inject one through the theme data // filter: registering a block refreshes the theme data, which reapplies the filter. @@ -54,7 +40,7 @@ public function test_reads_through_to_the_resolver() { $this->assertSame( 'fresh', $settings['custom']['cacheProbe'] ?? null, - 'Settings changed after a block registration should be present on the next call.' + 'The accessor should read through to the resolver instead of caching its own copy.' ); } } From c68189c47203f99eb4c65873be3845e0eb500b6f Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Fri, 9 Oct 2026 15:07:59 +0400 Subject: [PATCH 05/11] Editor: Name the settings accessor test after the behavior it checks. Match the test name and assertion message from WordPress/gutenberg#81979. The Gutenberg suite defines WP_DEBUG, which bypassed the plugin's removed cache, so the test there cannot show that cache's absence. Core's removed cache was bypassed only in theme development mode, so this test still fails against it. --- tests/phpunit/tests/theme/wpGetGlobalSettings.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/phpunit/tests/theme/wpGetGlobalSettings.php b/tests/phpunit/tests/theme/wpGetGlobalSettings.php index a5cc7e1ddad41..23b8376ef34cd 100644 --- a/tests/phpunit/tests/theme/wpGetGlobalSettings.php +++ b/tests/phpunit/tests/theme/wpGetGlobalSettings.php @@ -14,7 +14,7 @@ public function tear_down() { parent::tear_down(); } - public function test_has_no_cache_in_front_of_the_resolver() { + public function test_reflects_theme_data_changed_after_a_previous_call() { wp_get_global_settings(); // Block registration adds no settings, so inject one through the theme data @@ -40,7 +40,7 @@ public function test_has_no_cache_in_front_of_the_resolver() { $this->assertSame( 'fresh', $settings['custom']['cacheProbe'] ?? null, - 'The accessor should read through to the resolver instead of caching its own copy.' + 'Settings from theme data changed after a previous call should be present.' ); } } From ef87252d00a827846bb207f4d1933ec453613b19 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Wed, 14 Oct 2026 16:14:04 +0400 Subject: [PATCH 06/11] Editor: Add the ticket annotation to the merged data cache tests. Tag the tests added for the resolver cache and the settings accessor with Trac ticket 66280. --- tests/phpunit/tests/theme/wpGetGlobalSettings.php | 3 +++ tests/phpunit/tests/theme/wpThemeJsonResolver.php | 6 ++++++ 2 files changed, 9 insertions(+) diff --git a/tests/phpunit/tests/theme/wpGetGlobalSettings.php b/tests/phpunit/tests/theme/wpGetGlobalSettings.php index 23b8376ef34cd..510de5a7fca17 100644 --- a/tests/phpunit/tests/theme/wpGetGlobalSettings.php +++ b/tests/phpunit/tests/theme/wpGetGlobalSettings.php @@ -14,6 +14,9 @@ public function tear_down() { parent::tear_down(); } + /** + * @ticket 66280 + */ public function test_reflects_theme_data_changed_after_a_previous_call() { wp_get_global_settings(); diff --git a/tests/phpunit/tests/theme/wpThemeJsonResolver.php b/tests/phpunit/tests/theme/wpThemeJsonResolver.php index 9b9547f230318..199aa954f5c2d 100644 --- a/tests/phpunit/tests/theme/wpThemeJsonResolver.php +++ b/tests/phpunit/tests/theme/wpThemeJsonResolver.php @@ -1541,6 +1541,7 @@ public function test_block_style_variation_merge_order() { } /** + * @ticket 66280 * @covers WP_Theme_JSON_Resolver::get_merged_data */ public function test_get_merged_data_returns_a_separate_instance_per_call() { @@ -1556,6 +1557,7 @@ public function test_get_merged_data_returns_a_separate_instance_per_call() { } /** + * @ticket 66280 * @dataProvider data_merged_data_origins * @covers WP_Theme_JSON_Resolver::get_merged_data * @@ -1579,6 +1581,7 @@ public function test_get_merged_data_returns_the_memoized_data( $origin ) { * Callers are free to modify the returned data. WP_Theme_JSON::resolve_variables() * does exactly that, so a modification must not be observable by the next caller. * + * @ticket 66280 * @covers WP_Theme_JSON_Resolver::get_merged_data */ public function test_get_merged_data_is_not_affected_by_modifying_the_result() { @@ -1624,6 +1627,7 @@ public function test_get_merged_data_is_not_affected_by_modifying_the_result() { } /** + * @ticket 66280 * @covers WP_Theme_JSON_Resolver::get_merged_data */ public function test_get_merged_data_reflects_blocks_registered_after_a_previous_call() { @@ -1654,6 +1658,7 @@ public function test_get_merged_data_reflects_blocks_registered_after_a_previous } /** + * @ticket 66280 * @dataProvider data_merged_data_origins * @covers WP_Theme_JSON_Resolver::clean_cached_data * @@ -1687,6 +1692,7 @@ public function data_merged_data_origins() { } /** + * @ticket 66280 * @covers WP_Theme_JSON_Resolver::get_merged_data */ public function test_switching_theme_refreshes_merged_data() { From 2df838cfc04ff9c151998187df790029b5c68191 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Wed, 14 Oct 2026 16:13:33 +0400 Subject: [PATCH 07/11] Editor: Test that wp_get_global_styles() reflects late block registration. Port the styles accessor test from WordPress/gutenberg#81979. The accessor reads the resolver's current data, so a block registered after a previous call appears in the next result. --- .../phpunit/tests/theme/wpGetGlobalStyles.php | 44 +++++++++++++++++++ 1 file changed, 44 insertions(+) create mode 100644 tests/phpunit/tests/theme/wpGetGlobalStyles.php diff --git a/tests/phpunit/tests/theme/wpGetGlobalStyles.php b/tests/phpunit/tests/theme/wpGetGlobalStyles.php new file mode 100644 index 0000000000000..31ffd626877cf --- /dev/null +++ b/tests/phpunit/tests/theme/wpGetGlobalStyles.php @@ -0,0 +1,44 @@ + array( + '__experimentalStyle' => array( + 'spacing' => array( 'blockGap' => '77px' ), + ), + ), + ) + ); + + $styles = wp_get_global_styles(); + + unregister_block_type( 'test/block-gap' ); + + $this->assertSame( + '77px', + $styles['blocks']['test/block-gap']['spacing']['blockGap'] ?? null, + 'Styles for a block registered after a previous call should be present.' + ); + } +} From 1458bb3a1521bc1b0e50c2b7f0592aa37310b4a6 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Wed, 14 Oct 2026 16:12:57 +0400 Subject: [PATCH 08/11] Editor: Read global styles per layout block instead of once per request. Remove the static that held the first wp_get_global_styles() result in wp_render_layout_support_flag(). The resolver now caches the merged data, so each read is a cached lookup, and a block registered after the first layout render is reflected in later renders. --- src/wp-includes/block-supports/layout.php | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/src/wp-includes/block-supports/layout.php b/src/wp-includes/block-supports/layout.php index 0162ae8d5a047..dc3b56c0171c5 100644 --- a/src/wp-includes/block-supports/layout.php +++ b/src/wp-includes/block-supports/layout.php @@ -1022,8 +1022,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(); @@ -1241,10 +1239,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. From 2ffbb7a7270d4a346d134e3dd6a145302a528e63 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Wed, 14 Oct 2026 16:27:50 +0400 Subject: [PATCH 09/11] Editor: Declare types on the merged data cache tests. Add native parameter and return types to the tests, the data provider, the sentinel helper and the filter closures this change adds, and type the provider's rows in its docblock. Make the data provider static, as the other providers in this directory are. --- .../tests/theme/wpGetGlobalSettings.php | 6 +++--- .../phpunit/tests/theme/wpGetGlobalStyles.php | 4 ++-- .../tests/theme/wpThemeJsonResolver.php | 20 +++++++++---------- 3 files changed, 15 insertions(+), 15 deletions(-) diff --git a/tests/phpunit/tests/theme/wpGetGlobalSettings.php b/tests/phpunit/tests/theme/wpGetGlobalSettings.php index 510de5a7fca17..caa54c8be888e 100644 --- a/tests/phpunit/tests/theme/wpGetGlobalSettings.php +++ b/tests/phpunit/tests/theme/wpGetGlobalSettings.php @@ -9,7 +9,7 @@ */ class Tests_Theme_wpGetGlobalSettings extends WP_UnitTestCase { - public function tear_down() { + public function tear_down(): void { wp_clean_theme_json_cache(); parent::tear_down(); } @@ -17,12 +17,12 @@ public function tear_down() { /** * @ticket 66280 */ - public function test_reflects_theme_data_changed_after_a_previous_call() { + public function test_reflects_theme_data_changed_after_a_previous_call(): void { wp_get_global_settings(); // Block registration adds no settings, so inject one through the theme data // filter: registering a block refreshes the theme data, which reapplies the filter. - $filter = static function ( $theme_json ) { + $filter = static function ( WP_Theme_JSON_Data $theme_json ): WP_Theme_JSON_Data { return $theme_json->update_with( array( 'version' => WP_Theme_JSON::LATEST_SCHEMA, diff --git a/tests/phpunit/tests/theme/wpGetGlobalStyles.php b/tests/phpunit/tests/theme/wpGetGlobalStyles.php index 31ffd626877cf..bcf4bbd43dc21 100644 --- a/tests/phpunit/tests/theme/wpGetGlobalStyles.php +++ b/tests/phpunit/tests/theme/wpGetGlobalStyles.php @@ -9,7 +9,7 @@ */ class Tests_Theme_wpGetGlobalStyles extends WP_UnitTestCase { - public function tear_down() { + public function tear_down(): void { wp_clean_theme_json_cache(); parent::tear_down(); } @@ -17,7 +17,7 @@ public function tear_down() { /** * @ticket 66280 */ - public function test_reflects_blocks_registered_after_a_previous_call() { + public function test_reflects_blocks_registered_after_a_previous_call(): void { wp_get_global_styles(); register_block_type( diff --git a/tests/phpunit/tests/theme/wpThemeJsonResolver.php b/tests/phpunit/tests/theme/wpThemeJsonResolver.php index 199aa954f5c2d..ae19a03ccea07 100644 --- a/tests/phpunit/tests/theme/wpThemeJsonResolver.php +++ b/tests/phpunit/tests/theme/wpThemeJsonResolver.php @@ -1544,7 +1544,7 @@ public function test_block_style_variation_merge_order() { * @ticket 66280 * @covers WP_Theme_JSON_Resolver::get_merged_data */ - public function test_get_merged_data_returns_a_separate_instance_per_call() { + public function test_get_merged_data_returns_a_separate_instance_per_call(): void { $first = WP_Theme_JSON_Resolver::get_merged_data(); $second = WP_Theme_JSON_Resolver::get_merged_data(); @@ -1563,7 +1563,7 @@ public function test_get_merged_data_returns_a_separate_instance_per_call() { * * @param string $origin Origin to request. */ - public function test_get_merged_data_returns_the_memoized_data( $origin ) { + public function test_get_merged_data_returns_the_memoized_data( string $origin ): void { WP_Theme_JSON_Resolver::get_merged_data( $origin ); $sentinel = $this->replace_merged_data_with_sentinel( $origin ); @@ -1584,9 +1584,9 @@ public function test_get_merged_data_returns_the_memoized_data( $origin ) { * @ticket 66280 * @covers WP_Theme_JSON_Resolver::get_merged_data */ - public function test_get_merged_data_is_not_affected_by_modifying_the_result() { + public function test_get_merged_data_is_not_affected_by_modifying_the_result(): void { // A style referencing a preset is injected because no bundled theme fixture has one that resolves. - $filter = static function ( $theme_json ) { + $filter = static function ( WP_Theme_JSON_Data $theme_json ): WP_Theme_JSON_Data { return $theme_json->update_with( array( 'version' => WP_Theme_JSON::LATEST_SCHEMA, @@ -1630,7 +1630,7 @@ public function test_get_merged_data_is_not_affected_by_modifying_the_result() { * @ticket 66280 * @covers WP_Theme_JSON_Resolver::get_merged_data */ - public function test_get_merged_data_reflects_blocks_registered_after_a_previous_call() { + public function test_get_merged_data_reflects_blocks_registered_after_a_previous_call(): void { // The first call records the registered blocks and stores the data; the second returns it. WP_Theme_JSON_Resolver::get_merged_data(); WP_Theme_JSON_Resolver::get_merged_data(); @@ -1664,7 +1664,7 @@ public function test_get_merged_data_reflects_blocks_registered_after_a_previous * * @param string $origin Origin to request. */ - public function test_clean_cached_data_refreshes_merged_data( $origin ) { + public function test_clean_cached_data_refreshes_merged_data( string $origin ): void { WP_Theme_JSON_Resolver::get_merged_data( $origin ); $this->replace_merged_data_with_sentinel( $origin ); @@ -1680,9 +1680,9 @@ public function test_clean_cached_data_refreshes_merged_data( $origin ) { /** * Data provider. * - * @return array[] + * @return array */ - public function data_merged_data_origins() { + public static function data_merged_data_origins(): array { return array( 'default' => array( 'default' ), 'blocks' => array( 'blocks' ), @@ -1695,7 +1695,7 @@ public function data_merged_data_origins() { * @ticket 66280 * @covers WP_Theme_JSON_Resolver::get_merged_data */ - public function test_switching_theme_refreshes_merged_data() { + public function test_switching_theme_refreshes_merged_data(): void { switch_theme( 'block-theme' ); $before = wp_list_pluck( WP_Theme_JSON_Resolver::get_merged_data()->get_settings()['color']['palette']['theme'] ?? array(), 'slug' ); @@ -1712,7 +1712,7 @@ public function test_switching_theme_refreshes_merged_data() { * @param string $origin Origin to replace the data for. * @return WP_Theme_JSON The instance now memoized for the origin. */ - private function replace_merged_data_with_sentinel( $origin ) { + private function replace_merged_data_with_sentinel( string $origin ): WP_Theme_JSON { $sentinel = new WP_Theme_JSON( array( 'version' => WP_Theme_JSON::LATEST_SCHEMA, From cde8b56d9631462e8d09e110be9144fcbab91c3c Mon Sep 17 00:00:00 2001 From: Peter Wilson Date: Thu, 15 Oct 2026 14:28:52 +1100 Subject: [PATCH 10/11] Remove unneeded tear downs. --- .../phpunit/tests/theme/wpGetGlobalSettings.php | 1 - .../phpunit/tests/theme/wpThemeJsonResolver.php | 16 ++++++---------- 2 files changed, 6 insertions(+), 11 deletions(-) diff --git a/tests/phpunit/tests/theme/wpGetGlobalSettings.php b/tests/phpunit/tests/theme/wpGetGlobalSettings.php index caa54c8be888e..2fa10abd3dd89 100644 --- a/tests/phpunit/tests/theme/wpGetGlobalSettings.php +++ b/tests/phpunit/tests/theme/wpGetGlobalSettings.php @@ -38,7 +38,6 @@ public function test_reflects_theme_data_changed_after_a_previous_call(): void { $settings = wp_get_global_settings(); unregister_block_type( 'test/block-settings' ); - remove_filter( 'wp_theme_json_data_theme', $filter ); $this->assertSame( 'fresh', diff --git a/tests/phpunit/tests/theme/wpThemeJsonResolver.php b/tests/phpunit/tests/theme/wpThemeJsonResolver.php index ae19a03ccea07..02af5b51c00d0 100644 --- a/tests/phpunit/tests/theme/wpThemeJsonResolver.php +++ b/tests/phpunit/tests/theme/wpThemeJsonResolver.php @@ -1610,16 +1610,12 @@ public function test_get_merged_data_is_not_affected_by_modifying_the_result(): add_filter( 'wp_theme_json_data_theme', $filter ); $path = array( 'typography', 'fontFamily' ); - try { - $before = _wp_array_get( WP_Theme_JSON_Resolver::get_merged_data()->get_raw_data()['styles'], $path ); - $resolved = _wp_array_get( - WP_Theme_JSON::resolve_variables( WP_Theme_JSON_Resolver::get_merged_data() )->get_raw_data()['styles'], - $path - ); - $after = _wp_array_get( WP_Theme_JSON_Resolver::get_merged_data()->get_raw_data()['styles'], $path ); - } finally { - remove_filter( 'wp_theme_json_data_theme', $filter ); - } + $before = _wp_array_get( WP_Theme_JSON_Resolver::get_merged_data()->get_raw_data()['styles'], $path ); + $resolved = _wp_array_get( + WP_Theme_JSON::resolve_variables( WP_Theme_JSON_Resolver::get_merged_data() )->get_raw_data()['styles'], + $path + ); + $after = _wp_array_get( WP_Theme_JSON_Resolver::get_merged_data()->get_raw_data()['styles'], $path ); $this->assertSame( 'var(--wp--preset--font-family--test-font)', $before, 'The theme data should reference a preset.' ); $this->assertSame( 'Test Font, sans-serif', $resolved, 'Expected the preset reference to resolve.' ); From be84046a69ae37dc32df565e8d227ff1f5ddd83f Mon Sep 17 00:00:00 2001 From: Peter Wilson Date: Thu, 15 Oct 2026 14:30:14 +1100 Subject: [PATCH 11/11] Comment explaining needed tear down. --- tests/phpunit/tests/theme/wpGetGlobalSettings.php | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/phpunit/tests/theme/wpGetGlobalSettings.php b/tests/phpunit/tests/theme/wpGetGlobalSettings.php index 2fa10abd3dd89..648eba198297c 100644 --- a/tests/phpunit/tests/theme/wpGetGlobalSettings.php +++ b/tests/phpunit/tests/theme/wpGetGlobalSettings.php @@ -10,6 +10,7 @@ class Tests_Theme_wpGetGlobalSettings extends WP_UnitTestCase { public function tear_down(): void { + // Required to reset properties in WP_Theme_JSON_Resolver. wp_clean_theme_json_cache(); parent::tear_down(); }