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. diff --git a/src/wp-includes/class-wp-theme-json-resolver.php b/src/wp-includes/class-wp-theme-json-resolver.php index 05eedbcd40397..c8129e1ebc864 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,39 @@ public static function get_merged_data( $origin = 'custom' ) { _deprecated_argument( __FUNCTION__, '5.9.0' ); } - $result = new WP_Theme_JSON(); - $result->merge( static::get_core_data() ); - if ( 'default' === $origin ) { - return $result; + if ( ! in_array( $origin, array( 'default', 'blocks', 'theme', 'custom' ), true ) ) { + $origin = 'custom'; } - $result->merge( static::get_block_data() ); - if ( 'blocks' === $origin ) { - return $result; + /* + * 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->merge( static::get_theme_data() ); - if ( 'theme' === $origin ) { - return $result; + $result = new WP_Theme_JSON(); + $result->merge( static::get_core_data() ); + if ( 'default' !== $origin ) { + $result->merge( static::get_block_data() ); + } + if ( 'theme' === $origin || 'custom' === $origin ) { + $result->merge( static::get_theme_data() ); + } + if ( 'custom' === $origin ) { + $result->merge( static::get_user_data() ); } - $result->merge( static::get_user_data() ); + static::$merged[ $origin ] = $result; - return $result; + // Return a clone so callers can modify the result without changing the cache. + return clone $result; } /** @@ -733,16 +760,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/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..648eba198297c --- /dev/null +++ b/tests/phpunit/tests/theme/wpGetGlobalSettings.php @@ -0,0 +1,49 @@ +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' ); + + $this->assertSame( + 'fresh', + $settings['custom']['cacheProbe'] ?? null, + 'Settings from theme data changed after a previous call should be present.' + ); + } +} diff --git a/tests/phpunit/tests/theme/wpGetGlobalStyles.php b/tests/phpunit/tests/theme/wpGetGlobalStyles.php new file mode 100644 index 0000000000000..bcf4bbd43dc21 --- /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.' + ); + } +} diff --git a/tests/phpunit/tests/theme/wpThemeJsonResolver.php b/tests/phpunit/tests/theme/wpThemeJsonResolver.php index 50a240982715b..02af5b51c00d0 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,188 @@ public function test_block_style_variation_merge_order() { $this->assertSameSetsWithIndex( $expected, $actual, 'Merged variation styles do not match.' ); } + + /** + * @ticket 66280 + * @covers WP_Theme_JSON_Resolver::get_merged_data + */ + 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(); + + $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.' + ); + } + + /** + * @ticket 66280 + * @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( string $origin ): void { + 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. + * + * @ticket 66280 + * @covers WP_Theme_JSON_Resolver::get_merged_data + */ + 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 ( WP_Theme_JSON_Data $theme_json ): WP_Theme_JSON_Data { + 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' ); + + $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.' ); + $this->assertSame( $before, $after, 'Resolving variables should not affect subsequent calls.' ); + } + + /** + * @ticket 66280 + * @covers WP_Theme_JSON_Resolver::get_merged_data + */ + 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(); + + 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.' + ); + } + + /** + * @ticket 66280 + * @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( string $origin ): void { + 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 static function data_merged_data_origins(): array { + return array( + 'default' => array( 'default' ), + 'blocks' => array( 'blocks' ), + 'theme' => array( 'theme' ), + 'custom' => array( 'custom' ), + ); + } + + /** + * @ticket 66280 + * @covers WP_Theme_JSON_Resolver::get_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' ); + + 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( string $origin ): WP_Theme_JSON { + $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; + } }