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

Preload image URLs for LCP elements with external background images #1697

Open
wants to merge 17 commits into
base: trunk
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 6 commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
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
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,25 @@
/**
* Tag visitor that optimizes elements with background-image styles.
*
* @phpstan-type LcpElementExternalBackgroundImage array{
* url: non-empty-string,
* tag: non-empty-string,
* id: string|null,
* class: string|null,
* }
*
* @since 0.1.0
* @access private
*/
final class Image_Prioritizer_Background_Image_Styled_Tag_Visitor extends Image_Prioritizer_Tag_Visitor {

/**
* Tuples of URL Metric group and the common LCP element external background image.
*
* @var array<array{OD_URL_Metric_Group, LcpElementExternalBackgroundImage}>
*/
private $group_common_lcp_element_external_background_images = array();

/**
* Visits a tag.
*
Expand Down Expand Up @@ -49,28 +63,122 @@ public function __invoke( OD_Tag_Visitor_Context $context ): bool {
}

if ( is_null( $background_image_url ) ) {
$this->maybe_preload_external_lcp_background_image( $context );
return false;
}

$xpath = $processor->get_xpath();

// If this element is the LCP (for a breakpoint group), add a preload link for it.
foreach ( $context->url_metric_group_collection->get_groups_by_lcp_element( $xpath ) as $group ) {
$link_attributes = array(
$this->add_preload_link( $context->link_collection, $group, $background_image_url );
}

return true;
}

/**
* Gets the common LCP element external background image for a URL Metric group.
*
* @since n.e.x.t
*
* @param OD_URL_Metric_Group $group Group.
* @return LcpElementExternalBackgroundImage|null
*/
private function get_common_lcp_element_external_background_image( OD_URL_Metric_Group $group ): ?array {

// If the group is not fully populated, we don't have enough URL Metrics to reliably know whether the background image is consistent across page loads.
// This is intentionally not using $group->is_complete() because we still will use stale URL Metrics in the calculation.
// TODO: There should be a $group->get_sample_size() method.
if ( $group->count() !== od_get_url_metrics_breakpoint_sample_size() ) {
return null;
}

$previous_lcp_element_external_background_image = null;
foreach ( $group as $url_metric ) {
/**
* Stored data.
*
* @var LcpElementExternalBackgroundImage|null $lcp_element_external_background_image
*/
$lcp_element_external_background_image = $url_metric->get( 'lcpElementExternalBackgroundImage' );
if ( ! is_array( $lcp_element_external_background_image ) ) {
return null;
}
if ( null !== $previous_lcp_element_external_background_image && $previous_lcp_element_external_background_image !== $lcp_element_external_background_image ) {
return null;
}
$previous_lcp_element_external_background_image = $lcp_element_external_background_image;
}

return $previous_lcp_element_external_background_image;
}

/**
* Maybe preloads external background image.
*
* @since n.e.x.t
*
* @param OD_Tag_Visitor_Context $context Context.
*/
private function maybe_preload_external_lcp_background_image( OD_Tag_Visitor_Context $context ): void {
static $did_collect_data = false;
if ( false === $did_collect_data ) {
foreach ( $context->url_metric_group_collection as $group ) {
$common = $this->get_common_lcp_element_external_background_image( $group );
if ( is_array( $common ) ) {
$this->group_common_lcp_element_external_background_images[] = array( $group, $common );
}
}
$did_collect_data = true;
}

// There are no common LCP background images, so abort.
if ( count( $this->group_common_lcp_element_external_background_images ) === 0 ) {
return;
}

$processor = $context->processor;
$tag_name = strtoupper( (string) $processor->get_tag() );
foreach ( $this->group_common_lcp_element_external_background_images as $i => list( $group, $common ) ) {
if (
// Note that the browser may send a lower-case tag name in the case of XHTML or embedded SVG/MathML, but
// the HTML Tag Processor is currently normalizing to all upper-case. The HTML Processor on the other
// hand may return the expected case.
strtoupper( $common['tag'] ) === $tag_name
&&
$processor->get_attribute( 'id' ) === $common['id'] // May be checking equality with null.
&&
$processor->get_attribute( 'class' ) === $common['class'] // May be checking equality with null.
) {
$this->add_preload_link( $context->link_collection, $group, $common['url'] );

// Now that the preload link has been added, eliminate the entry to stop looking for it while iterating over the rest of the document.
unset( $this->group_common_lcp_element_external_background_images[ $i ] );
}
}
}

/**
* Adds an image preload link for the group.
*
* @since n.e.x.t
*
* @param OD_Link_Collection $link_collection Link collection.
* @param OD_URL_Metric_Group $group URL Metric group.
* @param non-empty-string $url Image URL.
*/
private function add_preload_link( OD_Link_Collection $link_collection, OD_URL_Metric_Group $group, string $url ): void {
$link_collection->add_link(
array(
'rel' => 'preload',
'fetchpriority' => 'high',
'as' => 'image',
'href' => $background_image_url,
'href' => $url,
'media' => 'screen',
);

$context->link_collection->add_link(
$link_attributes,
$group->get_minimum_viewport_width(),
$group->get_maximum_viewport_width()
);
}

return true;
),
$group->get_minimum_viewport_width(),
$group->get_maximum_viewport_width()
);
}
}
193 changes: 193 additions & 0 deletions plugins/image-prioritizer/detect.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,193 @@
/**
* Image Prioritizer module for Optimization Detective
*
* TODO: Description.
*/

const consoleLogPrefix = '[Image Prioritizer]';

/**
* Detected LCP external background image candidates.
*
* @type {Array<{url: string, tag: string, id: string, class: string}>}
*/
const externalBackgroundImages = [];

/**
* @typedef {import("web-vitals").LCPMetric} LCPMetric
* @typedef {import("../optimization-detective/types.ts").InitializeCallback} InitializeCallback
* @typedef {import("../optimization-detective/types.ts").InitializeArgs} InitializeArgs
* @typedef {import("../optimization-detective/types.ts").FinalizeArgs} FinalizeArgs
* @typedef {import("../optimization-detective/types.ts").FinalizeCallback} FinalizeCallback
*/

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

/**
* Initializes extension.
*
* @since n.e.x.t
*
* @type {InitializeCallback}
* @param {InitializeArgs} args Args.
*/
export function initialize( { isDebug, webVitalsLibrarySrc } ) {
import( webVitalsLibrarySrc ).then( ( { onLCP } ) => {
onLCP(
( /** @type {LCPMetric} */ metric ) => {
handleLCPMetric( metric, isDebug );
},
{
// This avoids needing to click to finalize LCP candidate. While this is helpful for testing, it also
// ensures that we always get an LCP candidate reported. Otherwise, the callback may never fire if the
// user never does a click or keydown, per <https://github.com/GoogleChrome/web-vitals/blob/07f6f96/src/onLCP.ts#L99-L107>.
reportAllChanges: true,
}
);
} );
}

/**
* Gets the performance resource entry for a given URL.
*
* @since n.e.x.t
*
* @param {string} url - Resource URL.
* @return {PerformanceResourceTiming|null} Resource entry or null.
*/
function getPerformanceResourceByURL( url ) {
const entries =
/** @type PerformanceResourceTiming[] */ performance.getEntriesByType(
'resource'
);
for ( const entry of entries ) {
if ( entry.name === url ) {
return entry;
}
}
return null;
}

/**
* Handles a new LCP metric being reported.
*
* @since n.e.x.t
*
* @param {LCPMetric} metric - LCP Metric.
* @param {boolean} isDebug - Whether in debug mode.
*/
function handleLCPMetric( metric, isDebug ) {
for ( const entry of metric.entries ) {
// Look only for LCP entries that have a URL and a corresponding element which is not an IMG or VIDEO.
if (
! entry.url ||
! ( entry.element instanceof HTMLElement ) ||
entry.element instanceof HTMLImageElement ||
entry.element instanceof HTMLVideoElement
) {
continue;
}

// Always ignore data: URLs.
if ( entry.url.startsWith( 'data:' ) ) {
continue;
}

// Skip elements that have the background image defined inline.
// These are handled by Image_Prioritizer_Background_Image_Styled_Tag_Visitor.
if ( entry.element.style.backgroundImage ) {
continue;
}

// Now only consider proceeding with the URL if its loading was initiated with CSS.
const resourceEntry = getPerformanceResourceByURL( entry.url );
if (
! resourceEntry ||
! [ 'css', 'link' ].includes( resourceEntry.initiatorType ) // TODO: When is it css and when is it link?
) {
if ( isDebug ) {
// eslint-disable-next-line no-console
console.warn(
consoleLogPrefix,
'Skipped considering URL do due to resource initiatorType:',
entry.url,
resourceEntry.initiatorType
);
}
return;
}

// Skip URLs that are excessively long. This is the maxLength defined in image_prioritizer_add_element_item_schema_properties().
if ( entry.url.length > 500 ) {
if ( isDebug ) {
log( `Skipping very long URL: ${ entry.url }` );
}
return;
}

// Note that getAttribute() is used instead of properties so that null can be returned in case of an absent attribute.
let id = entry.element.getAttribute( 'id' );
if ( null !== id ) {
id = id.substring( 0, 100 ); // This is the maxLength defined in image_prioritizer_add_element_item_schema_properties().
}
let className = entry.element.getAttribute( 'class' );
if ( null !== className ) {
className = className.substring( 0, 500 ); // This is the maxLength defined in image_prioritizer_add_element_item_schema_properties().
}

// The id and className allow the tag visitor to detect whether the element is still in the document.
// This is used instead of having a full XPath which is likely not available since the tag visitor would not
// know to return true for this element since it has no awareness of which elements have external backgrounds.
const externalBackgroundImage = {
url: entry.url,
tag: entry.element.tagName,
id,
class: className,
};

if ( isDebug ) {
log(
'Detected external LCP background image:',
externalBackgroundImage
);
}

externalBackgroundImages.push( externalBackgroundImage );
}
}

/**
* Finalizes extension.
*
* @since n.e.x.t
*
* @type {FinalizeCallback}
* @param {FinalizeArgs} args Args.
*/
export async function finalize( { extendRootData, isDebug } ) {
if ( externalBackgroundImages.length === 0 ) {
return;
}

// Get the last detected external background image which is going to be for the LCP element (or very likely will be).
const lcpElementExternalBackgroundImage = externalBackgroundImages.pop();

if ( isDebug ) {
log(
'Sending external background image for LCP element:',
lcpElementExternalBackgroundImage
);
}

extendRootData( { lcpElementExternalBackgroundImage } );
}
Loading