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

Merged
merged 59 commits into from
Oct 14, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
59 commits
Select commit Hold shift + click to select a range
0a376c1
Introduce methods to get minumum height of element
westonruter Jul 17, 2024
8aa7e63
Set the min-height of an embed prior to it loading
westonruter Jul 29, 2024
11f98f4
Set min-height on embed-wrapper instead of figure container
westonruter Jul 30, 2024
d7cc7cf
Use 500px as a better representation of an element that could be LCP
westonruter Jul 30, 2024
48e57e8
Add test for existing style manipulation
westonruter Jul 30, 2024
0d285f2
Add helper generator method to get all elements
westonruter Jul 31, 2024
4778a3d
Try using MutationObserve to watch for embed height changes
westonruter Aug 14, 2024
1dbd4a1
Use the more appropriate ResizeObserver instead of MutationObserver
westonruter Aug 14, 2024
a4bab7e
Remove condition that breaks monitoring resizes of post embeds
westonruter Aug 14, 2024
5f0cdbe
Introduce client-side Optimization Detective extensions and move Embe…
westonruter Aug 17, 2024
0ba2d6e
Override clientBoundingRect once embed has loaded
westonruter Aug 17, 2024
5f4189d
Move jsdoc types to types.d.ts for reuse
westonruter Aug 18, 2024
bf2b3c5
Send URL metric when leaving the page
westonruter Aug 18, 2024
b9bad0d
Use boundingClientRect instead of intersectionRect in get_all_element…
westonruter Aug 18, 2024
c6b02ec
Eliminate timeout for disconneccting ResizeObsever
westonruter Aug 18, 2024
52a2260
Move extension initialization after idle callback
westonruter Aug 18, 2024
edc52fa
Fix warning when prematurely applying buffered text replacements, esp…
westonruter Aug 18, 2024
820d66d
Prepend min-height to style attribute instead of appending
westonruter Aug 19, 2024
def2aab
Use object spread
westonruter Aug 22, 2024
cd9a618
Merge branch 'trunk' of https://github.com/WordPress/performance into…
westonruter Aug 22, 2024
02c4fd9
Merge branch 'trunk' into add/embed-optimizer-min-height-reservation
westonruter Sep 13, 2024
1da219f
Use get_json_params() instead of get_params() so _wpnonce query param…
westonruter Sep 17, 2024
e34d9fe
Implement resizedBoundingClientRect extended property in schema
westonruter Sep 17, 2024
5db6f54
Fix testing JSON request
westonruter Sep 18, 2024
0fa263a
Go back to get_params() by ignoring _wpnonce
westonruter Sep 18, 2024
72b285d
Merge branch 'trunk' into add/embed-optimizer-min-height-reservation
westonruter Sep 19, 2024
2a723f7
Fix jsdoc
westonruter Sep 29, 2024
71dd914
Merge branch 'trunk' of https://github.com/WordPress/performance into…
westonruter Oct 3, 2024
29d4383
Eliminate use of deprecated property
westonruter Oct 4, 2024
a529218
Add breakpoint-specific min-heights to account for responsive embeds
westonruter Oct 4, 2024
fa8a34e
Add od_generate_media_query() helper
westonruter Oct 4, 2024
1e40f84
Break up embed tag visitor into separate methods
westonruter Oct 4, 2024
5d4d5b2
Bump alpha versions
westonruter Oct 8, 2024
5f1c2ac
Add missing short-circuit in case EMBED_OPTIMIZER_VERSION is defined
westonruter Oct 8, 2024
915e1e7
Rework bootstrap logic to wait until init priority 9 and add od_init …
westonruter Oct 8, 2024
26ae396
Add test for when resizedBoundingClientRect data not available
westonruter Oct 8, 2024
cd80ed1
Remove obsolete short-circuiting now that OD dependency version is ch…
westonruter Oct 8, 2024
bd008c5
Evolve get_all_url_metrics_groups_elements into get_all_denormalized_…
westonruter Oct 8, 2024
1b5cf13
Add Embed Optimizer tests
westonruter Oct 8, 2024
a70df28
Account for error when passing single-item array to min() or max()
westonruter Oct 8, 2024
4e48d3d
Add test for Image Prioritizer
westonruter Oct 8, 2024
d17cace
Remove now-unused method to get element minimum hights
westonruter Oct 8, 2024
5574081
Improve handling of get_updated_html
westonruter Oct 8, 2024
19c0425
Add test for get_all_denormalized_elements
westonruter Oct 9, 2024
ea36bac
Add tests for new OD code
westonruter Oct 9, 2024
01c083d
Clarify purpose of overridden get_updated_html method
westonruter Oct 9, 2024
c5d6991
Add missing since tags
westonruter Oct 9, 2024
455ef4f
Clarify handling of embed block tags and embed wrapper tags
westonruter Oct 9, 2024
a390e15
Replace tuple with assoc array
westonruter Oct 9, 2024
a760705
Add doc block for detect.js
westonruter Oct 9, 2024
7ca1fbc
Add API functions to pass to finalize callbacks to avoid direct mutat…
westonruter Oct 11, 2024
f66445f
Improve error handling
westonruter Oct 11, 2024
6e0aa8e
Harden types and disallow setting core properties
westonruter Oct 11, 2024
9e99e0d
Reuse sets for reserved property keys
westonruter Oct 11, 2024
46ba7e3
Move functions to root of module
westonruter Oct 11, 2024
0bc521e
Fix TypeScript error related to embedWrapper
westonruter Oct 11, 2024
477cc33
Add missing period to return doc
westonruter Oct 14, 2024
dca43e9
Eliminate needless ternary
westonruter Oct 14, 2024
73d2252
Rename amend to extend
westonruter Oct 14, 2024
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
147 changes: 140 additions & 7 deletions plugins/embed-optimizer/class-embed-optimizer-tag-visitor.php
Original file line number Diff line number Diff line change
Expand Up @@ -26,26 +26,82 @@ final class Embed_Optimizer_Tag_Visitor {
*/
protected $added_lazy_script = false;

/**
* Determines whether the processor is currently at a figure.wp-block-embed tag.
*
* @since n.e.x.t
*
* @param OD_HTML_Tag_Processor $processor Processor.
* @return bool Whether at the tag.
*/
private function is_embed_figure( OD_HTML_Tag_Processor $processor ): bool {
return (
'FIGURE' === $processor->get_tag()
&&
true === $processor->has_class( 'wp-block-embed' )
);
}

/**
* Determines whether the processor is currently at a div.wp-block-embed__wrapper tag (which is a child of figure.wp-block-embed).
*
* @since n.e.x.t
*
* @param OD_HTML_Tag_Processor $processor Processor.
* @return bool Whether the tag should be measured and stored in URL metrics.
*/
private function is_embed_wrapper( OD_HTML_Tag_Processor $processor ): bool {
return (
'DIV' === $processor->get_tag()
&&
true === $processor->has_class( 'wp-block-embed__wrapper' )
);
}

/**
* Visits a tag.
*
* This visitor has two entry points, the `figure.wp-block-embed` tag and its child the `div.wp-block-embed__wrapper`
* tag. For example:
*
* <figure class="wp-block-embed is-type-video is-provider-wordpress-tv wp-block-embed-wordpress-tv wp-embed-aspect-16-9 wp-has-aspect-ratio">
* <div class="wp-block-embed__wrapper">
* <iframe title="VideoPress Video Player" aria-label='VideoPress Video Player' width='750' height='422' src='https://video.wordpress.com/embed/vaWm9zO6?hd=1&amp;cover=1' frameborder='0' allowfullscreen allow='clipboard-write'></iframe>
* <script src='https://v0.wordpress.com/js/next/videopress-iframe.js?m=1674852142'></script>
* </div>
* </figure>
*
* For the `div.wp-block-embed__wrapper` tag, the only thing this tag visitor does is flag it for tracking in URL
* Metrics (by returning true). When visiting the parent `figure.wp-block-embed` tag, it does all the actual
* processing. In particular, it will use the element metrics gathered for the child `div.wp-block-embed__wrapper`
* element to set the min-height style on the `figure.wp-block-embed` to avoid layout shifts. Additionally, when
* the embed is in the initial viewport for any breakpoint, it will add preconnect links for key resources.
* Otherwise, if the embed is not in any initial viewport, it will add lazy-loading logic.
*
* @since 0.2.0
*
* @param OD_Tag_Visitor_Context $context Tag visitor context.
* @return bool Whether the tag should be tracked in URL metrics.
*/
public function __invoke( OD_Tag_Visitor_Context $context ): bool {
$processor = $context->processor;
if ( ! (
'FIGURE' === $processor->get_tag()
&&
true === $processor->has_class( 'wp-block-embed' )
) ) {

/*
* The only thing we need to do if it is a div.wp-block-embed__wrapper tag is return true so that the tag
* will get measured and stored in the URL Metrics.
*/
if ( $this->is_embed_wrapper( $processor ) ) {
return true;
}

// Short-circuit if not a figure.wp-block-embed tag.
if ( ! $this->is_embed_figure( $processor ) ) {
return false;
}

$max_intersection_ratio = $context->url_metric_group_collection->get_element_max_intersection_ratio( $processor->get_xpath() );
$this->reduce_layout_shifts( $context );

$max_intersection_ratio = $context->url_metric_group_collection->get_element_max_intersection_ratio( self::get_embed_wrapper_xpath( $processor->get_xpath() ) );
if ( $max_intersection_ratio > 0 ) {
/*
* The following embeds have been chosen for optimization due to their relative popularity among all embed types.
Expand Down Expand Up @@ -119,6 +175,83 @@ public function __invoke( OD_Tag_Visitor_Context $context ): bool {
$this->added_lazy_script = true;
}

return true;
/*
* At this point the tag is a figure.wp-block-embed, and we can return false because this does not need to be
* measured and stored in URL Metrics. Only the child div.wp-block-embed__wrapper tag is measured and stored
* so that this visitor can look up the height to set as a min-height on the figure.wp-block-embed. For more
* information on what the return values mean for tag visitors, see <https://github.com/WordPress/performance/issues/1342>.
*/
return false;
}

/**
* Gets the XPath for the embed wrapper DIV which is the sole child of the embed block FIGURE.
*
* @since n.e.x.t
*
* @param string $embed_block_xpath XPath for the embed block FIGURE tag. For example: `/*[1][self::HTML]/*[2][self::BODY]/*[1][self::FIGURE]`.
* @return string XPath for the child DIV. For example: `/*[1][self::HTML]/*[2][self::BODY]/*[1][self::FIGURE]/*[1][self::DIV]`
*/
private static function get_embed_wrapper_xpath( string $embed_block_xpath ): string {
return $embed_block_xpath . '/*[1][self::DIV]';
}

/**
* Reduces layout shifts.
*
* @since n.e.x.t
*
* @param OD_Tag_Visitor_Context $context Tag visitor context, with the cursor currently at an embed block.
*/
private function reduce_layout_shifts( OD_Tag_Visitor_Context $context ): void {
$processor = $context->processor;
$embed_wrapper_xpath = self::get_embed_wrapper_xpath( $processor->get_xpath() );

/**
* Collection of the minimum heights for the element with each group keyed by the minimum viewport with.
*
* @var array<int, array{group: OD_URL_Metric_Group, height: int}> $minimums
*/
$minimums = array();

$denormalized_elements = $context->url_metric_group_collection->get_all_denormalized_elements()[ $embed_wrapper_xpath ] ?? array();
foreach ( $denormalized_elements as list( $group, $url_metric, $element ) ) {
Comment on lines +217 to +218
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

if ( ! isset( $element['resizedBoundingClientRect'] ) ) {
continue;
}
$group_min_width = $group->get_minimum_viewport_width();
if ( ! isset( $minimums[ $group_min_width ] ) ) {
$minimums[ $group_min_width ] = array(
'group' => $group,
'height' => $element['resizedBoundingClientRect']['height'],
);
} else {
$minimums[ $group_min_width ]['height'] = min(
$minimums[ $group_min_width ]['height'],
$element['resizedBoundingClientRect']['height']
);
}
}

// Add style rules to set the min-height for each viewport group.
if ( count( $minimums ) > 0 ) {
$element_id = $processor->get_attribute( 'id' );
if ( ! is_string( $element_id ) ) {
$element_id = 'embed-optimizer-' . md5( $processor->get_xpath() );
$processor->set_attribute( 'id', $element_id );
}

$style_rules = array();
foreach ( $minimums as $minimum ) {
$style_rules[] = sprintf(
'@media %s { #%s { min-height: %dpx; } }',
od_generate_media_query( $minimum['group']->get_minimum_viewport_width(), $minimum['group']->get_maximum_viewport_width() ),
$element_id,
$minimum['height']
);
}

$processor->append_head_html( sprintf( "<style>\n%s\n</style>\n", join( "\n", $style_rules ) ) );
}
}
}
124 changes: 124 additions & 0 deletions plugins/embed-optimizer/detect.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
/**
* Embed Optimizer module for Optimization Detective
*
* When a URL metric is being collected by Optimization Detective, this module adds a ResizeObserver to keep track of
* the changed heights for embed blocks. This data is extended/amended onto the element data of the pending URL metric
* when it is submitted for storage.
*/

const consoleLogPrefix = '[Embed Optimizer]';
felixarntz marked this conversation as resolved.
Show resolved Hide resolved

/**
* @typedef {import("../optimization-detective/types.d.ts").ElementData} ElementMetrics
* @typedef {import("../optimization-detective/types.d.ts").URLMetric} URLMetric
* @typedef {import("../optimization-detective/types.d.ts").Extension} Extension
* @typedef {import("../optimization-detective/types.d.ts").InitializeCallback} InitializeCallback
* @typedef {import("../optimization-detective/types.d.ts").InitializeArgs} InitializeArgs
* @typedef {import("../optimization-detective/types.d.ts").FinalizeArgs} FinalizeArgs
* @typedef {import("../optimization-detective/types.d.ts").FinalizeCallback} FinalizeCallback
* @typedef {import("../optimization-detective/types.d.ts").ExtendedElementData} ExtendedElementData
*/

/**
* Logs a message.
*
* @param {...*} message
*/
function log( ...message ) {
// eslint-disable-next-line no-console
console.log( consoleLogPrefix, ...message );
}

/**
* Logs an error.
*
* @param {...*} message
*/
function error( ...message ) {
// eslint-disable-next-line no-console
console.error( consoleLogPrefix, ...message );
}

/**
* Embed element heights.
*
* @type {Map<string, DOMRectReadOnly>}
*/
const loadedElementContentRects = new Map();

/**
* Initializes extension.
*
* @type {InitializeCallback}
* @param {InitializeArgs} args Args.
*/
export function initialize( { isDebug } ) {
/** @type NodeListOf<HTMLDivElement> */
const embedWrappers = document.querySelectorAll(
'.wp-block-embed > .wp-block-embed__wrapper[data-od-xpath]'
);

for ( const embedWrapper of embedWrappers ) {
monitorEmbedWrapperForResizes( embedWrapper, isDebug );
}

if ( isDebug ) {
log( 'Loaded embed content rects:', loadedElementContentRects );
}
}

/**
* Finalizes extension.
*
* @type {FinalizeCallback}
* @param {FinalizeArgs} args Args.
*/
export async function finalize( {
isDebug,
getElementData,
extendElementData,
} ) {
for ( const [ xpath, domRect ] of loadedElementContentRects.entries() ) {
try {
extendElementData( xpath, {
resizedBoundingClientRect: domRect,
} );
if ( isDebug ) {
const elementData = getElementData( xpath );
log(
`boundingClientRect for ${ xpath } resized:`,
elementData.boundingClientRect,
'=>',
domRect
);
}
} catch ( err ) {
error(
`Failed to extend element data for ${ xpath } with resizedBoundingClientRect:`,
domRect,
err
);
}
}
}

/**
* Monitors embed wrapper for resizes.
*
* @param {HTMLDivElement} embedWrapper Embed wrapper DIV.
* @param {boolean} isDebug Whether debug.
*/
function monitorEmbedWrapperForResizes( embedWrapper, isDebug ) {
if ( ! ( 'odXpath' in embedWrapper.dataset ) ) {
throw new Error( 'Embed wrapper missing data-od-xpath attribute.' );
}
const xpath = embedWrapper.dataset.odXpath;
const observer = new ResizeObserver( ( entries ) => {
const [ entry ] = entries;
loadedElementContentRects.set( xpath, entry.contentRect );
if ( isDebug ) {
log( `Resized element ${ xpath }:`, entry.contentRect );
}
} );
observer.observe( embedWrapper, { box: 'content-box' } );
}
Loading
Loading