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

Leverage URL metrics to reserve space for embeds to reduce CLS #1373

Open
wants to merge 53 commits into
base: trunk
Choose a base branch
from

Conversation

westonruter
Copy link
Member

@westonruter westonruter commented Jul 17, 2024

Fixes #1310

The changes in this pull request grew larger than the strict scope of reducing CLS for embeds since the work to implement this uncovered shortcomings with the current Optimization Detective API. This PR includes the following key pieces:

  1. Introduce client-side extensions in the form of script modules which are loaded when the detection logic runs. The client-side script modules are able to register functionality to run as soon as the module is loaded by implementing an exported initialize function. They also are able to register functionality to run just before the URL metric data is being submitted by implementing an exported finalize function which amends the URL metric data as needed. This builds on Allow URL metric schema to be extended #1492 which enabled the URL metric schema to be extended. This PR includes Embed Optimizer extending the URL metric element schema to include a resizedBoundingClientRect key which is populated with the script module via a ResizeObserver which starts observing in the module's initialize function. New script modules are added simply by appending to the array passed to the od_extension_module_urls filter.
  2. The gathered URL metric data is now sent when the page is hidden as opposed to when the page is "done" loading. This is essential in order to capture the resized heights of embeds once the user scrolls them into view and they are loaded. This will also open up new opportunities to track INP over the life of the page to include in the submitted URL metric data.
  3. New APIs are added to Optimization Detective which Embed Optimizer depends on (e.g. OD_URL_Metric_Group_Collection::get_all_denormalized_elements()). In order to avoid the need to do method_exists() checks and the like, a new od_init action is introduced which passes the Optimization Detective version as its only argument. Embed Optimizer and Image Prioritizer then check this passed version to see if it meets the requirements. If not, then an admin notice is displayed. If yes, then it goes ahead and loads the Optimization Detective extension code.
  4. Related to the above, I found that certain plugins initialize their module code after the after_setup_theme action. In proposing embedding Optimization Detective in Elementor (Leverage URL metrics to reserve space for embeds to reduce CLS #1373), I found that modules load at init with priority 0. This makes the current bootstrap code unusable when it is loaded in such a module, necessitating an ugly workaround. Since Optimization Detective doesn't need to bootstrap before init anyway, the solution seems to simply be to move the bootstrapping logic to init as well. This is implemented in this PR by moving to init at priority 9 since the plugin has code that runs at init with the default priority of 10.

Accounting for responsive embeds

Initially I was thinking the optimization logic should wait to set the min-height until there are URL metrics gathered for the smallest breakpoint group. For example, let's say the page is responsive and on desktop an embed is much taller than it is on mobile. If the desktop metrics are collected before mobile metrics are collected, then the min-height could be being set for desktop could be too high for mobile, resulting in a gap below the embed on mobile. For example, consider this tweet on mobile vs desktop:

Mobile (320w) Desktop
Screenshot 2024-10-04 11 12 32 Screenshot 2024-10-04 11 12 45

The tweet on mobile here is 574px high whereas on desktop it is 825px, so if desktop metrics were only collected then the result would be a worst case gap of 251px at the bottom before the next content:

Screenshot 2024-10-04 11 18 31

A simple way to solve this would not to set the min-height on the figure.wp-block-embed element at all, but rather to create a new style rule with responsive styles that sets multiple min-height styles for the element according to each breakpoint. This is implemented in this PR. For example, the following CSS is added to the head for a Twitter embed:

<style>
@media (max-width: 480px) { #embed-optimizer-a7659db28ecaa36ddee6ae66857dabd8 { min-height: 500px; } }
@media (min-width: 481px) and (max-width: 600px) { #embed-optimizer-a7659db28ecaa36ddee6ae66857dabd8 { min-height: 500px; } }
@media (min-width: 601px) and (max-width: 782px) { #embed-optimizer-a7659db28ecaa36ddee6ae66857dabd8 { min-height: 500px; } }
@media (min-width: 783px) { #embed-optimizer-a7659db28ecaa36ddee6ae66857dabd8 { min-height: 500px; } }
</style>

Demo Videos

Before

bad-cls-without-embed-optimizer.webm

After

Once URL metrics have been collected from visitors by Optimization Detective:

good-cls-with-embed-optimizer.webm

@westonruter westonruter added the [Type] Enhancement A suggestion for improvement of an existing feature label Jul 17, 2024
@swissspidy swissspidy added the [Plugin] Embed Optimizer Issues for the Embed Optimizer plugin (formerly Auto Sizes) label Jul 26, 2024
@westonruter westonruter force-pushed the add/embed-optimizer-min-height-reservation branch from f38247b to 947ca41 Compare July 26, 2024 18:23
@westonruter westonruter added this to the embed-optimizer n.e.x.t milestone Jul 26, 2024
@westonruter westonruter added the [Plugin] Optimization Detective Issues for the Optimization Detective plugin label Jul 26, 2024
@westonruter westonruter force-pushed the add/embed-optimizer-min-height-reservation branch from 3f38eb0 to b3ca4ad Compare July 26, 2024 22:06
return false;
}

$max_intersection_ratio = $context->url_metrics_group_collection->get_element_max_intersection_ratio( $processor->get_xpath() );
$embed_wrapper_xpath = $processor->get_xpath() . '/*[1][self::DIV]';
Copy link
Member Author

Choose a reason for hiding this comment

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

It's not ideal to be constructing this XPath manually.

Copy link
Member Author

Choose a reason for hiding this comment

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

I've opened #1407 to explore this, but it's not necessary for this PR to move forward.

@westonruter
Copy link
Member Author

westonruter commented Jul 26, 2024

I noticed Query Monitor flagging a PHP deprecation error when loading a page with Optimization Detective and Embed Optimizer active. A TikTok embed is present on the page. I'm getting:

PHP Deprecated: Implicit conversion from float 739.9976196289062 to int loses precision in /var/www/html/wp-content/plugins/optimization-detective/class-od-url-metrics-group-collection.php on line 486

Call stack:

image

Update: Addressed in #1411.

@westonruter
Copy link
Member Author

westonruter commented Jul 26, 2024

I also saw this for some reason:

PHP Notice: OD_HTML_Tag_Processor::get_updated_html(): Unable to append markup to optimization_detective_end_of_body since the bookmark no longer exists. in /var/www/html/wp-includes/functions.php on line 6085

Update: See fix below in #1373 (comment)

@westonruter westonruter force-pushed the add/embed-optimizer-min-height-reservation branch from 02b8fb3 to b17d8ba Compare July 30, 2024 00:40
@westonruter westonruter changed the base branch from trunk to fix/od-schema July 30, 2024 00:40
@westonruter westonruter force-pushed the add/embed-optimizer-min-height-reservation branch from b17d8ba to 76369b4 Compare July 30, 2024 00:42
Base automatically changed from fix/od-schema to trunk July 30, 2024 14:58
@westonruter westonruter force-pushed the add/embed-optimizer-min-height-reservation branch from 1f5e5f8 to 77bea30 Compare August 2, 2024 00:06
Comment on lines 88 to 95
$style = $processor->get_attribute( 'style' );
if ( is_string( $style ) ) {
$style = rtrim( trim( $style ), ';' ) . '; ';
} else {
$style = '';
}
$style .= sprintf( 'min-height: %dpx;', $minimum_height );
$processor->set_attribute( 'style', $style );
Copy link
Member Author

Choose a reason for hiding this comment

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

This should be a helper method on OD_HTML_Tag_Processor.

Copy link
Member Author

Choose a reason for hiding this comment

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

@westonruter westonruter force-pushed the add/embed-optimizer-min-height-reservation branch 2 times, most recently from 019526c to 36e5620 Compare August 13, 2024 23:03
@westonruter
Copy link
Member Author

Scratched code to explore using onCLS to detect when an embed has loaded (and causes a layout shift. Problem: In the case of the Twitter embed, the source node being reported is REMOVED from the DOM, so we cannot see the ancestor which has a data-od-xpath attribute.

Code scratch
	const { onLCP, onCLS } = await import( webVitalsLibrarySrc );

	onCLS(
		( metric ) => {
			for ( const entry of metric.entries ) {
				if (
					entry.entryType === 'layout-shift' &&
					! entry.hadRecentInput
				) {
					console.info( entry );
					for ( const source of entry.sources ) {
						console.info( source.node );
					}
				}
			}
		},
		{
			// This is necessary in order to collect all layout shifts, even those which don't cause a bad CLS score.
			reportAllChanges: true,
		}
	);

@westonruter westonruter modified the milestones: embed-optimizer 0.3.0, embed-optimizer n.e.x.t Aug 15, 2024
@westonruter westonruter force-pushed the add/embed-optimizer-min-height-reservation branch from ec1b5c6 to 5574081 Compare October 8, 2024 23:53
* (the default sample size). Therefore, given the number (n) of visited elements on the page this will only
* end up running n*4*3 times.
*
* @todo Should there be an OD_Element class which has a $url_metric property which then in turn has a $group property. Then this would only need to return array<string, OD_Element[]>.
Copy link
Member Author

Choose a reason for hiding this comment

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

I want to address this in a follow-up PR since it will require more refactoring and I don't want to make this PR even larger.

Copy link
Member Author

Choose a reason for hiding this comment

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

See #1585

Copy link
Member

Choose a reason for hiding this comment

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

Sounds good. For the future, ideally the different plugin modifications would happen in separate PRs too. It's more convenient all in one PR, but would be better for review, plus you could still use sub-PRs like you've done before, to e.g. build something in Embed Optimizer on top of another PR change to the Optimization Detective infrastructure.

Copy link
Member Author

Choose a reason for hiding this comment

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

Yes, I agree. Sorry for the large PR. It was a bit of a journey.

$element_intersection_ratios[] = $element['intersectionRatio'];
}
$elements_max_intersection_ratios[ $xpath ] = count( $element_intersection_ratios ) > 1
? (float) max( ...$element_intersection_ratios )
Copy link
Member Author

Choose a reason for hiding this comment

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

Interestingly, if there is only one element in the array than this is a fatal error: https://3v4l.org/nQNZh

Too bad it doesn't just return the initial item.

Comment on lines +200 to +201
$denormalized_elements = $context->url_metric_group_collection->get_all_denormalized_elements()[ $embed_wrapper_xpath ] ?? array();
foreach ( $denormalized_elements as list( $group, $url_metric, $element ) ) {
Copy link
Member Author

@westonruter westonruter Oct 9, 2024

Choose a reason for hiding this comment

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

See note below on the get_all_denormalized_elements() method that I'd like to instead have $element be an OD_Element class instance which has a $url_metric property to access the OD_URL_Metric instance if is a part of. In the same way, the OD_URL_Metric class can have a $group property to indicate the OD_URL_Metric_Group it is a part of. This would eliminate the need to pass back an array of tuples and instead. So instead, to get the $group this code could do $element->url_metric->group.

This I want to do in a follow-up PR.

Copy link
Member Author

Choose a reason for hiding this comment

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

See #1585

@westonruter westonruter added the [Plugin] Image Prioritizer Issues for the Image Prioritizer plugin (dependent on Optimization Detective) label Oct 9, 2024
@westonruter westonruter self-assigned this Oct 9, 2024
@westonruter westonruter marked this pull request as ready for review October 9, 2024 01:04
Copy link

github-actions bot commented Oct 9, 2024

The following accounts have interacted with this PR and/or linked issues. I will continue to update these lists as activity occurs. You can also manually ask me to refresh this list by adding the props-bot label.

If you're merging code through a pull request on GitHub, copy and paste the following into the bottom of the merge commit message.

Co-authored-by: westonruter <westonruter@git.wordpress.org>
Co-authored-by: adamsilverstein <adamsilverstein@git.wordpress.org>
Co-authored-by: swissspidy <swissspidy@git.wordpress.org>
Co-authored-by: felixarntz <flixos90@git.wordpress.org>

To understand the WordPress project's expectations around crediting contributors, please review the Contributor Attribution page in the Core Handbook.

@westonruter
Copy link
Member Author

@felixarntz Finally! Ready for review.

Copy link
Member

@felixarntz felixarntz left a comment

Choose a reason for hiding this comment

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

@westonruter At first glance this looks good, for now mostly a few questions for clarification.

plugins/embed-optimizer/detect.js Show resolved Hide resolved
Comment on lines -47 to +54
// Wait until after the plugins have loaded and the theme has loaded. The after_setup_theme action is used
// because it is the first action that fires once the theme is loaded.
add_action( 'after_setup_theme', $bootstrap, PHP_INT_MIN );
/*
* Wait until after the plugins have loaded and the theme has loaded. The after_setup_theme action could be
* used since it is the first action that fires once the theme is loaded. However, plugins may embed this
* logic inside a module which initializes even later at the init action. The earliest action that this
* plugin has hooks for is the init action at the default priority of 10 (which includes the rest_api_init
* action), so this is why it gets initialized at priority 9.
*/
add_action( 'init', $bootstrap, 9 );
Copy link
Member

Choose a reason for hiding this comment

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

I'm not sure I understand this. Why is the timing of this (in Image Prioritizer and Embed Optimizer) relevant for other plugins? Are you thinking of extensions to those plugins (rather than extensions to Optimization Detective)?

Another thought: Why can't this use the od_init action?

Copy link
Member Author

Choose a reason for hiding this comment

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

Bootstrapping at init is all about the embeddability of these plugins inside of other plugins, which is why this whole bootstrap logic was first devised (#1081, #1159, #1185).

For example, in elementor/elementor#28844 I've created a new module for Elementor that embeds the Optimization Detective plugin and its extensions. Elementor initializes its modules at init priority 0. Since none of the functionality in Optimization Detective needs to be initialized before init, there's no need for the current use of after_setup_theme to initialize. So this postpones initialization to latest possible point which opens the door to be embedded in more places.

Copy link
Member

Choose a reason for hiding this comment

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

This still feels like a rather shaky foundation to me. For example, why does it use priority 9?

I think I'm still not following what this is meant to do: I think I understand now why certain code needs to run before this callback, but why does certain other code need to run after this callback? For instance, why couldn't we use init on 999999, or even wp_loaded or a later hook?

Copy link
Member Author

Choose a reason for hiding this comment

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

Priority 9 is one less than the default priority of 10, as noted in the comment. There are callbacks added at this default priority in the plugin. So priority 9 is the last.

If we used init with an even later priority, then we'd need to adjust the other init actions' priorities as well in this plugin, for example:

add_action( 'init', array( __CLASS__, 'register_post_type' ) );

add_action( 'init', 'embed_optimizer_add_hooks' );

If we went with wp_loaded, then we couldn't use the init action in the plugins at all, which as I understand is too late for when post types should be registered.

Copy link
Member

Choose a reason for hiding this comment

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

So basically this needs to be called as late as possible, but before any of Optimization Detective's own hooks would fire?

Copy link
Member Author

Choose a reason for hiding this comment

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

Yes, exactly. As late as possible, and hopefully after any other possible embedding plugins' modules initialize (e.g. Elementor initializes at init priority 0).

Copy link
Member

Choose a reason for hiding this comment

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

Makes sense. Since we have full control over the code in Optimization Detective, we may want to consider moving the plugin's init callbacks to a very late priority, so that we can also push this back even further, while still being on init. That way we can cater for other plugins even better, since I wouldn't find it unreasonable for other plugins to load modules on init with the default priority either.

Given the callbacks from Optimization Detective do not have to run after everything else, maybe we just use a very high number (rather than PHP_INT_MAX which I would only use if it technically has to be the last thing).

We can open a follow up PR for this though, if you agree?

Copy link
Member Author

Choose a reason for hiding this comment

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

Sounds good!

* plugin has hooks for is the init action at the default priority of 10 (which includes the rest_api_init
* action), so this is why it gets initialized at priority 9.
*/
add_action( 'init', $bootstrap, 9 );
Copy link
Member

Choose a reason for hiding this comment

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

See other comment.

Copy link
Member Author

Choose a reason for hiding this comment

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

* (the default sample size). Therefore, given the number (n) of visited elements on the page this will only
* end up running n*4*3 times.
*
* @todo Should there be an OD_Element class which has a $url_metric property which then in turn has a $group property. Then this would only need to return array<string, OD_Element[]>.
Copy link
Member

Choose a reason for hiding this comment

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

Sounds good. For the future, ideally the different plugin modifications would happen in separate PRs too. It's more convenient all in one PR, but would be better for review, plus you could still use sub-PRs like you've done before, to e.g. build something in Embed Optimizer on top of another PR change to the Optimization Detective infrastructure.

setStorageLock( getCurrentTime() );
for ( const extension of extensions ) {
if ( extension.finalize instanceof Function ) {
extension.finalize( {
Copy link
Member

Choose a reason for hiding this comment

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

Looking at this old comment thread, does it still require an update? Asking since urlMetric is still being passed below.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
blocked [Plugin] Embed Optimizer Issues for the Embed Optimizer plugin (formerly Auto Sizes) [Plugin] Image Prioritizer Issues for the Image Prioritizer plugin (dependent on Optimization Detective) [Plugin] Optimization Detective Issues for the Optimization Detective plugin [Type] Enhancement A suggestion for improvement of an existing feature
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Embeds cause layout shift which can be reduced with Optimization Detective
4 participants