Skip to content

Commit

Permalink
Add client-side extension to Image Prioritizer to detect LCP external…
Browse files Browse the repository at this point in the history
… background images
  • Loading branch information
westonruter committed Nov 22, 2024
1 parent cee5c24 commit da12ebd
Show file tree
Hide file tree
Showing 4 changed files with 217 additions and 0 deletions.
164 changes: 164 additions & 0 deletions plugins/image-prioritizer/detect.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
/**
* Image Prioritizer module for Optimization Detective
*
* TODO: Description.
*/

const consoleLogPrefix = '[Image Prioritizer]';

/**
* Detected LCP external background image candidates.
*
* @type {Array<{url: string, tagName: string, parentTagName: string, id: string, className: 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 || resourceEntry.initiatorType !== 'css' ) {
return;
}

// 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,
tagName: entry.element.tagName,
parentTagName: entry.element.parentElement.tagName,
id: entry.id,
className: entry.element.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 } );
}
47 changes: 47 additions & 0 deletions plugins/image-prioritizer/helper.php
Original file line number Diff line number Diff line change
Expand Up @@ -95,3 +95,50 @@ function image_prioritizer_get_lazy_load_script(): string {

return $script;
}

/**
* Filters the list of Optimization Detective extension module URLs to include the extension for Image Prioritizer.
*
* @since n.e.x.t
*
* @param string[]|mixed $extension_module_urls Extension module URLs.
* @return string[] Extension module URLs.
*/
function image_prioritizer_filter_extension_module_urls( $extension_module_urls ): array {
if ( ! is_array( $extension_module_urls ) ) {
$extension_module_urls = array();
}
$extension_module_urls[] = add_query_arg( 'ver', IMAGE_PRIORITIZER_VERSION, plugin_dir_url( __FILE__ ) . sprintf( 'detect%s.js', wp_scripts_get_suffix() ) );
return $extension_module_urls;
}

/**
* Filters additional properties for the element item schema for Optimization Detective.
*
* @since n.e.x.t
*
* @param array<string, array{type: string}> $additional_properties Additional properties.
* @return array<string, array{type: string}> Additional properties.
*/
function image_prioritizer_add_element_item_schema_properties( array $additional_properties ): array {
// TODO: Validation of the URL.
$additional_properties['lcpElementExternalBackgroundImage'] = array(
'type' => 'object',
'properties' => array_fill_keys(
array(
'url',
'tagName',
'parentTagName',
'id',
'className',
),
array(
// TODO: Add constraints on length.
// TODO: Add constraints on formats and patterns.
'type' => 'string',
'required' => true,
)
),
);
return $additional_properties;
}
2 changes: 2 additions & 0 deletions plugins/image-prioritizer/hooks.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,5 @@
}

add_action( 'od_init', 'image_prioritizer_init' );
add_filter( 'od_extension_module_urls', 'image_prioritizer_filter_extension_module_urls' );
add_filter( 'od_url_metric_schema_root_additional_properties', 'image_prioritizer_add_element_item_schema_properties' );
4 changes: 4 additions & 0 deletions webpack.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,10 @@ const imagePrioritizer = ( env ) => {
plugins: [
new CopyWebpackPlugin( {
patterns: [
{
from: `${ pluginDir }/detect.js`,
to: `${ pluginDir }/detect.min.js`,
},
{
from: `${ pluginDir }/lazy-load.js`,
to: `${ pluginDir }/lazy-load.min.js`,
Expand Down

0 comments on commit da12ebd

Please sign in to comment.