Skip to content

Commit

Permalink
Merge pull request #1373 from WordPress/add/embed-optimizer-min-heigh…
Browse files Browse the repository at this point in the history
…t-reservation

Leverage URL metrics to reserve space for embeds to reduce CLS
  • Loading branch information
westonruter authored Oct 14, 2024
2 parents 94fc5e7 + 73d2252 commit 0ae166b
Show file tree
Hide file tree
Showing 36 changed files with 1,474 additions and 270 deletions.
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 ) ) {
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]';

/**
* @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

0 comments on commit 0ae166b

Please sign in to comment.