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 25 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;
}
25 changes: 25 additions & 0 deletions lib/compat/wordpress-6.2/class-wp-theme-json-resolver-6-2.php
Original file line number Diff line number Diff line change
Expand Up @@ -32,4 +32,29 @@ public static function theme_has_support() {
return wp_theme_has_theme_json();
}

/**
* Private method to clean the cached data after an upgrade.
*
* It is hooked into the `upgrader_process_complete` action.
*
* @see default-filters.php
*
* @param WP_Upgrader $upgrader WP_Upgrader instance.
* @param array $options Array of bulk item update data.
*/
public static function _clean_cached_data_upon_upgrading( $upgrader, $options ) {
Copy link
Member

Choose a reason for hiding this comment

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

Let stop adding static methods to this class. They are not in part of the WordPress coding standards and are harder to test.

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 done a quick search for this pattern and there are multiple cases where this pattern is used:

I won't list them all, but these should serve to highlight that there are different sensibilities around this. Given this code is architected this way, and it is a very common pattern, I don't think we should do it differently.

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

if (
'core' === $options['type'] ||
'plugin' === $options['type'] ||
// Clean cache only if the active theme was updated.
( 'theme' === $options['type'] && ( isset( $options['themes'][ get_stylesheet() ] ) || isset( $options['themes'][ get_template() ] ) ) )
) {
static::clean_cached_data();
}
}

}
10 changes: 10 additions & 0 deletions lib/compat/wordpress-6.2/default-filters.php
Original file line number Diff line number Diff line change
Expand Up @@ -20,3 +20,13 @@
add_action( 'switch_theme', 'wp_theme_has_theme_json_clean_cache' );
add_action( 'start_previewing_theme', 'wp_theme_has_theme_json_clean_cache' );
add_action( 'upgrader_process_complete', '_wp_theme_has_theme_json_clean_cache_upon_upgrading_active_theme', 10, 2 );
add_action( 'save_post_wp_global_styles', array( 'WP_Theme_JSON_Resolver_Gutenberg', 'clean_cached_data' ) );
add_action( 'activated_plugin', array( 'WP_Theme_JSON_Resolver_Gutenberg', 'clean_cached_data' ) );
add_action( 'deactivated_plugin', array( 'WP_Theme_JSON_Resolver_Gutenberg', 'clean_cached_data' ) );
add_action( 'upgrader_process_complete', array( 'WP_Theme_JSON_Resolver_Gutenberg', '_clean_cached_data_upon_upgrading', 10, 2 ) );
add_action( 'save_post_wp_global_styles', 'gutenberg_get_global_stylesheet_clean_cache' );
add_action( 'switch_theme', 'gutenberg_get_global_stylesheet_clean_cache' );
add_action( 'start_previewing_theme', 'gutenberg_get_global_stylesheet_clean_cache' );
add_action( 'activated_plugin', 'gutenberg_get_global_stylesheet_clean_cache' );
add_action( 'deactivated_plugin', 'gutenberg_get_global_stylesheet_clean_cache' );
add_action( 'upgrader_process_complete', '_gutenberg_get_global_stylesheet_clean_cache_upon_upgrading', 10, 2 );
119 changes: 119 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 @@ -79,3 +79,122 @@ function _wp_theme_has_theme_json_clean_cache_upon_upgrading_active_theme( $upgr
}
}
}

/**
* 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() ) {
/**
* Filters whether the cached global stylesheet can be used.
*
* @param boolean $can_use_cached Whether the cached global stylesheet can be used.
*/
$can_use_cached = apply_filters(
'wp_get_global_stylesheet_can_use_cache',
Copy link
Member

Choose a reason for hiding this comment

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

What would be the use-case for having this controlled by a filter? If this is just to allow turning it off during development, I would suggest we remove this and rethink it, as this consideration does not only apply to the global stylesheet but also anything related to theme.json.

See my other comment - maybe a filter is actually the best approach, but then it should probably be a more general filter? Anyway, my main point is we should discuss this holistically, not across several PRs where we're adding different ideas in code. I think an issue to discuss first would be best for this.

At a minimum, we should align on one thing throughout those PRs, even if we want to remain open to changing it later. Right now I'm afraid the approaches are getting mixed up, which would be a problem.

Copy link
Member

Choose a reason for hiding this comment

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

The use case for the filter is documented in this other conversation (it's unrelated to development/debug) #45679 (comment)

Copy link
Member

Choose a reason for hiding this comment

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

@oandregal Thanks for the context. What I'm not understanding yet though, why is this specifically a problem here? Wouldn't the same concerns apply to pretty much any cache usage in WordPress? How is this one different?

Copy link
Member

Choose a reason for hiding this comment

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

I think we should add this filter later, I am not sure about it either.

Copy link
Contributor Author

@mmtr mmtr Nov 18, 2022

Choose a reason for hiding this comment

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

What would be the use-case for having this controlled by a filter?

One example is that this filter would allow plugins to preview styles in the frontend without saving them.

Plugins have the ability to change the styles using one of the wp_theme_json_data_* filters, but those filters are not applied if the global stylesheet is cached since WP_Theme_JSON_Resolver_Gutenberg::get_merged_data() is never executed.

Without a filter that short-circuits the cache, a plugin that hooks into wp_theme_json_data_user to show how styles would look like in the frontend won't be able to do so if the actual styles (the ones showed to visitors) are already cached.

The plugin could of course invalidate the cache before the styles are previewed, but then those previewed-not-actual-styles would be cached, and the plugin would need to invalidate the cache again before a visitor access the site. This would also be prone to race conditions errors as noted by @oandregal.

With a filter, this would be much easier to handle.

Anyway, happy to remove the filter for now and leave it for a follow-up.

Copy link
Member

@oandregal oandregal Nov 18, 2022

Choose a reason for hiding this comment

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

I may have been too concise on the rationale, let me rephrase/provide more context.

This is the product need we want to support: 3rd parties are able to hook into the theme.json data filters, which control the output of the gutenberg_get_global_stylesheet method. Those filters only run if the value is recalculated, not taken from the cache. There are valid uses cases that are not covered by the current cache invalidation, hence we need a mechanism for 3rd parties to force the values to be recalculated. Some of these use cases are:

  • a hosting company may want to recalculate the stylesheet based on a user profile.
  • an agency may want to control the styles of a site based on seasonal events (e.g.: black friday).
  • a plugin may want to pre-visualize styles in the front-end that were not saved.

That's the why, the product need. I hope this provides a clear context.

Now, discussing implementations to support this product need:

  • I pushed against the filter initially (see). I though asking consumers to clean the cache would be enough.
  • Upon further inspection, I realized that the code was vulnerable to race conditions (see), an issue that the filter approach does not have.

Is there another alternative we should consider to be able to support this use case?

Copy link
Member

Choose a reason for hiding this comment

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

I think being able to add filters on something that goes to cache is the root flaw here that we're trying to work around. Adding a filter to bypass cache is not solving this problem, it would just be a workaround.

I would advocate for leaving out the filter and work on solving the real problem. There must be ways to migrate the filter to be run on the data Post-Cache rather than pre-cache.

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 talked about this with Felix in private. I understand Felix's point of view, though I have a different perspective. In the interest of making progress, let's leave the filter out of this PR and re-evaluate later, should we need.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Thanks for the feedback @felixarntz. I see now how requiring plugin developers to disable the cache to be able to hook into the wp_theme_json_data_* filters shouldn't be a required pattern, so I removed the filter for now in 2b16326.

( 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.

// Ignore cache when `WP_DEBUG` is enabled, so it doesn't interfere with the theme developers workflow.
( ! defined( 'WP_DEBUG' ) || ! WP_DEBUG )
mmtr marked this conversation as resolved.
Show resolved Hide resolved
);
$cache_key = 'gutenberg_get_global_stylesheet';
$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;
}

/**
* Clean the cache used by the `gutenberg_get_global_stylesheet` function.
*/
function gutenberg_get_global_stylesheet_clean_cache() {
wp_cache_delete( 'gutenberg_get_global_stylesheet', 'theme_json' );
}

/**
* Private function to clean the cache used by the `gutenberg_get_global_stylesheet` function after an upgrade.
*
* It is hooked into the `upgrader_process_complete` action.
*
* @see default-filters.php
*
* @param WP_Upgrader $upgrader WP_Upgrader instance.
* @param array $options Array of bulk item update data.
*/
function _gutenberg_get_global_stylesheet_clean_cache_upon_upgrading( $upgrader, $options ) {
if ( 'update' !== $options['action'] ) {
return;
}

if (
'core' === $options['type'] ||
'plugin' === $options['type'] ||
// Clean cache only if the active theme was updated.
( 'theme' === $options['type'] && ( isset( $options['themes'][ get_stylesheet() ] ) || isset( $options['themes'][ get_template() ] ) ) )
) {
gutenberg_get_global_stylesheet_clean_cache();
}
}

98 changes: 98 additions & 0 deletions phpunit/wp-get-global-stylesheet-test.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
<?php
/**
* Tests wp_get_global_stylesheet().
*
* @package Gutenberg
*/

class WP_Get_Global_Stylesheet_Test extends WP_UnitTestCase {

/**
* Administrator ID.
*
* @var int
*/
protected static $administrator_id;

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

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

public static function set_up_before_class() {
parent::set_up_before_class();

self::$administrator_id = self::factory()->user->create(
array(
'role' => 'administrator',
'user_email' => '[email protected]',
)
);
}

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();
}

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

public function test_global_styles_user_cpt_change_invalidates_cached_stylesheet() {
add_filter( 'wp_get_global_stylesheet_can_use_cache', '__return_true' );
switch_theme( 'block-theme' );
wp_set_current_user( self::$administrator_id );
Copy link
Member

Choose a reason for hiding this comment

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

Documenting something Miguel fixed for posterity: we need to make the petition as an admin, otherwise WP_Theme_JSON_Resolver_Gutenberg::get_user_data_from_wp_global_styles returns an empty post.


$styles = gutenberg_get_global_stylesheet();
$this->assertStringNotContainsString( 'background-color: hotpink;', $styles );

$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']['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( 'background-color: hotpink;', $styles );
remove_filter( 'wp_get_global_stylesheet_can_use_cache', '__return_true' );
}
}