Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Global Styles: Update gutenberg_get_global_stylesheet to use WP_Object_Cache #45679

Merged
merged 27 commits into from
Nov 22, 2022
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
fad27ea
Global Styles: Update `gutenberg_get_global_stylesheet` to use `WP_Ob…
mmtr Nov 9, 2022
0a23b43
Add remaining hooks
mmtr Nov 9, 2022
714819b
Simplify test setup
mmtr Nov 9, 2022
4f36f72
Rename cache key and functions
mmtr Nov 9, 2022
0f80d71
Merge remote-tracking branch 'origin/trunk' into update/global-styles…
mmtr Nov 16, 2022
a845e85
Add filter to shortcircuit the cache
mmtr Nov 16, 2022
6014519
Clean cache within `wp_theme_clean_theme_json_cached_data`
mmtr Nov 16, 2022
5acfd40
Fix test
mmtr Nov 16, 2022
4eaeb71
Add missing params in docstring
mmtr Nov 16, 2022
e8a32cb
Create separate function to clean the cache
mmtr Nov 16, 2022
6931aa8
Remove SCRIPT_DEBUG check since it is not usually part of the theme d…
mmtr Nov 16, 2022
a2b9d6f
Clear cache from WP_Theme_JSON_Resolver separately
mmtr Nov 16, 2022
2acbafd
Rename filter
mmtr Nov 16, 2022
30361b5
Fix linting issues
mmtr Nov 16, 2022
b38b5b8
Merge remote-tracking branch 'origin/trunk' into update/global-styles…
mmtr Nov 17, 2022
0a371d4
Use WP version in since annotation
mmtr Nov 17, 2022
18cd94f
Check parent theme (props @felixarntz)
mmtr Nov 17, 2022
8e2ffb8
Invalidate WP_Theme_JSON_Resolver_Gutenberg cache after toggling a pl…
mmtr Nov 17, 2022
18f1443
Invalidate WP_Theme_JSON_Resolver_Gutenberg cache after an upgrade
mmtr Nov 17, 2022
4f1a1b8
Remove @since 6.2.0 annotations as we don't know which WP version wil…
mmtr Nov 17, 2022
795045a
Merge remote-tracking branch 'origin/trunk' into update/global-styles…
mmtr Nov 17, 2022
c9df6b4
Merge remote-tracking branch 'origin/trunk' into update/global-styles…
mmtr Nov 17, 2022
88c1e33
Update phpunit/wp-get-global-stylesheet-test.php
mmtr Nov 17, 2022
9dcdde7
Add missing params to actions
mmtr Nov 18, 2022
9321aca
Merge remote-tracking branch 'origin/trunk' into update/global-styles…
mmtr Nov 21, 2022
8f642a4
Merge remote-tracking branch 'origin/trunk' into update/global-styles…
mmtr Nov 22, 2022
2b16326
Remove filter
mmtr Nov 22, 2022
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
85 changes: 0 additions & 85 deletions lib/compat/wordpress-6.1/get-global-styles-and-settings.php
Original file line number Diff line number Diff line change
Expand Up @@ -54,88 +54,3 @@ function ( $item ) {
}
}
}

/**
* Returns the stylesheet resulting of merging core, theme, and user data.
*
* @param array $types Types of styles to load. Optional.
* It accepts 'variables', 'styles', 'presets' as values.
* If empty, it'll load all for themes with theme.json support
* and only [ 'variables', 'presets' ] for themes without theme.json support.
*
* @return string Stylesheet.
*/
function gutenberg_get_global_stylesheet( $types = array() ) {
// Return cached value if it can be used and exists.
// It's cached by theme to make sure that theme switching clears the cache.
$can_use_cached = (
Copy link
Member

@oandregal oandregal Nov 17, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This logic is changing:

  • ✔️ adds a filter.
  • ✔️ keeps empty( $types ).
  • ✔️ keeps the WP_DEBUG check.
  • ✔️ removes the SCRIPT_DEBUG. WP_DEBUG should be enough.
  • ✔️ removes is_admin. I don't know why we started adding is_admin in a few places (in this function, in the SVG filters cache, and webfonts). From the cache management point of view, the way it works now is that admin users won't use the cache, no matter the other conditions. I don't see a reason why, other than perhaps circumventing the issues introduced by the transient (styles were not immediately applied in the front-end). I think it's safe to remove.
  • 🟡 removes REST_REQUEST. I don't know why this logic was present and why we cannot cache REST requests. After some searching, I found the PR that first introduced this logic. The SVG and webfonts PRs came later, so they probably followed what they saw. I asked there for more context.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Got a response from Ari: he says the rest request check was probably for the editor (it was many moons ago, so it's hard for anyone to remember exactly). Going to investigate this a bit.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I only know see places in which we use a REST Request with this data:

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@geriux and @fluiddot would you be able to test if this PR, as it is, works for you as well? If so, we can safely remove it.

Sure! I will test it and report back 👍

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've just tested these changes and the mobile endpoint works correctly ✅ Thanks for the ping!

( empty( $types ) ) &&
( ! defined( 'WP_DEBUG' ) || ! WP_DEBUG ) &&
( ! defined( 'SCRIPT_DEBUG' ) || ! SCRIPT_DEBUG ) &&
( ! defined( 'REST_REQUEST' ) || ! REST_REQUEST ) &&
! is_admin()
);
$transient_name = 'gutenberg_global_styles_' . get_stylesheet();
if ( $can_use_cached ) {
$cached = get_transient( $transient_name );
if ( $cached ) {
return $cached;
}
}
$tree = WP_Theme_JSON_Resolver_Gutenberg::get_merged_data();
$supports_theme_json = wp_theme_has_theme_json();
if ( empty( $types ) && ! $supports_theme_json ) {
$types = array( 'variables', 'presets', 'base-layout-styles' );
} elseif ( empty( $types ) ) {
$types = array( 'variables', 'styles', 'presets' );
}

/*
* If variables are part of the stylesheet,
* we add them.
*
* This is so themes without a theme.json still work as before 5.9:
* they can override the default presets.
* See https://core.trac.wordpress.org/ticket/54782
*/
$styles_variables = '';
if ( in_array( 'variables', $types, true ) ) {
/*
* We only use the default, theme, and custom origins.
* This is because styles for blocks origin are added
* at a later phase (render cycle) so we only render the ones in use.
* @see wp_add_global_styles_for_blocks
*/
$origins = array( 'default', 'theme', 'custom' );
$styles_variables = $tree->get_stylesheet( array( 'variables' ), $origins );
$types = array_diff( $types, array( 'variables' ) );
}

/*
* For the remaining types (presets, styles), we do consider origins:
*
* - themes without theme.json: only the classes for the presets defined by core
* - themes with theme.json: the presets and styles classes, both from core and the theme
*/
$styles_rest = '';
if ( ! empty( $types ) ) {
/*
* We only use the default, theme, and custom origins.
* This is because styles for blocks origin are added
* at a later phase (render cycle) so we only render the ones in use.
* @see wp_add_global_styles_for_blocks
*/
$origins = array( 'default', 'theme', 'custom' );
if ( ! $supports_theme_json ) {
$origins = array( 'default' );
}
$styles_rest = $tree->get_stylesheet( $types, $origins );
}
$stylesheet = $styles_variables . $styles_rest;
if ( $can_use_cached ) {
// Cache for a minute.
// This cache doesn't need to be any longer, we only want to avoid spikes on high-traffic sites.
set_transient( $transient_name, $stylesheet, MINUTE_IN_SECONDS );
}
return $stylesheet;
}
7 changes: 7 additions & 0 deletions lib/compat/wordpress-6.2/default-filters.php
Original file line number Diff line number Diff line change
Expand Up @@ -25,3 +25,10 @@
*/
add_action( 'switch_theme', 'wp_theme_clean_theme_json_cached_data' );
add_action( 'start_previewing_theme', 'wp_theme_clean_theme_json_cached_data' );

add_action( 'save_post_wp_global_styles', 'gutenberg_clean_cached_stylesheet' );
add_action( 'switch_theme', 'gutenberg_clean_cached_stylesheet' );
add_action( 'start_previewing_theme', 'gutenberg_clean_cached_stylesheet' );
add_action( 'activated_plugin', 'gutenberg_clean_cached_stylesheet' );
add_action( 'deactivated_plugin', 'gutenberg_clean_cached_stylesheet' );
add_action( 'upgrader_process_complete', '_gutenberg_clean_cached_stylesheet_upon_upgrading' );
mmtr marked this conversation as resolved.
Show resolved Hide resolved
105 changes: 105 additions & 0 deletions lib/compat/wordpress-6.2/get-global-styles-and-settings.php
Original file line number Diff line number Diff line change
Expand Up @@ -45,3 +45,108 @@ function wp_theme_clean_theme_json_cached_data() {
WP_Theme_JSON_Resolver_Gutenberg::clean_cached_data();
}
}

/**
* Returns the stylesheet resulting of merging core, theme, and user data.
*
* @param array $types Types of styles to load. Optional.
* It accepts 'variables', 'styles', 'presets' as values.
* If empty, it'll load all for themes with theme.json support
* and only [ 'variables', 'presets' ] for themes without theme.json support.
*
* @return string Stylesheet.
*/
function gutenberg_get_global_stylesheet( $types = array() ) {
// Return cached value if it can be used and exists.
// It's cached by theme to make sure that theme switching clears the cache.
$can_use_cached = (
( empty( $types ) ) &&
Copy link
Member

@oandregal oandregal Nov 16, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Idea for a potential follow-up: this code currently only caches the whole stylesheet, meaning the one that is enqueued in the front-end (see). I wonder whether it makes sense to cache the others for the editor as well (see) and what effect would it have in the loading times of the editor.

( ! defined( 'WP_DEBUG' ) || ! WP_DEBUG ) &&
( ! defined( 'SCRIPT_DEBUG' ) || ! SCRIPT_DEBUG ) &&
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Those will be truthy in our phpunit setup, so a test will not be able to use the cached data in the current state in a test. We could, of course, add a filter to circumvent that 😅

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's remove this, as it hides caching behaviour changes on local dev, with it not great for unit testing and local profiling.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we don't have a mechanism to invalidate the cache in development mode, what would you recommend to provide a good developer experience (changes to theme.json are immediately applied)?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We could, of course, add a filter to circumvent that 😅

Yeah, I discussed this more deeply with @oandregal and probably will give that a try, so we can keep the cache off by default in some scenarios (e.g. during theme development). There might be also legitimate scenarios where plugins want to disable this cache as well (e.g. if they want to change the styles in real time using some of the Global Styles filters), so they could benefit from this filter as well.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In a845e85 I simplified the conditions that allows the cache to be used by removing the ! REST_REQUEST and ! is_admin() checks, so from now on we should be caching more often.

I kept the ! WP_DEBUG and ! SCRIPT_DEBUG checks since theme developers presumably have these constants turned on when developing a theme and we don't want the cache to interfere with their work.

I also kept the empty( $types ) check, since otherwise we would need to cache the stylesheet for every possible value in $types.

Furthermore, there is now a filter that can override the default cache behavior when needed such as in the unit tests.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(I want to document some of the rationale for the filter)

The use case the filter addresses is allowing consumers to control cache based on external conditions. Sometimes, it's not enough to clear the cache upon all the events this is hooked into.

Initially, my thinking was that Miguel and I discussed that this could be enough (asking consumers to clear the cache under those conditions):

// In some 3rd party code, such as a plugin:
if ( $some_conditions_are_met ) {
	gutenberg_get_global_stylesheet_clean_cache();
	// So it gets recalculated based on
	// the results of the filters for theme.json data.
}

// ...

// In core code:
gutenberg_get_global_stylesheet(); // will repopulate the cache

However, this code is vulnerable to race conditions.

A simple example would be when the object cache is implemented as a persistent cache across request that is shared among many front-end servers. Other examples exist, but this is easy to visualize. A reproducible race condition would be:

  • server A cleans the cache (in the plugin code)
  • server B sets the cache (in the core code)
  • server A goes to find the value (in the core code) and it comes from the cache, so it's not recalculated as expected

By allowing the plugin code to modify the core behaviour (by means of the filter):

function gutenberg_get_global_stylesheet( $types = array() ) {
	$can_use_cached = apply_filters( 'wp_get_global_stylesheet_can_use_cache', $default );
	// ...

the cache will never be used if the consumer does not want to, avoiding race conditions.

( ! defined( 'REST_REQUEST' ) || ! REST_REQUEST ) &&
! is_admin()
);
$cache_key = 'gutenberg_global_styles';
$cache_group = 'theme_json';
if ( $can_use_cached ) {
$cached = wp_cache_get( $cache_key, $cache_group );
if ( $cached ) {
return $cached;
}
}
$tree = WP_Theme_JSON_Resolver_Gutenberg::get_merged_data();
$supports_theme_json = wp_theme_has_theme_json();
if ( empty( $types ) && ! $supports_theme_json ) {
$types = array( 'variables', 'presets', 'base-layout-styles' );
} elseif ( empty( $types ) ) {
$types = array( 'variables', 'styles', 'presets' );
}

/*
* If variables are part of the stylesheet,
* we add them.
*
* This is so themes without a theme.json still work as before 5.9:
* they can override the default presets.
* See https://core.trac.wordpress.org/ticket/54782
*/
$styles_variables = '';
if ( in_array( 'variables', $types, true ) ) {
/*
* We only use the default, theme, and custom origins.
* This is because styles for blocks origin are added
* at a later phase (render cycle) so we only render the ones in use.
* @see wp_add_global_styles_for_blocks
*/
$origins = array( 'default', 'theme', 'custom' );
$styles_variables = $tree->get_stylesheet( array( 'variables' ), $origins );
$types = array_diff( $types, array( 'variables' ) );
}

/*
* For the remaining types (presets, styles), we do consider origins:
*
* - themes without theme.json: only the classes for the presets defined by core
* - themes with theme.json: the presets and styles classes, both from core and the theme
*/
$styles_rest = '';
if ( ! empty( $types ) ) {
/*
* We only use the default, theme, and custom origins.
* This is because styles for blocks origin are added
* at a later phase (render cycle) so we only render the ones in use.
* @see wp_add_global_styles_for_blocks
*/
$origins = array( 'default', 'theme', 'custom' );
if ( ! $supports_theme_json ) {
$origins = array( 'default' );
}
$styles_rest = $tree->get_stylesheet( $types, $origins );
}
$stylesheet = $styles_variables . $styles_rest;
if ( $can_use_cached ) {
wp_cache_set( $cache_key, $stylesheet, $cache_group );
}
return $stylesheet;
}

/**
* Invalidate the cached stylesheet.
*/
function gutenberg_clean_cached_stylesheet() {
wp_cache_delete( 'gutenberg_global_styles', 'theme_json' );
}

function _gutenberg_clean_cached_stylesheet_upon_upgrading( $upgrader, $options ) {
if ( 'update' !== $options['action'] ) {
return;
}

if (
'core' === $options['type'] ||
'plugin' === $options['type'] ||
( 'theme' === $options['type'] && array_key_exists( get_stylesheet(), $options['themes'] ) )
) {
gutenberg_clean_cached_stylesheet();
}
}
86 changes: 86 additions & 0 deletions phpunit/wp-get-global-stylesheet-test.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
<?php
/**
* Tests wp_get_global_stylesheet().
*
* @package Gutenberg
*/

class WP_Get_Global_Stylesheet_Test extends WP_UnitTestCase {

/**
* Theme root directory.
*
* @var string
*/
private $theme_root;

/**
* Original theme directory.
*
* @var string
*/
private $orig_theme_dir;

public function set_up() {
parent::set_up();

$this->orig_theme_dir = $GLOBALS['wp_theme_directories'];
$this->theme_root = realpath( DIR_TESTDATA . '/themedir1' );

// /themes is necessary as theme.php functions assume /themes is the root if there is only one root.
$GLOBALS['wp_theme_directories'] = array( WP_CONTENT_DIR . '/themes', $this->theme_root );

// Set up the new root.
add_filter( 'theme_root', array( $this, 'filter_set_theme_root' ) );
add_filter( 'stylesheet_root', array( $this, 'filter_set_theme_root' ) );
add_filter( 'template_root', array( $this, 'filter_set_theme_root' ) );

// Clear caches.
wp_clean_themes_cache();
unset( $GLOBALS['wp_themes'] );
}

public function tear_down() {
$GLOBALS['wp_theme_directories'] = $this->orig_theme_dir;

// Clear up the filters to modify the theme root.
remove_filter( 'theme_root', array( $this, 'filter_set_theme_root' ) );
remove_filter( 'stylesheet_root', array( $this, 'filter_set_theme_root' ) );
remove_filter( 'template_root', array( $this, 'filter_set_theme_root' ) );

wp_clean_themes_cache();
unset( $GLOBALS['wp_themes'] );

parent::tear_down();
}

/**
* Cleans up global scope.
*
* @global WP_Styles $wp_styles
*/
public function clean_up_global_scope() {
global $wp_styles;
parent::clean_up_global_scope();
$wp_styles = null;
}

public function filter_set_theme_root() {
return $this->theme_root;
}

public function test_global_styles_changes_invalidates_cache() {
switch_theme( 'block-theme' );

$user_cpt = WP_Theme_JSON_Resolver_Gutenberg::get_user_data_from_wp_global_styles( wp_get_theme(), true );

$config = json_decode( $user_cpt['post_content'], true );
$config['styles']['elements']['button']['color']['background'] = 'hotpink';
$user_cpt['post_content'] = wp_json_encode( $config );

wp_update_post( $user_cpt, true, false );

$styles = gutenberg_get_global_stylesheet();
mmtr marked this conversation as resolved.
Show resolved Hide resolved
$this->assertStringContainsString( '.button{background-color: hotpink;}', $styles );
}
}