From 94309d12552f5ba17b6da8a27a034a958d491444 Mon Sep 17 00:00:00 2001 From: Tonya Mork Date: Tue, 11 Oct 2022 17:15:11 +0000 Subject: [PATCH] Editor: Fix performance regression in WP_Theme_JSON_Resolver. A significant performance regression was added late in WP 6.1 beta cycle when some of the existing caching for `theme.json` processing was removed. The rationale for removing the caching was this code was now used before all the blocks are registered (aka get template data, etc.) and resulted in stale cache that created issues (see [https://github.com/WordPress/gutenberg/issues/44434 Gutenberg Issue 44434] and [https://github.com/WordPress/gutenberg/issues/44619 Gutenberg Issue 44619]). The changes were limited to only reads from the file system. However, it introduced a big impact in performance. This commit adds caching and checks for blocks with different origins. How? It add caching for the calculated data for core, theme, and user based on the blocks that are registered. If the blocks haven't changed since the last time they were calculated for the origin, the cached data is returned. Otherwise, the data is recalculated and cached. Essentially, this brings back the previous cache, but refreshing it when the blocks change. It partially adds unit tests for these changes. Additional tests will be added. References: * [https://github.com/WordPress/gutenberg/issues/44772 Performance regression in WP 6.1 for theme.json processing] Follow-up to [54251], [54399]. Props aristath, oandregal, bernhard-reiter, spacedmonkey, hellofromTonya. See #56467. git-svn-id: https://develop.svn.wordpress.org/trunk@54493 602fd350-edb4-49c9-b593-d223f7449a82 --- .../class-wp-theme-json-resolver.php | 106 ++++++-- .../tests/theme/wpThemeJsonResolver.php | 241 ++++++++++++++++++ 2 files changed, 332 insertions(+), 15 deletions(-) diff --git a/src/wp-includes/class-wp-theme-json-resolver.php b/src/wp-includes/class-wp-theme-json-resolver.php index b1a0d5198d67b..6b2695fe481d4 100644 --- a/src/wp-includes/class-wp-theme-json-resolver.php +++ b/src/wp-includes/class-wp-theme-json-resolver.php @@ -20,6 +20,19 @@ #[AllowDynamicProperties] class WP_Theme_JSON_Resolver { + /** + * Container for keep track of registered blocks. + * + * @since 6.1.0 + * @var array + */ + protected static $blocks_cache = array( + 'core' => array(), + 'blocks' => array(), + 'theme' => array(), + 'user' => array(), + ); + /** * Container for data coming from core. * @@ -28,6 +41,14 @@ class WP_Theme_JSON_Resolver { */ protected static $core = null; + /** + * Container for data coming from the blocks. + * + * @since 6.1.0 + * @var WP_Theme_JSON + */ + protected static $blocks = null; + /** * Container for data coming from the theme. * @@ -145,6 +166,10 @@ protected static function translate( $theme_json, $domain = 'default' ) { * @return WP_Theme_JSON Entity that holds core data. */ public static function get_core_data() { + if ( null !== static::$core && static::has_same_registered_blocks( 'core' ) ) { + return static::$core; + } + $config = static::read_json_file( __DIR__ . '/theme.json' ); $config = static::translate( $config ); @@ -162,6 +187,37 @@ public static function get_core_data() { return static::$core; } + /** + * Checks whether the registered blocks were already processed for this origin. + * + * @since 6.1.0 + * + * @param string $origin Data source for which to cache the blocks. + * Valid values are 'core', 'blocks', 'theme', and 'user'. + * @return bool True on success, false otherwise. + */ + protected static function has_same_registered_blocks( $origin ) { + // Bail out if the origin is invalid. + if ( ! isset( static::$blocks_cache[ $origin ] ) ) { + return false; + } + + $registry = WP_Block_Type_Registry::get_instance(); + $blocks = $registry->get_all_registered(); + + // Is there metadata for all currently registered blocks? + $block_diff = array_diff_key( $blocks, static::$blocks_cache[ $origin ] ); + if ( empty( $block_diff ) ) { + return true; + } + + foreach ( $blocks as $block_name => $block_type ) { + static::$blocks_cache[ $origin ][ $block_name ] = true; + } + + return false; + } + /** * Returns the theme's data. * @@ -189,19 +245,21 @@ public static function get_theme_data( $deprecated = array(), $options = array() $options = wp_parse_args( $options, array( 'with_supports' => true ) ); - $theme_json_data = static::read_json_file( static::get_file_path_from_theme( 'theme.json' ) ); - $theme_json_data = static::translate( $theme_json_data, wp_get_theme()->get( 'TextDomain' ) ); + if ( null === static::$theme || ! static::has_same_registered_blocks( 'theme' ) ) { + $theme_json_data = static::read_json_file( static::get_file_path_from_theme( 'theme.json' ) ); + $theme_json_data = static::translate( $theme_json_data, wp_get_theme()->get( 'TextDomain' ) ); - /** - * Filters the data provided by the theme for global styles and settings. - * - * @since 6.1.0 - * - * @param WP_Theme_JSON_Data Class to access and update the underlying data. - */ - $theme_json = apply_filters( 'theme_json_theme', new WP_Theme_JSON_Data( $theme_json_data, 'theme' ) ); - $theme_json_data = $theme_json->get_data(); - static::$theme = new WP_Theme_JSON( $theme_json_data ); + /** + * Filters the data provided by the theme for global styles and settings. + * + * @since 6.1.0 + * + * @param WP_Theme_JSON_Data Class to access and update the underlying data. + */ + $theme_json = apply_filters( 'theme_json_theme', new WP_Theme_JSON_Data( $theme_json_data, 'theme' ) ); + $theme_json_data = $theme_json->get_data(); + static::$theme = new WP_Theme_JSON( $theme_json_data ); + } if ( wp_get_theme()->parent() ) { // Get parent theme.json. @@ -258,7 +316,6 @@ public static function get_theme_data( $deprecated = array(), $options = array() } $with_theme_supports = new WP_Theme_JSON( $theme_support_data ); $with_theme_supports->merge( static::$theme ); - return $with_theme_supports; } @@ -272,7 +329,12 @@ public static function get_theme_data( $deprecated = array(), $options = array() public static function get_block_data() { $registry = WP_Block_Type_Registry::get_instance(); $blocks = $registry->get_all_registered(); - $config = array( 'version' => 2 ); + + if ( null !== static::$blocks && static::has_same_registered_blocks( 'blocks' ) ) { + return static::$blocks; + } + + $config = array( 'version' => 2 ); foreach ( $blocks as $block_name => $block_type ) { if ( isset( $block_type->supports['__experimentalStyle'] ) ) { $config['styles']['blocks'][ $block_name ] = static::remove_json_comments( $block_type->supports['__experimentalStyle'] ); @@ -298,7 +360,8 @@ public static function get_block_data() { $theme_json = apply_filters( 'theme_json_blocks', new WP_Theme_JSON_Data( $config, 'blocks' ) ); $config = $theme_json->get_data(); - return new WP_Theme_JSON( $config, 'blocks' ); + static::$blocks = new WP_Theme_JSON( $config, 'blocks' ); + return static::$blocks; } /** @@ -407,6 +470,10 @@ public static function get_user_data_from_wp_global_styles( $theme, $create_post * @return WP_Theme_JSON Entity that holds styles for user data. */ public static function get_user_data() { + if ( null !== static::$user && static::has_same_registered_blocks( 'user' ) ) { + return static::$user; + } + $config = array(); $user_cpt = static::get_user_data_from_wp_global_styles( wp_get_theme() ); @@ -562,9 +629,18 @@ protected static function get_file_path_from_theme( $file_name, $template = fals * @since 5.8.0 * @since 5.9.0 Added the `$user`, `$user_custom_post_type_id`, * and `$i18n_schema` variables to reset. + * @since 6.1.0 Added the `$blocks` and `$blocks_cache` variables + * 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::$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 9b43a4fb33eb1..bbfffde317b31 100644 --- a/tests/phpunit/tests/theme/wpThemeJsonResolver.php +++ b/tests/phpunit/tests/theme/wpThemeJsonResolver.php @@ -33,6 +33,52 @@ class Tests_Theme_wpThemeJsonResolver extends WP_UnitTestCase { */ private $queries = array(); + /** + * WP_Theme_JSON_Resolver::$blocks_cache property. + * + * @var ReflectionProperty + */ + private static $property_blocks_cache; + + /** + * Original value of the WP_Theme_JSON_Resolver::$blocks_cache property. + * + * @var array + */ + private static $property_blocks_cache_orig_value; + + /** + * WP_Theme_JSON_Resolver::$core property. + * + * @var ReflectionProperty + */ + private static $property_core; + + /** + * Original value of the WP_Theme_JSON_Resolver::$core property. + * + * @var WP_Theme_JSON + */ + private static $property_core_orig_value; + + public static function set_up_before_class() { + parent::set_up_before_class(); + + static::$property_blocks_cache = new ReflectionProperty( WP_Theme_JSON_Resolver::class, 'blocks_cache' ); + static::$property_blocks_cache->setAccessible( true ); + static::$property_blocks_cache_orig_value = static::$property_blocks_cache->getValue(); + + static::$property_core = new ReflectionProperty( WP_Theme_JSON_Resolver::class, 'core' ); + static::$property_core->setAccessible( true ); + static::$property_core_orig_value = static::$property_core->getValue(); + } + + public static function tear_down_after_class() { + static::$property_blocks_cache->setValue( WP_Theme_JSON_Resolver::class, static::$property_blocks_cache_orig_value ); + static::$property_core->setValue( WP_Theme_JSON_Resolver::class, static::$property_core_orig_value ); + parent::tear_down_after_class(); + } + public function set_up() { parent::set_up(); $this->theme_root = realpath( DIR_TESTDATA . '/themedir1' ); @@ -55,6 +101,9 @@ public function tear_down() { $GLOBALS['wp_theme_directories'] = $this->orig_theme_dir; wp_clean_themes_cache(); unset( $GLOBALS['wp_themes'] ); + + // Reset data between tests. + WP_Theme_JSON_Resolver::clean_cached_data(); parent::tear_down(); } @@ -190,6 +239,198 @@ public function test_translations_are_applied() { ); } + private function get_registered_block_names( $hard_reset = false ) { + static $expected_block_names; + + if ( ! $hard_reset && ! empty( $expected_block_names ) ) { + return $expected_block_names; + } + + $expected_block_names = array(); + $resolver = WP_Block_Type_Registry::get_instance(); + $blocks = $resolver->get_all_registered(); + foreach ( array_keys( $blocks ) as $block_name ) { + $expected_block_names[ $block_name ] = true; + } + + return $expected_block_names; + } + + /** + * Tests when WP_Theme_JSON_Resolver::$blocks_cache is empty or does not match + * the all registered blocks. + * + * Though this is a non-public method, it is vital to other functionality. + * Therefore, tests are provided to validate it functions as expected. + * + * @dataProvider data_has_same_registered_blocks_when_all_blocks_not_cached + * @ticket 56467 + * + * @param string $origin The origin to test. + */ + public function test_has_same_registered_blocks_when_all_blocks_not_cached( $origin, array $cache = array() ) { + $has_same_registered_blocks = new ReflectionMethod( WP_Theme_JSON_Resolver::class, 'has_same_registered_blocks' ); + $has_same_registered_blocks->setAccessible( true ); + $expected_cache = $this->get_registered_block_names(); + + // Set up the blocks cache for the origin. + $blocks_cache = static::$property_blocks_cache->getValue(); + $blocks_cache[ $origin ] = $cache; + static::$property_blocks_cache->setValue( null, $blocks_cache ); + + $this->assertFalse( $has_same_registered_blocks->invoke( null, $origin ), 'WP_Theme_JSON_Resolver::has_same_registered_blocks() should return false when same blocks are not cached' ); + $blocks_cache = static::$property_blocks_cache->getValue(); + $this->assertSameSets( $expected_cache, $blocks_cache[ $origin ], 'WP_Theme_JSON_Resolver::$blocks_cache should contain all expected block names for the given origin' ); + } + + /** + * Data provider. + * + * @return array + */ + public function data_has_same_registered_blocks_when_all_blocks_not_cached() { + return array( + 'origin: core; cache: empty' => array( + 'origin' => 'core', + ), + 'origin: blocks; cache: empty' => array( + 'origin' => 'blocks', + ), + 'origin: theme; cache: empty' => array( + 'origin' => 'theme', + ), + 'origin: user; cache: empty' => array( + 'origin' => 'user', + ), + 'origin: core; cache: not empty' => array( + 'origin' => 'core', + 'cache' => array( + 'core/block' => true, + ), + ), + 'origin: blocks; cache: not empty' => array( + 'origin' => 'blocks', + 'cache' => array( + 'core/block' => true, + 'core/comments' => true, + ), + ), + 'origin: theme; cache: not empty' => array( + 'origin' => 'theme', + 'cache' => array( + 'core/cover' => true, + ), + ), + 'origin: user; cache: not empty' => array( + 'origin' => 'user', + 'cache' => array( + 'core/gallery' => true, + ), + ), + ); + } + + /** + * Tests when WP_Theme_JSON_Resolver::$blocks_cache is empty or does not match + * the all registered blocks. + * + * Though this is a non-public method, it is vital to other functionality. + * Therefore, tests are provided to validate it functions as expected. + * + * @dataProvider data_has_same_registered_blocks_when_all_blocks_are_cached + * @ticket 56467 + * + * @param string $origin The origin to test. + */ + public function test_has_same_registered_blocks_when_all_blocks_are_cached( $origin ) { + $has_same_registered_blocks = new ReflectionMethod( WP_Theme_JSON_Resolver::class, 'has_same_registered_blocks' ); + $has_same_registered_blocks->setAccessible( true ); + $expected_cache = $this->get_registered_block_names(); + + // Set up the cache with all registered blocks. + $blocks_cache = static::$property_blocks_cache->getValue(); + $blocks_cache[ $origin ] = $this->get_registered_block_names(); + static::$property_blocks_cache->setValue( null, $blocks_cache ); + + $this->assertTrue( $has_same_registered_blocks->invoke( null, $origin ), 'WP_Theme_JSON_Resolver::has_same_registered_blocks() should return true when using the cache' ); + $this->assertSameSets( $expected_cache, $blocks_cache[ $origin ], 'WP_Theme_JSON_Resolver::$blocks_cache should contain all expected block names for the given origin' ); + } + + /** + * Data provider. + * + * @return array + */ + public function data_has_same_registered_blocks_when_all_blocks_are_cached() { + return array( + 'core' => array( 'core' ), + 'blocks' => array( 'blocks' ), + 'theme' => array( 'theme' ), + 'user' => array( 'user' ), + ); + } + + /** + * @dataProvider data_get_core_data + * @covers WP_Theme_JSON_Resolver::get_core_data + * @ticket 56467 + */ + public function test_get_core_data( $should_fire_filter, $core_is_cached, $blocks_are_cached ) { + WP_Theme_JSON_Resolver::clean_cached_data(); + + // If should cache core, then fire the method to cache it before running the tests. + if ( $core_is_cached ) { + WP_Theme_JSON_Resolver::get_core_data(); + } + + // If should cache registered blocks, then set them up before running the tests. + if ( $blocks_are_cached ) { + $blocks_cache = static::$property_blocks_cache->getValue(); + $blocks_cache['core'] = $this->get_registered_block_names(); + static::$property_blocks_cache->setValue( null, $blocks_cache ); + } + + $expected_filter_count = did_filter( 'theme_json_default' ); + $actual = WP_Theme_JSON_Resolver::get_core_data(); + if ( $should_fire_filter ) { + $expected_filter_count++; + } + + $this->assertSame( $expected_filter_count, did_filter( 'theme_json_default' ), 'The filter "theme_json_default" should fire the given number of times' ); + $this->assertInstanceOf( WP_Theme_JSON::class, $actual, 'WP_Theme_JSON_Resolver::get_core_data() should return instance of WP_Theme_JSON' ); + $this->assertSame( static::$property_core->getValue(), $actual, 'WP_Theme_JSON_Resolver::$core property should be the same object as returned from WP_Theme_JSON_Resolver::get_core_data()' ); + } + + /** + * Data provider. + * + * @return array + */ + public function data_get_core_data() { + return array( + 'When both caches are empty' => array( + 'should_fire_filter' => true, + 'core_is_cached' => false, + 'blocks_are_cached' => false, + ), + 'When the blocks_cache is not empty and matches' => array( + 'should_fire_filter' => true, + 'core_is_cached' => false, + 'blocks_are_cached' => true, + ), + 'When blocks_cache is empty but core cache is not' => array( + 'should_fire_filter' => true, + 'core_is_cached' => true, + 'blocks_are_cached' => false, + ), + 'When both caches are not empty' => array( + 'should_fire_filter' => true, + 'core_is_cached' => true, + 'blocks_are_cached' => false, + ), + ); + } + /** * @ticket 52991 */