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

Prepare 3.7.0 release #1754

Merged
merged 8 commits into from
Dec 18, 2024
Merged

Prepare 3.7.0 release #1754

merged 8 commits into from
Dec 18, 2024

Conversation

westonruter
Copy link
Member

@westonruter westonruter commented Dec 17, 2024

Fixes #1739
Previously #1657

  • Bump versions
  • Update since n.e.x.t
  • Add changelogs to readmes via npm run readme
  • Update WWO readme with current integrations
  • Update code references in Optimization Detective readme
  • Amend changelogs

The following plugins are included in this release:

  1. auto-sizes: 1.4.0
  2. dominant-color-images: 1.2.0
  3. embed-optimizer: 0.4.0
  4. image-prioritizer: 0.3.0
  5. optimization-detective: 0.9.0
  6. performance-lab: 3.7.0
  7. web-worker-offloading: 0.2.0
  8. webp-uploads: 2.4.0

@westonruter westonruter added this to the performance-lab 3.7.0 milestone Dec 17, 2024
@westonruter westonruter added [Type] Documentation Documentation to be added or enhanced Infrastructure Issues for the overall performance plugin infrastructure skip changelog PRs that should not be mentioned in changelogs labels Dec 17, 2024
@westonruter
Copy link
Member Author

Pending release diffs:

auto-sizes

Important

Stable tag change: 1.3.0 → 1.4.0

svn status:

M       auto-sizes.php
M       hooks.php
?       includes
M       readme.txt
svn diff
Index: auto-sizes.php
===================================================================
--- auto-sizes.php	(revision 3209553)
+++ auto-sizes.php	(working copy)
@@ -3,9 +3,9 @@
  * Plugin Name: Enhanced Responsive Images
  * Plugin URI: https://github.com/WordPress/performance/tree/trunk/plugins/auto-sizes
  * Description: Improves responsive images with better sizes calculations and auto-sizes for lazy-loaded images.
- * Requires at least: 6.5
+ * Requires at least: 6.6
  * Requires PHP: 7.2
- * Version: 1.3.0
+ * Version: 1.4.0
  * Author: WordPress Performance Team
  * Author URI: https://make.wordpress.org/performance/
  * License: GPLv2 or later
@@ -25,6 +25,8 @@
 	return;
 }
 
-define( 'IMAGE_AUTO_SIZES_VERSION', '1.3.0' );
+define( 'IMAGE_AUTO_SIZES_VERSION', '1.4.0' );
 
+require_once __DIR__ . '/includes/auto-sizes.php';
+require_once __DIR__ . '/includes/improve-calculate-sizes.php';
 require_once __DIR__ . '/hooks.php';
Index: hooks.php
===================================================================
--- hooks.php	(revision 3209553)
+++ hooks.php	(working copy)
@@ -11,102 +11,6 @@
 }
 
 /**
- * Adds auto to the sizes attribute to the image, if applicable.
- *
- * @since 1.0.0
- *
- * @param array<string, string>|mixed $attr Attributes for the image markup.
- * @return array<string, string> The filtered attributes for the image markup.
- */
-function auto_sizes_update_image_attributes( $attr ): array {
-	if ( ! is_array( $attr ) ) {
-		$attr = array();
-	}
-
-	// Bail early if the image is not lazy-loaded.
-	if ( ! isset( $attr['loading'] ) || 'lazy' !== $attr['loading'] ) {
-		return $attr;
-	}
-
-	// Bail early if the image is not responsive.
-	if ( ! isset( $attr['sizes'] ) ) {
-		return $attr;
-	}
-
-	// Don't add 'auto' to the sizes attribute if it already exists.
-	if ( auto_sizes_attribute_includes_valid_auto( $attr['sizes'] ) ) {
-		return $attr;
-	}
-
-	$attr['sizes'] = 'auto, ' . $attr['sizes'];
-
-	return $attr;
-}
-
-/**
- * Adds auto to the sizes attribute to the image, if applicable.
- *
- * @since 1.0.0
- *
- * @param string|mixed $html The HTML image tag markup being filtered.
- * @return string The filtered HTML image tag markup.
- */
-function auto_sizes_update_content_img_tag( $html ): string {
-	if ( ! is_string( $html ) ) {
-		$html = '';
-	}
-
-	$processor = new WP_HTML_Tag_Processor( $html );
-
-	// Bail if there is no IMG tag.
-	if ( ! $processor->next_tag( array( 'tag_name' => 'IMG' ) ) ) {
-		return $html;
-	}
-
-	// Bail early if the image is not lazy-loaded.
-	$value = $processor->get_attribute( 'loading' );
-	if ( ! is_string( $value ) || 'lazy' !== strtolower( trim( $value, " \t\f\r\n" ) ) ) {
-		return $html;
-	}
-
-	$sizes = $processor->get_attribute( 'sizes' );
-
-	// Bail early if the image is not responsive.
-	if ( ! is_string( $sizes ) ) {
-		return $html;
-	}
-
-	// Don't add 'auto' to the sizes attribute if it already exists.
-	if ( auto_sizes_attribute_includes_valid_auto( $sizes ) ) {
-		return $html;
-	}
-
-	$processor->set_attribute( 'sizes', "auto, $sizes" );
-	return $processor->get_updated_html();
-}
-
-// Skip loading plugin filters if WordPress Core already loaded the functionality.
-if ( ! function_exists( 'wp_sizes_attribute_includes_valid_auto' ) ) {
-	add_filter( 'wp_get_attachment_image_attributes', 'auto_sizes_update_image_attributes' );
-	add_filter( 'wp_content_img_tag', 'auto_sizes_update_content_img_tag' );
-}
-
-/**
- * Checks whether the given 'sizes' attribute includes the 'auto' keyword as the first item in the list.
- *
- * Per the HTML spec, if present it must be the first entry.
- *
- * @since 1.2.0
- *
- * @param string $sizes_attr The 'sizes' attribute value.
- * @return bool True if the 'auto' keyword is present, false otherwise.
- */
-function auto_sizes_attribute_includes_valid_auto( string $sizes_attr ): bool {
-	list( $first_size ) = explode( ',', $sizes_attr, 2 );
-	return 'auto' === strtolower( trim( $first_size, " \t\f\r\n" ) );
-}
-
-/**
  * Displays the HTML generator tag for the plugin.
  *
  * See {@see 'wp_head'}.
@@ -120,135 +24,19 @@
 add_action( 'wp_head', 'auto_sizes_render_generator' );
 
 /**
- * Gets the smaller image size if the layout width is bigger.
- *
- * It will return the smaller image size and return "px" if the layout width
- * is something else, e.g. min(640px, 90vw) or 90vw.
- *
- * @since 1.1.0
- *
- * @param string $layout_width The layout width.
- * @param int    $image_width  The image width.
- * @return string The proper width after some calculations.
+ * Filters related to the auto-sizes functionality.
  */
-function auto_sizes_get_width( string $layout_width, int $image_width ): string {
-	if ( str_ends_with( $layout_width, 'px' ) ) {
-		return $image_width > (int) $layout_width ? $layout_width : $image_width . 'px';
-	}
-	return $image_width . 'px';
+// Skip loading plugin filters if WordPress Core already loaded the functionality.
+if ( ! function_exists( 'wp_img_tag_add_auto_sizes' ) ) {
+	add_filter( 'wp_get_attachment_image_attributes', 'auto_sizes_update_image_attributes' );
+	add_filter( 'wp_content_img_tag', 'auto_sizes_update_content_img_tag' );
 }
 
 /**
- * Filter the sizes attribute for images to improve the default calculation.
- *
- * @since 1.1.0
- *
- * @param string                                                   $content      The block content about to be rendered.
- * @param array{ attrs?: array{ align?: string, width?: string } } $parsed_block The parsed block.
- * @return string The updated block content.
+ * Filters related to the improved image sizes functionality.
  */
-function auto_sizes_filter_image_tag( string $content, array $parsed_block ): string {
-	$processor = new WP_HTML_Tag_Processor( $content );
-	$has_image = $processor->next_tag( array( 'tag_name' => 'img' ) );
-
-	// Only update the markup if an image is found.
-	if ( $has_image ) {
-		$processor->set_attribute( 'data-needs-sizes-update', true );
-		if ( isset( $parsed_block['attrs']['align'] ) ) {
-			$processor->set_attribute( 'data-align', $parsed_block['attrs']['align'] );
-		}
-
-		// Resize image width.
-		if ( isset( $parsed_block['attrs']['width'] ) ) {
-			$processor->set_attribute( 'data-resize-width', $parsed_block['attrs']['width'] );
-		}
-
-		$content = $processor->get_updated_html();
-	}
-	return $content;
-}
-add_filter( 'render_block_core/image', 'auto_sizes_filter_image_tag', 10, 2 );
-add_filter( 'render_block_core/cover', 'auto_sizes_filter_image_tag', 10, 2 );
-
-/**
- * Filter the sizes attribute for images to improve the default calculation.
- *
- * @since 1.1.0
- *
- * @param string $content The block content about to be rendered.
- * @return string The updated block content.
- */
-function auto_sizes_improve_image_sizes_attributes( string $content ): string {
-	$processor = new WP_HTML_Tag_Processor( $content );
-	if ( ! $processor->next_tag( array( 'tag_name' => 'img' ) ) ) {
-		return $content;
-	}
-
-	$remove_data_attributes = static function () use ( $processor ): void {
-		$processor->remove_attribute( 'data-needs-sizes-update' );
-		$processor->remove_attribute( 'data-align' );
-		$processor->remove_attribute( 'data-resize-width' );
-	};
-
-	// Bail early if the responsive images are disabled.
-	if ( null === $processor->get_attribute( 'sizes' ) ) {
-		$remove_data_attributes();
-		return $processor->get_updated_html();
-	}
-
-	// Skips second time parsing if already processed.
-	if ( null === $processor->get_attribute( 'data-needs-sizes-update' ) ) {
-		return $content;
-	}
-
-	$align = $processor->get_attribute( 'data-align' );
-
-	// Retrieve width from the image tag itself.
-	$image_width = $processor->get_attribute( 'width' );
-	if ( ! is_string( $image_width ) && ! in_array( $align, array( 'full', 'wide' ), true ) ) {
-		return $content;
-	}
-
-	$layout = wp_get_global_settings( array( 'layout' ) );
-
-	$sizes = null;
-	// Handle different alignment use cases.
-	switch ( $align ) {
-		case 'full':
-			$sizes = '100vw';
-			break;
-
-		case 'wide':
-			if ( array_key_exists( 'wideSize', $layout ) ) {
-				$sizes = sprintf( '(max-width: %1$s) 100vw, %1$s', $layout['wideSize'] );
-			}
-			break;
-
-		case 'left':
-		case 'right':
-		case 'center':
-			// Resize image width.
-			$image_width = $processor->get_attribute( 'data-resize-width' ) ?? $image_width;
-			$sizes       = sprintf( '(max-width: %1$dpx) 100vw, %1$dpx', $image_width );
-			break;
-
-		default:
-			if ( array_key_exists( 'contentSize', $layout ) ) {
-				// Resize image width.
-				$image_width = $processor->get_attribute( 'data-resize-width' ) ?? $image_width;
-				$width       = auto_sizes_get_width( $layout['contentSize'], (int) $image_width );
-				$sizes       = sprintf( '(max-width: %1$s) 100vw, %1$s', $width );
-			}
-			break;
-	}
-
-	if ( is_string( $sizes ) ) {
-		$processor->set_attribute( 'sizes', $sizes );
-	}
-
-	$remove_data_attributes();
-
-	return $processor->get_updated_html();
-}
-// Run filter prior to auto sizes "auto_sizes_update_content_img_tag" filter.
-add_filter( 'wp_content_img_tag', 'auto_sizes_improve_image_sizes_attributes', 9 );
+add_filter( 'the_content', 'auto_sizes_prime_attachment_caches', 9 ); // This must run before 'do_blocks', which runs at priority 9.
+add_filter( 'render_block_core/image', 'auto_sizes_filter_image_tag', 10, 3 );
+add_filter( 'render_block_core/cover', 'auto_sizes_filter_image_tag', 10, 3 );
+add_filter( 'get_block_type_uses_context', 'auto_sizes_filter_uses_context', 10, 2 );
+add_filter( 'render_block_context', 'auto_sizes_filter_render_block_context', 10, 2 );
Index: readme.txt
===================================================================
--- readme.txt	(revision 3209553)
+++ readme.txt	(working copy)
@@ -1,8 +1,8 @@
 === Enhanced Responsive Images ===
 
 Contributors: wordpressdotorg
-Tested up to: 6.6
-Stable tag:   1.3.0
+Tested up to: 6.7
+Stable tag:   1.4.0
 License:      GPLv2 or later
 License URI:  https://www.gnu.org/licenses/gpl-2.0.html
 Tags:         performance, images, auto-sizes
@@ -52,6 +52,23 @@
 
 == Changelog ==
 
+= 1.4.0 =
+
+**Features**
+
+* Accurate Sizes: Incorporate layout constraints in image sizes calculations. ([1738](https://github.com/WordPress/performance/pull/1738))
+
+**Enhancements**
+
+* Accurate sizes: Pass parent alignment context to images. ([1701](https://github.com/WordPress/performance/pull/1701))
+* Accurate sizes: Reorganize file structure by feature. ([1699](https://github.com/WordPress/performance/pull/1699))
+* Accurate sizes: Support relative alignment widths. ([1737](https://github.com/WordPress/performance/pull/1737))
+* Remove `auto_sizes_get_layout_settings()`. ([1743](https://github.com/WordPress/performance/pull/1743))
+
+**Bug Fixes**
+
+* Accurate sizes: Disable layout calculations for classic themes. ([1744](https://github.com/WordPress/performance/pull/1744))
+
 = 1.3.0 =
 
 **Enhancements**

dominant-color-images

Important

Stable tag change: 1.1.2 → 1.2.0

svn status:

M       hooks.php
M       load.php
M       readme.txt
svn diff
Index: hooks.php
===================================================================
--- hooks.php	(revision 3209553)
+++ hooks.php	(working copy)
@@ -186,3 +186,94 @@
 	echo '<meta name="generator" content="dominant-color-images ' . esc_attr( DOMINANT_COLOR_IMAGES_VERSION ) . '">' . "\n";
 }
 add_action( 'wp_head', 'dominant_color_render_generator' );
+
+/**
+ * Adds inline CSS for dominant color styling in the WordPress admin area.
+ *
+ * This function registers and enqueues a custom style handle, then adds inline CSS
+ * to apply background color based on the dominant color for attachment previews
+ * in the WordPress admin interface.
+ *
+ * @since 1.2.0
+ */
+function dominant_color_admin_inline_style(): void {
+	$handle = 'dominant-color-admin-styles';
+	// phpcs:ignore WordPress.WP.EnqueuedResourceParameters.MissingVersion -- Version not used since this handle is only registered for adding an inline style.
+	wp_register_style( $handle, false );
+	wp_enqueue_style( $handle );
+	$custom_css = '.wp-core-ui .attachment-preview[data-dominant-color]:not(.has-transparency) { background-color: var(--dominant-color); }';
+	wp_add_inline_style( $handle, $custom_css );
+}
+add_action( 'admin_enqueue_scripts', 'dominant_color_admin_inline_style' );
+
+/**
+ * Adds a script to the admin footer to modify the attachment template.
+ *
+ * This function injects a JavaScript snippet into the admin footer that modifies
+ * the attachment template. It adds attributes for dominant color and transparency
+ * to the template, allowing these properties to be displayed in the media library.
+ *
+ * @since 1.2.0
+ * @see wp_print_media_templates()
+ */
+function dominant_color_admin_script(): void {
+	?>
+	<script type="module">
+		const tmpl = document.getElementById( 'tmpl-attachment' );
+		if ( tmpl ) {
+			tmpl.textContent = tmpl.textContent.replace( /^\s*<div[^>]*?(?=>)/, ( match ) => {
+				let replaced = match.replace( /\sclass="/, " class=\"{{ data.hasTransparency ? 'has-transparency' : 'not-transparent' }} " );
+				replaced += ' data-dominant-color="{{ data.dominantColor }}"';
+				replaced += ' data-has-transparency="{{ data.hasTransparency }}"';
+				let hasStyleAttr = false;
+				const colorStyle = "{{ data.dominantColor ? '--dominant-color: #' + data.dominantColor + ';' : '' }}";
+				replaced = replaced.replace( /\sstyle="/, ( styleMatch ) => {
+					hasStyleAttr = true;
+					return styleMatch + colorStyle;
+				} );
+				if ( ! hasStyleAttr ) {
+					replaced += ` style="${colorStyle}"`;
+				}
+				return replaced;
+			} );
+		}
+	</script>
+	<?php
+}
+add_action( 'admin_print_footer_scripts', 'dominant_color_admin_script' );
+
+/**
+ * Prepares attachment data for JavaScript, adding dominant color and transparency information.
+ *
+ * This function enhances the attachment data for JavaScript by including information about
+ * the dominant color and transparency of the image. It modifies the response array to include
+ * these additional properties, which can be used in the media library interface.
+ *
+ * @since 1.2.0
+ *
+ * @param array<mixed>|mixed $response   The current response array for the attachment.
+ * @param WP_Post            $attachment The attachment post object.
+ * @param array<mixed>       $meta       The attachment metadata.
+ * @return array<mixed> The modified response array with added dominant color and transparency information.
+ */
+function dominant_color_prepare_attachment_for_js( $response, WP_Post $attachment, array $meta ): array {
+	if ( ! is_array( $response ) ) {
+		$response = array();
+	}
+
+	$response['dominantColor'] = '';
+	if (
+		isset( $meta['dominant_color'] )
+		&&
+		1 === preg_match( '/^[0-9a-f]+$/', $meta['dominant_color'] ) // See format returned by dominant_color_rgb_to_hex().
+	) {
+		$response['dominantColor'] = $meta['dominant_color'];
+	}
+	$response['hasTransparency'] = '';
+	if ( isset( $meta['has_transparency'] ) ) {
+		$response['hasTransparency'] = (bool) $meta['has_transparency'];
+	}
+
+	return $response;
+}
+add_filter( 'wp_prepare_attachment_for_js', 'dominant_color_prepare_attachment_for_js', 10, 3 );
Index: load.php
===================================================================
--- load.php	(revision 3209553)
+++ load.php	(working copy)
@@ -3,9 +3,9 @@
  * Plugin Name: Image Placeholders
  * Plugin URI: https://github.com/WordPress/performance/tree/trunk/plugins/dominant-color-images
  * Description: Displays placeholders based on an image's dominant color while the image is loading.
- * Requires at least: 6.5
+ * Requires at least: 6.6
  * Requires PHP: 7.2
- * Version: 1.1.2
+ * Version: 1.2.0
  * Author: WordPress Performance Team
  * Author URI: https://make.wordpress.org/performance/
  * License: GPLv2 or later
@@ -25,7 +25,7 @@
 	return;
 }
 
-define( 'DOMINANT_COLOR_IMAGES_VERSION', '1.1.2' );
+define( 'DOMINANT_COLOR_IMAGES_VERSION', '1.2.0' );
 
 require_once __DIR__ . '/helper.php';
 require_once __DIR__ . '/hooks.php';
Index: readme.txt
===================================================================
--- readme.txt	(revision 3209553)
+++ readme.txt	(working copy)
@@ -1,8 +1,8 @@
 === Image Placeholders ===
 
 Contributors: wordpressdotorg
-Tested up to: 6.6
-Stable tag:   1.1.2
+Tested up to: 6.7
+Stable tag:   1.2.0
 License:      GPLv2 or later
 License URI:  https://www.gnu.org/licenses/gpl-2.0.html
 Tags:         performance, images, dominant color
@@ -47,6 +47,12 @@
 
 == Changelog ==
 
+= 1.2.0 =
+
+**Enhancements**
+
+* Enhance admin media UI with dominant color support. ([1719](https://github.com/WordPress/performance/pull/1719))
+
 = 1.1.2 =
 
 **Enhancements**

embed-optimizer

Important

Stable tag change: 0.3.0 → 0.4.0

svn status:

M       class-embed-optimizer-tag-visitor.php
M       detect.js
?       detect.min.js
M       hooks.php
M       lazy-load.js
?       lazy-load.min.js
M       load.php
M       readme.txt
svn diff
Index: class-embed-optimizer-tag-visitor.php
===================================================================
--- class-embed-optimizer-tag-visitor.php	(revision 3209553)
+++ class-embed-optimizer-tag-visitor.php	(working copy)
@@ -50,7 +50,7 @@
 	 * @since 0.3.0
 	 *
 	 * @param OD_HTML_Tag_Processor $processor Processor.
-	 * @return bool Whether the tag should be measured and stored in URL metrics.
+	 * @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 (
@@ -81,9 +81,10 @@
 	 * Otherwise, if the embed is not in any initial viewport, it will add lazy-loading logic.
 	 *
 	 * @since 0.2.0
+	 * @since 0.4.0 Adds preconnect links for each viewport group and skips if the element is not in the viewport for that group.
 	 *
 	 * @param OD_Tag_Visitor_Context $context Tag visitor context.
-	 * @return bool Whether the tag should be tracked in URL metrics.
+	 * @return bool Whether the tag should be tracked in URL Metrics.
 	 */
 	public function __invoke( OD_Tag_Visitor_Context $context ): bool {
 		$processor = $context->processor;
@@ -103,13 +104,14 @@
 
 		$this->reduce_layout_shifts( $context );
 
-		// Preconnect links and lazy-loading can only be done once there are URL metrics collected for both mobile and desktop.
+		// Preconnect links and lazy-loading can only be done once there are URL Metrics collected for both mobile and desktop.
 		if (
 			$context->url_metric_group_collection->get_first_group()->count() > 0
 			&&
 			$context->url_metric_group_collection->get_last_group()->count() > 0
 		) {
-			$max_intersection_ratio = $context->url_metric_group_collection->get_element_max_intersection_ratio( self::get_embed_wrapper_xpath( $processor->get_xpath() ) );
+			$embed_wrapper_xpath    = self::get_embed_wrapper_xpath( $processor->get_xpath() );
+			$max_intersection_ratio = $context->url_metric_group_collection->get_element_max_intersection_ratio( $embed_wrapper_xpath );
 			if ( $max_intersection_ratio > 0 ) {
 				/*
 				 * The following embeds have been chosen for optimization due to their relative popularity among all embed types.
@@ -171,12 +173,20 @@
 				}
 
 				foreach ( $preconnect_hrefs as $preconnect_href ) {
-					$context->link_collection->add_link(
-						array(
-							'rel'  => 'preconnect',
-							'href' => $preconnect_href,
-						)
-					);
+					foreach ( $context->url_metric_group_collection as $group ) {
+						if ( ! ( $group->get_element_max_intersection_ratio( $embed_wrapper_xpath ) > 0.0 ) ) {
+							continue;
+						}
+
+						$context->link_collection->add_link(
+							array(
+								'rel'  => 'preconnect',
+								'href' => $preconnect_href,
+							),
+							$group->get_minimum_viewport_width(),
+							$group->get_maximum_viewport_width()
+						);
+					}
 				}
 			} elseif ( embed_optimizer_update_markup( $processor, false ) && ! $this->added_lazy_script ) {
 				$processor->append_body_html( wp_get_inline_script_tag( embed_optimizer_get_lazy_load_script(), array( 'type' => 'module' ) ) );
Index: detect.js
===================================================================
--- detect.js	(revision 3209553)
+++ detect.js	(working copy)
@@ -1 +1,123 @@
-const consoleLogPrefix="[Embed Optimizer]";function log(...e){console.log(consoleLogPrefix,...e)}function error(...e){console.error(consoleLogPrefix,...e)}const loadedElementContentRects=new Map;export function initialize({isDebug:e}){const t=document.querySelectorAll(".wp-block-embed > .wp-block-embed__wrapper[data-od-xpath]");for(const o of t)monitorEmbedWrapperForResizes(o,e);e&&log("Loaded embed content rects:",loadedElementContentRects)}export async function finalize({isDebug:e,getElementData:t,extendElementData:o}){for(const[n,r]of loadedElementContentRects.entries())try{o(n,{resizedBoundingClientRect:r}),e&&log(`boundingClientRect for ${n} resized:`,t(n).boundingClientRect,"=>",r)}catch(e){error(`Failed to extend element data for ${n} with resizedBoundingClientRect:`,r,e)}}function monitorEmbedWrapperForResizes(e,t){if(!("odXpath"in e.dataset))throw new Error("Embed wrapper missing data-od-xpath attribute.");const o=e.dataset.odXpath;new ResizeObserver((e=>{const[n]=e;loadedElementContentRects.set(o,n.contentRect),t&&log(`Resized element ${o}:`,n.contentRect)})).observe(e,{box:"content-box"})}
\ No newline at end of file
+/**
+ * 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.ts").URLMetric} URLMetric
+ * @typedef {import("../optimization-detective/types.ts").Extension} Extension
+ * @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
+ * @typedef {import("../optimization-detective/types.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 async 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' } );
+}
Index: hooks.php
===================================================================
--- hooks.php	(revision 3209553)
+++ hooks.php	(working copy)
@@ -42,7 +42,7 @@
  * @param string $optimization_detective_version Current version of the optimization detective plugin.
  */
 function embed_optimizer_init_optimization_detective( string $optimization_detective_version ): void {
-	$required_od_version = '0.7.0';
+	$required_od_version = '0.9.0';
 	if ( version_compare( (string) strtok( $optimization_detective_version, '-' ), $required_od_version, '<' ) ) {
 		add_action(
 			'admin_notices',
@@ -121,7 +121,7 @@
 	if ( ! is_array( $extension_module_urls ) ) {
 		$extension_module_urls = array();
 	}
-	$extension_module_urls[] = add_query_arg( 'ver', EMBED_OPTIMIZER_VERSION, plugin_dir_url( __FILE__ ) . 'detect.js' );
+	$extension_module_urls[] = add_query_arg( 'ver', EMBED_OPTIMIZER_VERSION, plugin_dir_url( __FILE__ ) . embed_optimizer_get_asset_path( 'detect.js' ) );
 	return $extension_module_urls;
 }
 
@@ -181,8 +181,9 @@
 		'script' => 'embed_optimizer_script',
 		'iframe' => 'embed_optimizer_iframe',
 	);
-	$trigger_error  = static function ( string $message ): void {
-		wp_trigger_error( __FUNCTION__, esc_html( $message ) );
+	$function_name  = __FUNCTION__;
+	$trigger_error  = static function ( string $message ) use ( $function_name ): void {
+		wp_trigger_error( $function_name, esc_html( $message ) );
 	};
 	try {
 		/*
@@ -325,7 +326,7 @@
  * @since 0.2.0
  */
 function embed_optimizer_get_lazy_load_script(): string {
-	$script = file_get_contents( __DIR__ . '/lazy-load.js' ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- It's a local filesystem path not a remote request.
+	$script = file_get_contents( __DIR__ . '/' . embed_optimizer_get_asset_path( 'lazy-load.js' ) ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- It's a local filesystem path not a remote request.
 
 	if ( false === $script ) {
 		return '';
@@ -423,3 +424,39 @@
 	// Use the plugin slug as it is immutable.
 	echo '<meta name="generator" content="embed-optimizer ' . esc_attr( EMBED_OPTIMIZER_VERSION ) . '">' . "\n";
 }
+
+/**
+ * Gets the path to a script or stylesheet.
+ *
+ * @since 0.4.0
+ *
+ * @param string      $src_path Source path, relative to plugin root.
+ * @param string|null $min_path Minified path. If not supplied, then '.min' is injected before the file extension in the source path.
+ * @return string URL to script or stylesheet.
+ */
+function embed_optimizer_get_asset_path( string $src_path, ?string $min_path = null ): string {
+	if ( null === $min_path ) {
+		// Note: wp_scripts_get_suffix() is not used here because we need access to both the source and minified paths.
+		$min_path = (string) preg_replace( '/(?=\.\w+$)/', '.min', $src_path );
+	}
+
+	$force_src = false;
+	if ( WP_DEBUG && ! file_exists( trailingslashit( __DIR__ ) . $min_path ) ) {
+		$force_src = true;
+		wp_trigger_error(
+			__FUNCTION__,
+			sprintf(
+				/* translators: %s is the minified asset path */
+				__( 'Minified asset has not been built: %s', 'embed-optimizer' ),
+				$min_path
+			),
+			E_USER_WARNING
+		);
+	}
+
+	if ( SCRIPT_DEBUG || $force_src ) {
+		return $src_path;
+	}
+
+	return $min_path;
+}
Index: lazy-load.js
===================================================================
--- lazy-load.js	(revision 3209553)
+++ lazy-load.js	(working copy)
@@ -1 +1,55 @@
-const lazyEmbedsScripts=document.querySelectorAll('script[type="application/vnd.embed-optimizer.javascript"]'),lazyEmbedScriptsByParents=new Map,lazyEmbedObserver=new IntersectionObserver((e=>{for(const t of e)if(t.isIntersecting){const e=t.target,r=lazyEmbedScriptsByParents.get(e),s=document.createElement("script");for(const e of r.attributes)"type"!==e.nodeName&&s.setAttribute("data-original-type"===e.nodeName?"type":e.nodeName,e.nodeValue);r.replaceWith(s),lazyEmbedObserver.unobserve(e)}}),{rootMargin:"100% 0% 100% 0%",threshold:0});for(const e of lazyEmbedsScripts){const t=e.parentNode;t instanceof HTMLElement&&(lazyEmbedScriptsByParents.set(t,e),lazyEmbedObserver.observe(t))}
\ No newline at end of file
+/**
+ * Lazy load embeds
+ *
+ * When an embed block is lazy loaded, the script tag is replaced with a script tag that has the original attributes
+ */
+
+const lazyEmbedsScripts = document.querySelectorAll(
+	'script[type="application/vnd.embed-optimizer.javascript"]'
+);
+const lazyEmbedScriptsByParents = new Map();
+
+const lazyEmbedObserver = new IntersectionObserver(
+	( entries ) => {
+		for ( const entry of entries ) {
+			if ( entry.isIntersecting ) {
+				const lazyEmbedParent = entry.target;
+				const lazyEmbedScript =
+					/** @type {HTMLScriptElement} */ lazyEmbedScriptsByParents.get(
+						lazyEmbedParent
+					);
+				const embedScript =
+					/** @type {HTMLScriptElement} */ document.createElement(
+						'script'
+					);
+				for ( const attr of lazyEmbedScript.attributes ) {
+					if ( attr.nodeName === 'type' ) {
+						// Omit type=application/vnd.embed-optimizer.javascript type.
+						continue;
+					}
+					embedScript.setAttribute(
+						attr.nodeName === 'data-original-type'
+							? 'type'
+							: attr.nodeName,
+						attr.nodeValue
+					);
+				}
+				lazyEmbedScript.replaceWith( embedScript );
+				lazyEmbedObserver.unobserve( lazyEmbedParent );
+			}
+		}
+	},
+	{
+		rootMargin: '100% 0% 100% 0%',
+		threshold: 0,
+	}
+);
+
+for ( const lazyEmbedScript of lazyEmbedsScripts ) {
+	const lazyEmbedParent =
+		/** @type {HTMLElement} */ lazyEmbedScript.parentNode;
+	if ( lazyEmbedParent instanceof HTMLElement ) {
+		lazyEmbedScriptsByParents.set( lazyEmbedParent, lazyEmbedScript );
+		lazyEmbedObserver.observe( lazyEmbedParent );
+	}
+}
Index: load.php
===================================================================
--- load.php	(revision 3209553)
+++ load.php	(working copy)
@@ -2,10 +2,10 @@
 /**
  * Plugin Name: Embed Optimizer
  * Plugin URI: https://github.com/WordPress/performance/tree/trunk/plugins/embed-optimizer
- * Description: Optimizes the performance of embeds by lazy-loading iframes and scripts.
- * Requires at least: 6.5
+ * Description: Optimizes the performance of embeds through lazy-loading, preconnecting, and reserving space to reduce layout shifts.
+ * Requires at least: 6.6
  * Requires PHP: 7.2
- * Version: 0.3.0
+ * Version: 0.4.0
  * Author: WordPress Performance Team
  * Author URI: https://make.wordpress.org/performance/
  * License: GPLv2 or later
@@ -70,7 +70,7 @@
 	}
 )(
 	'embed_optimizer_pending_plugin',
-	'0.3.0',
+	'0.4.0',
 	static function ( string $version ): void {
 		if ( defined( 'EMBED_OPTIMIZER_VERSION' ) ) {
 			return;
Index: readme.txt
===================================================================
--- readme.txt	(revision 3209553)
+++ readme.txt	(working copy)
@@ -2,19 +2,35 @@
 
 Contributors: wordpressdotorg
 Tested up to: 6.7
-Stable tag:   0.3.0
+Stable tag:   0.4.0
 License:      GPLv2 or later
 License URI:  https://www.gnu.org/licenses/gpl-2.0.html
 Tags:         performance, embeds
 
-Optimizes the performance of embeds by lazy-loading iframes and scripts.
+Optimizes the performance of embeds through lazy-loading, preconnecting, and reserving space to reduce layout shifts.
 
 == Description ==
 
-This plugin's purpose is to optimize the performance of [embeds in WordPress](https://wordpress.org/documentation/article/embeds/), such as YouTube videos, TikToks, and so on. Initially this is achieved by lazy-loading them only when they come into view. This improves performance because embeds are generally very resource-intensive and so lazy-loading them ensures that they don't compete with resources when the page is loading. [Other optimizations](https://github.com/WordPress/performance/issues?q=is%3Aissue+is%3Aopen+label%3A%22%5BPlugin%5D+Embed+Optimizer%22) are planned for the future.
+This plugin's purpose is to optimize the performance of [embeds in WordPress](https://wordpress.org/documentation/article/embeds/), such as Tweets, YouTube videos, TikToks, and others.
 
-This plugin also recommends that you install and activate the [Optimization Detective](https://wordpress.org/plugins/optimization-detective/) plugin. When it is active, it will start recording which embeds appear in the initial viewport based on actual visitors to your site. With this information in hand, Embed Optimizer will then avoid lazy-loading embeds which appear in the initial viewport (above the fold). This is important because lazy-loading adds a delay which can hurt the user experience and even degrade the Largest Contentful Paint (LCP) score for the page. In addition to not lazy-loading such above-the-fold embeds, Embed Optimizer will add preconnect links for the hosts of network resources known to be required for the most popular embeds (e.g. YouTube, Twitter, Vimeo, Spotify, VideoPress); this can further speed up the loading of critical embeds. Again, these performance enhancements are only enabled when Optimization Detective is active.
+The current optimizations include:
 
+1. Lazy loading embeds just before they come into view.
+2. Adding preconnect links for embeds in the initial viewport.
+3. Reserving space for embeds that resize to reduce layout shifting.
+
+**Lazy loading embeds** improves performance because embeds are generally very resource-intensive, so lazy loading them ensures that they don't compete with resources when the page is loading. Lazy loading of `IFRAME`\-based embeds is handled simply by adding the `loading=lazy` attribute. Lazy loading embeds that include `SCRIPT` tags is handled by using an Intersection Observer to watch for when the embed’s `FIGURE` container is going to enter the viewport and then it dynamically inserts the `SCRIPT` tag.
+
+**This plugin also recommends that you install and activate the [Optimization Detective](https://wordpress.org/plugins/optimization-detective/) plugin**, which unlocks several optimizations beyond just lazy loading. Without Optimization Detective, lazy loading can actually degrade performance *when an embed is positioned in the initial viewport*. This is because lazy loading such viewport-initial elements can degrade LCP since rendering is delayed by the logic to determine whether the element is visible. This is why WordPress Core tries its best to [avoid](https://make.wordpress.org/core/2021/07/15/refining-wordpress-cores-lazy-loading-implementation/) [lazy loading](https://make.wordpress.org/core/2021/07/15/refining-wordpress-cores-lazy-loading-implementation/) `IMG` tags which appear in the initial viewport, although the server-side heuristics aren’t perfect. This is where Optimization Detective comes in since it detects whether an embed appears in any breakpoint-specific viewports, like mobile, tablet, and desktop. (See also the [Image Prioritizer](https://wordpress.org/plugins/image-prioritizer/) plugin which extends Optimization Detective to ensure lazy loading is correctly applied based on whether an IMG is in the initial viewport.)
+
+When Optimization Detective is active, it will start keeping track of which embeds appear in the initial viewport based on actual visits to your site. With this information in hand, Embed Optimizer will then avoid lazy loading embeds which appear in the initial viewport. Furthermore, for such above-the-fold embeds Embed Optimizer will also **add preconnect links** for resources known to be used by those embeds. For example, if a YouTube embed appears in the initial viewport, Embed Optimizer with Optimization Detective will omit `loading=lazy` while also adding a preconnect link for `https://i.ytimg.com` which is the domain from which YouTube video poster images are served. Such preconnect links cause the initial-viewport embeds to load even faster.
+
+The other major feature in Embed Optimizer enabled by Optimization Detective is the **reduction of layout shifts** caused by embeds that resize when they load. This is seen commonly in WordPress post embeds or Tweet embeds. Embed Optimizer keeps track of the resized heights of these embeds. With these resized heights stored, Embed Optimizer sets the appropriate height on the container FIGURE element as the viewport-specific `min-height` so that when the embed loads it does not cause a layout shift.
+
+Since Optimization Detective relies on page visits to learn how the page is laid out, you’ll need to wait until you have visits from a mobile and desktop device to start seeing optimizations applied. Also, note that Optimization Detective does not apply optimizations by default for logged-in admin users.
+
+Please note that the optimizations are intended to apply to Embed blocks. So if you do not see optimizations applied, make sure that your embeds are not inside of a Classic Block.
+
 There are currently **no settings** and no user interface for this plugin since it is designed to work without any configuration.
 
 == Installation ==
@@ -51,11 +67,17 @@
 
 == Changelog ==
 
+= 0.4.0 =
+
+**Enhancements**
+
+* Incorporate media queries into preconnect links to account for whether embeds are in viewport. ([1654](https://github.com/WordPress/performance/pull/1654))
+
 = 0.3.0 =
 
 **Enhancements**
 
-* Leverage URL metrics to reserve space for embeds to reduce CLS. ([1373](https://github.com/WordPress/performance/pull/1373))
+* Leverage URL Metrics to reserve space for embeds to reduce CLS. ([1373](https://github.com/WordPress/performance/pull/1373))
 * Avoid lazy-loading images and embeds unless there are URL Metrics for both mobile and desktop. ([1604](https://github.com/WordPress/performance/pull/1604))
 
 = 0.2.0 =

image-prioritizer

Important

Stable tag change: 0.2.0 → 0.3.0

svn status:

M       class-image-prioritizer-background-image-styled-tag-visitor.php
M       class-image-prioritizer-img-tag-visitor.php
M       class-image-prioritizer-tag-visitor.php
M       class-image-prioritizer-video-tag-visitor.php
?       detect.js
?       detect.min.js
M       helper.php
M       hooks.php
?       lazy-load-bg-image.css
?       lazy-load-bg-image.js
?       lazy-load-bg-image.min.css
?       lazy-load-bg-image.min.js
?       lazy-load-video.js
?       lazy-load-video.min.js
!       lazy-load.js
?       lazy-load.min.js
M       load.php
M       readme.txt
svn diff
Index: class-image-prioritizer-background-image-styled-tag-visitor.php
===================================================================
--- class-image-prioritizer-background-image-styled-tag-visitor.php	(revision 3209553)
+++ class-image-prioritizer-background-image-styled-tag-visitor.php	(working copy)
@@ -14,6 +14,13 @@
 /**
  * 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
  */
@@ -20,10 +27,34 @@
 final class Image_Prioritizer_Background_Image_Styled_Tag_Visitor extends Image_Prioritizer_Tag_Visitor {
 
 	/**
+	 * Class name used to indicate a background image which is lazy-loaded.
+	 *
+	 * @since 0.3.0
+	 * @var string
+	 */
+	const LAZY_BG_IMAGE_CLASS_NAME = 'od-lazy-bg-image';
+
+	/**
+	 * Whether the lazy-loading script and stylesheet have been added.
+	 *
+	 * @since 0.3.0
+	 * @var bool
+	 */
+	private $added_lazy_assets = false;
+
+	/**
+	 * Tuples of URL Metric group and the common LCP element external background image.
+	 *
+	 * @since 0.3.0
+	 * @var array<array{OD_URL_Metric_Group, LcpElementExternalBackgroundImage}>
+	 */
+	private $group_common_lcp_element_external_background_images;
+
+	/**
 	 * Visits a tag.
 	 *
 	 * @param OD_Tag_Visitor_Context $context Tag visitor context.
-	 * @return bool Whether the tag should be tracked in URL metrics.
+	 * @return bool Whether the tag should be tracked in URL Metrics.
 	 */
 	public function __invoke( OD_Tag_Visitor_Context $context ): bool {
 		$processor = $context->processor;
@@ -49,6 +80,7 @@
 		}
 
 		if ( is_null( $background_image_url ) ) {
+			$this->maybe_preload_external_lcp_background_image( $context );
 			return false;
 		}
 
@@ -56,21 +88,152 @@
 
 		// 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_image_preload_link( $context->link_collection, $group, $background_image_url );
+		}
+
+		$this->lazy_load_bg_images( $context );
+
+		return true;
+	}
+
+	/**
+	 * Gets the common LCP element external background image for a URL Metric group.
+	 *
+	 * @since 0.3.0
+	 *
+	 * @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.
+		if ( $group->count() !== $group->get_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 0.3.0
+	 *
+	 * @param OD_Tag_Visitor_Context $context Context.
+	 */
+	private function maybe_preload_external_lcp_background_image( OD_Tag_Visitor_Context $context ): void {
+		// Gather the tuples of URL Metric group and the common LCP element external background image.
+		// Note the groups of URL Metrics do not change across invocations, we just need to compute this once for all.
+		if ( ! is_array( $this->group_common_lcp_element_external_background_images ) ) {
+			$this->group_common_lcp_element_external_background_images = array();
+			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 );
+				}
+			}
+		}
+
+		// 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 ( array_keys( $this->group_common_lcp_element_external_background_images ) as $i ) {
+			list( $group, $common ) = $this->group_common_lcp_element_external_background_images[ $i ];
+			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_image_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 0.3.0
+	 *
+	 * @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_image_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',
-			);
+			),
+			$group->get_minimum_viewport_width(),
+			$group->get_maximum_viewport_width()
+		);
+	}
 
-			$context->link_collection->add_link(
-				$link_attributes,
-				$group->get_minimum_viewport_width(),
-				$group->get_maximum_viewport_width()
-			);
+	/**
+	 * Optimizes an element with a background image based on whether it is displayed in any initial viewport.
+	 *
+	 * @since 0.3.0
+	 *
+	 * @param OD_Tag_Visitor_Context $context Tag visitor context, with the cursor currently at block with a background image.
+	 */
+	private function lazy_load_bg_images( OD_Tag_Visitor_Context $context ): void {
+		$processor = $context->processor;
+
+		// Lazy-loading can only be done once there are URL Metrics collected for both mobile and desktop.
+		if (
+			$context->url_metric_group_collection->get_first_group()->count() === 0
+			||
+			$context->url_metric_group_collection->get_last_group()->count() === 0
+		) {
+			return;
 		}
 
-		return true;
+		$xpath = $processor->get_xpath();
+
+		// If the element is in the initial viewport, do not lazy load its background image.
+		if ( false !== $context->url_metric_group_collection->is_element_positioned_in_any_initial_viewport( $xpath ) ) {
+			return;
+		}
+
+		$processor->add_class( self::LAZY_BG_IMAGE_CLASS_NAME );
+
+		if ( ! $this->added_lazy_assets ) {
+			$processor->append_head_html( sprintf( "<style>\n%s\n</style>\n", image_prioritizer_get_lazy_load_bg_image_stylesheet() ) );
+			$processor->append_body_html( wp_get_inline_script_tag( image_prioritizer_get_lazy_load_bg_image_script(), array( 'type' => 'module' ) ) );
+			$this->added_lazy_assets = true;
+		}
 	}
 }
Index: class-image-prioritizer-img-tag-visitor.php
===================================================================
--- class-image-prioritizer-img-tag-visitor.php	(revision 3209553)
+++ class-image-prioritizer-img-tag-visitor.php	(working copy)
@@ -14,6 +14,8 @@
 /**
  * Tag visitor that optimizes IMG tags.
  *
+ * @phpstan-import-type LinkAttributes from OD_Link_Collection
+ *
  * @since 0.1.0
  * @access private
  */
@@ -22,19 +24,37 @@
 	/**
 	 * Visits a tag.
 	 *
+	 * @since 0.1.0
+	 * @since 0.3.0 Separate the processing of IMG and PICTURE elements.
+	 *
 	 * @param OD_Tag_Visitor_Context $context Tag visitor context.
-	 *
-	 * @return bool Whether the tag should be tracked in URL metrics.
+	 * @return bool Whether the tag should be tracked in URL Metrics.
 	 */
 	public function __invoke( OD_Tag_Visitor_Context $context ): bool {
 		$processor = $context->processor;
-		if ( 'IMG' !== $processor->get_tag() ) {
-			return false;
+		$tag       = $processor->get_tag();
+
+		if ( 'PICTURE' === $tag ) {
+			return $this->process_picture( $processor, $context );
+		} elseif ( 'IMG' === $tag ) {
+			return $this->process_img( $processor, $context );
 		}
 
-		// Skip empty src attributes and data: URLs.
-		$src = trim( (string) $processor->get_attribute( 'src' ) );
-		if ( '' === $src || $this->is_data_url( $src ) ) {
+		return false;
+	}
+
+	/**
+	 * Process an IMG element.
+	 *
+	 * @since 0.3.0
+	 *
+	 * @param OD_HTML_Tag_Processor  $processor HTML tag processor.
+	 * @param OD_Tag_Visitor_Context $context   Tag visitor context.
+	 * @return bool Whether the tag should be tracked in URL Metrics.
+	 */
+	private function process_img( OD_HTML_Tag_Processor $processor, OD_Tag_Visitor_Context $context ): bool {
+		$src = $this->get_valid_src( $processor );
+		if ( null === $src ) {
 			return false;
 		}
 
@@ -61,7 +81,7 @@
 			 * At this point, the element is not the shared LCP across all viewport groups. Nevertheless, server-side
 			 * heuristics have added fetchpriority=high to the element, but this is not warranted either due to a lack
 			 * of data or because the LCP element is not common across all viewport groups. Since we have collected at
-			 * least some URL metrics (per is_any_group_populated), further below a fetchpriority=high preload link will
+			 * least some URL Metrics (per is_any_group_populated), further below a fetchpriority=high preload link will
 			 * be added for the viewport(s) for which this is actually the LCP element. Some viewport groups may never
 			 * get populated due to a lack of traffic (e.g. from tablets or phablets), so it is important to remove
 			 * fetchpriority=high in such case to prevent server-side heuristics from prioritizing loading the image
@@ -71,10 +91,10 @@
 		}
 
 		/*
-		 * Do not do any lazy-loading if the mobile and desktop viewport groups lack URL metrics. This is important
+		 * Do not do any lazy-loading if the mobile and desktop viewport groups lack URL Metrics. This is important
 		 * because if there is an IMG in the initial viewport on desktop but not mobile, if then there are only URL
 		 * metrics collected for mobile then the IMG will get lazy-loaded which is good for mobile but for desktop
-		 * it will hurt performance. So this is why it is important to have URL metrics collected for both desktop and
+		 * it will hurt performance. So this is why it is important to have URL Metrics collected for both desktop and
 		 * mobile to verify whether maximum intersectionRatio is accounting for both screen sizes.
 		 */
 		$element_max_intersection_ratio = $context->url_metric_group_collection->get_element_max_intersection_ratio( $xpath );
@@ -142,41 +162,207 @@
 			}
 		}
 
-		// 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_merge(
+		$parent_tag = $this->get_parent_tag_name( $context );
+		if ( 'PICTURE' !== $parent_tag ) {
+			$this->add_image_preload_link_for_lcp_element_groups(
+				$context,
+				$xpath,
 				array(
-					'rel'           => 'preload',
-					'fetchpriority' => 'high',
-					'as'            => 'image',
-				),
-				array_filter(
-					array(
-						'href'        => (string) $processor->get_attribute( 'src' ),
-						'imagesrcset' => (string) $processor->get_attribute( 'srcset' ),
-						'imagesizes'  => (string) $processor->get_attribute( 'sizes' ),
-					),
-					static function ( string $value ): bool {
-						return '' !== $value;
-					}
+					'href'           => $processor->get_attribute( 'src' ),
+					'imagesrcset'    => $processor->get_attribute( 'srcset' ),
+					'imagesizes'     => $processor->get_attribute( 'sizes' ),
+					'crossorigin'    => $this->get_attribute_value( $processor, 'crossorigin' ),
+					'referrerpolicy' => $this->get_attribute_value( $processor, 'referrerpolicy' ),
 				)
 			);
+		}
 
-			$crossorigin = $this->get_attribute_value( $processor, 'crossorigin' );
-			if ( null !== $crossorigin ) {
-				$link_attributes['crossorigin'] = 'use-credentials' === $crossorigin ? 'use-credentials' : 'anonymous';
+		return true;
+	}
+
+	/**
+	 * Process a PICTURE element.
+	 *
+	 * @since 0.3.0
+	 *
+	 * @param OD_HTML_Tag_Processor  $processor HTML tag processor.
+	 * @param OD_Tag_Visitor_Context $context   Tag visitor context.
+	 * @return bool Whether the tag should be tracked in URL Metrics.
+	 */
+	private function process_picture( OD_HTML_Tag_Processor $processor, OD_Tag_Visitor_Context $context ): bool {
+		/**
+		 * First SOURCE tag's attributes.
+		 *
+		 * @var array{ srcset: non-empty-string, sizes: string|null, type: non-empty-string }|null $first_source
+		 */
+		$first_source = null;
+		$img_xpath    = null;
+
+		$referrerpolicy = null;
+		$crossorigin    = null;
+
+		// Loop through child tags until we reach the closing PICTURE tag.
+		while ( $processor->next_tag() ) {
+			$tag = $processor->get_tag();
+
+			// If we reached the closing PICTURE tag, break.
+			if ( 'PICTURE' === $tag && $processor->is_tag_closer() ) {
+				break;
 			}
 
-			$link_attributes['media'] = 'screen';
+			// Process the SOURCE elements.
+			if ( 'SOURCE' === $tag && ! $processor->is_tag_closer() ) {
+				// Abort processing if the PICTURE involves art direction since then adding a preload link is infeasible.
+				if ( null !== $processor->get_attribute( 'media' ) ) {
+					return false;
+				}
 
+				// Abort processing if a SOURCE lacks the required srcset attribute.
+				$srcset = $this->get_valid_src( $processor, 'srcset' );
+				if ( null === $srcset ) {
+					return false;
+				}
+
+				// Abort processing if there is no valid image type.
+				$type = $this->get_attribute_value( $processor, 'type' );
+				if ( ! is_string( $type ) || ! str_starts_with( $type, 'image/' ) ) {
+					return false;
+				}
+
+				// Collect the first valid SOURCE as the preload link.
+				if ( null === $first_source ) {
+					$sizes        = $processor->get_attribute( 'sizes' );
+					$first_source = array(
+						'srcset' => $srcset,
+						'sizes'  => is_string( $sizes ) ? $sizes : null,
+						'type'   => $type,
+					);
+				}
+			}
+
+			// Process the IMG element within the PICTURE.
+			if ( 'IMG' === $tag && ! $processor->is_tag_closer() ) {
+				$src = $this->get_valid_src( $processor );
+				if ( null === $src ) {
+					return false;
+				}
+
+				// These attributes are only defined on the IMG itself.
+				$referrerpolicy = $this->get_attribute_value( $processor, 'referrerpolicy' );
+				$crossorigin    = $this->get_attribute_value( $processor, 'crossorigin' );
+
+				// Capture the XPath for the IMG since the browser captures it as the LCP element, so we need this to
+				// look up whether it is the LCP element in the URL Metric groups.
+				$img_xpath = $processor->get_xpath();
+			}
+		}
+
+		// Abort if we never encountered a SOURCE or IMG tag.
+		if ( null === $img_xpath || null === $first_source ) {
+			return false;
+		}
+
+		$this->add_image_preload_link_for_lcp_element_groups(
+			$context,
+			$img_xpath,
+			array(
+				'imagesrcset'    => $first_source['srcset'],
+				'imagesizes'     => $first_source['sizes'],
+				'type'           => $first_source['type'],
+				'crossorigin'    => $crossorigin,
+				'referrerpolicy' => $referrerpolicy,
+			)
+		);
+
+		return false;
+	}
+
+	/**
+	 * Gets valid src attribute value for preloading.
+	 *
+	 * Returns null if the src attribute is not a string (i.e. src was used as a boolean attribute was used), if it
+	 * it has an empty string value after trimming, or if it is a data: URL.
+	 *
+	 * @since 0.3.0
+	 *
+	 * @param OD_HTML_Tag_Processor $processor      Processor.
+	 * @param 'src'|'srcset'        $attribute_name Attribute name.
+	 * @return non-empty-string|null URL which is not a data: URL.
+	 */
+	private function get_valid_src( OD_HTML_Tag_Processor $processor, string $attribute_name = 'src' ): ?string {
+		$src = $processor->get_attribute( $attribute_name );
+		if ( ! is_string( $src ) ) {
+			return null;
+		}
+		$src = trim( $src );
+		if ( '' === $src || $this->is_data_url( $src ) ) {
+			return null;
+		}
+		return $src;
+	}
+
+	/**
+	 * Adds a LINK with the supplied attributes for each viewport group when the provided XPath is the LCP element.
+	 *
+	 * @since 0.3.0
+	 *
+	 * @param OD_Tag_Visitor_Context          $context    Tag visitor context.
+	 * @param string                          $xpath      XPath of the element.
+	 * @param array<string, string|true|null> $attributes Attributes to add to the link.
+	 */
+	private function add_image_preload_link_for_lcp_element_groups( OD_Tag_Visitor_Context $context, string $xpath, array $attributes ): void {
+		$attributes = array_filter(
+			$attributes,
+			static function ( $attribute_value ) {
+				return is_string( $attribute_value ) && '' !== $attribute_value;
+			}
+		);
+
+		/**
+		 * Link attributes.
+		 *
+		 * This type is needed because PHPStan isn't apparently aware of the new keys added after the array_merge().
+		 * Note that there is no type checking being done on the attributes above other than ensuring they are
+		 * non-empty-strings.
+		 *
+		 * @var LinkAttributes $attributes
+		 */
+		$attributes = array_merge(
+			array(
+				'rel'           => 'preload',
+				'fetchpriority' => 'high',
+				'as'            => 'image',
+			),
+			$attributes,
+			array(
+				'media' => 'screen',
+			)
+		);
+
+		foreach ( $context->url_metric_group_collection->get_groups_by_lcp_element( $xpath ) as $group ) {
 			$context->link_collection->add_link(
-				$link_attributes,
+				$attributes,
 				$group->get_minimum_viewport_width(),
 				$group->get_maximum_viewport_width()
 			);
 		}
+	}
 
-		return true;
+	/**
+	 * Gets the parent tag name.
+	 *
+	 * @since 0.3.0
+	 *
+	 * @param OD_Tag_Visitor_Context $context Tag visitor context.
+	 * @return string|null The parent tag name or null if not found.
+	 */
+	private function get_parent_tag_name( OD_Tag_Visitor_Context $context ): ?string {
+		$breadcrumbs = $context->processor->get_breadcrumbs();
+		$length      = count( $breadcrumbs );
+		if ( $length < 2 ) {
+			return null;
+		}
+		return $breadcrumbs[ $length - 2 ];
 	}
 
 	/**
Index: class-image-prioritizer-tag-visitor.php
===================================================================
--- class-image-prioritizer-tag-visitor.php	(revision 3209553)
+++ class-image-prioritizer-tag-visitor.php	(working copy)
@@ -14,7 +14,7 @@
 /**
  * Tag visitor that optimizes image tags.
  *
- * @phpstan-type NormalizedAttributeNames 'fetchpriority'|'loading'|'crossorigin'|'preload'
+ * @phpstan-type NormalizedAttributeNames 'fetchpriority'|'loading'|'crossorigin'|'preload'|'referrerpolicy'|'type'
  *
  * @since 0.1.0
  * @access private
@@ -25,7 +25,7 @@
 	 * Visits a tag.
 	 *
 	 * @param OD_Tag_Visitor_Context $context Tag visitor context.
-	 * @return bool Whether the tag should be tracked in URL metrics.
+	 * @return bool Whether the tag should be tracked in URL Metrics.
 	 */
 	abstract public function __invoke( OD_Tag_Visitor_Context $context ): bool;
 
@@ -44,6 +44,7 @@
 	 *
 	 * @since 0.2.0
 	 * @todo Move this into the OD_HTML_Tag_Processor/OD_HTML_Processor class eventually.
+	 * @todo It would be nice if PHPStan could know that if you pass 'crossorigin' as $attribute_name that you will get back null|'anonymous'|'use-credentials'.
 	 *
 	 * @phpstan-param NormalizedAttributeNames $attribute_name
 	 *
@@ -53,9 +54,16 @@
 	 */
 	protected function get_attribute_value( OD_HTML_Tag_Processor $processor, string $attribute_name ) {
 		$value = $processor->get_attribute( $attribute_name );
+		if ( null === $value ) {
+			return null;
+		}
+
 		if ( is_string( $value ) ) {
 			$value = strtolower( trim( $value, " \t\f\r\n" ) );
 		}
+		if ( 'crossorigin' === $attribute_name && 'use-credentials' !== $value ) {
+			$value = 'anonymous';
+		}
 		return $value;
 	}
 }
Index: class-image-prioritizer-video-tag-visitor.php
===================================================================
--- class-image-prioritizer-video-tag-visitor.php	(revision 3209553)
+++ class-image-prioritizer-video-tag-visitor.php	(working copy)
@@ -17,7 +17,6 @@
  * Image Prioritizer: Image_Prioritizer_Video_Tag_Visitor class
  *
  * @since 0.2.0
- *
  * @access private
  */
 final class Image_Prioritizer_Video_Tag_Visitor extends Image_Prioritizer_Tag_Visitor {
@@ -44,7 +43,7 @@
 	 * @since 0.2.0
 	 *
 	 * @param OD_Tag_Visitor_Context $context Tag visitor context.
-	 * @return bool Whether the tag should be tracked in URL metrics.
+	 * @return bool Whether the tag should be tracked in URL Metrics.
 	 */
 	public function __invoke( OD_Tag_Visitor_Context $context ): bool {
 		$processor = $context->processor;
@@ -96,8 +95,8 @@
 		$xpath = $processor->get_xpath();
 
 		/*
-		 * Obtain maximum width of the element exclusively from the URL metrics group with the widest viewport width,
-		 * which would be desktop. This prevents the situation where if URL metrics have only so far been gathered for
+		 * Obtain maximum width of the element exclusively from the URL Metrics group with the widest viewport width,
+		 * which would be desktop. This prevents the situation where if URL Metrics have only so far been gathered for
 		 * mobile viewports that an excessively-small poster would end up getting served to the first desktop visitor.
 		 */
 		$max_element_width = 0;
@@ -173,10 +172,10 @@
 		$processor = $context->processor;
 
 		/*
-		 * Do not do any lazy-loading if the mobile and desktop viewport groups lack URL metrics. This is important
+		 * Do not do any lazy-loading if the mobile and desktop viewport groups lack URL Metrics. This is important
 		 * because if there is a VIDEO in the initial viewport on desktop but not mobile, if then there are only URL
 		 * metrics collected for mobile then the VIDEO will get lazy-loaded which is good for mobile but for desktop
-		 * it will hurt performance. So this is why it is important to have URL metrics collected for both desktop and
+		 * it will hurt performance. So this is why it is important to have URL Metrics collected for both desktop and
 		 * mobile to verify whether maximum intersectionRatio is accounting for both screen sizes.
 		 */
 		if (
@@ -241,7 +240,7 @@
 		}
 
 		if ( ! $this->added_lazy_script ) {
-			$processor->append_body_html( wp_get_inline_script_tag( image_prioritizer_get_lazy_load_script(), array( 'type' => 'module' ) ) );
+			$processor->append_body_html( wp_get_inline_script_tag( image_prioritizer_get_video_lazy_load_script(), array( 'type' => 'module' ) ) );
 			$this->added_lazy_script = true;
 		}
 	}
Index: helper.php
===================================================================
--- helper.php	(revision 3209553)
+++ helper.php	(working copy)
@@ -14,11 +14,12 @@
  * Initializes Image Prioritizer when Optimization Detective has loaded.
  *
  * @since 0.2.0
+ * @access private
  *
  * @param string $optimization_detective_version Current version of the optimization detective plugin.
  */
 function image_prioritizer_init( string $optimization_detective_version ): void {
-	$required_od_version = '0.7.0';
+	$required_od_version = '0.9.0';
 	if ( ! version_compare( (string) strtok( $optimization_detective_version, '-' ), $required_od_version, '>=' ) ) {
 		add_action(
 			'admin_notices',
@@ -52,6 +53,7 @@
  * See {@see 'wp_head'}.
  *
  * @since 0.1.0
+ * @access private
  */
 function image_prioritizer_render_generator_meta_tag(): void {
 	// Use the plugin slug as it is immutable.
@@ -62,6 +64,7 @@
  * Registers tag visitors.
  *
  * @since 0.1.0
+ * @access private
  *
  * @param OD_Tag_Visitor_Registry $registry Tag visitor registry.
  */
@@ -76,3 +79,330 @@
 	$video_visitor = new Image_Prioritizer_Video_Tag_Visitor();
 	$registry->register( 'image-prioritizer/video', $video_visitor );
 }
+
+/**
+ * Filters the list of Optimization Detective extension module URLs to include the extension for Image Prioritizer.
+ *
+ * @since 0.3.0
+ * @access private
+ *
+ * @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__ ) . image_prioritizer_get_asset_path( 'detect.js' ) );
+	return $extension_module_urls;
+}
+
+/**
+ * Filters additional properties for the element item schema for Optimization Detective.
+ *
+ * @since 0.3.0
+ * @access private
+ *
+ * @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 {
+	$additional_properties['lcpElementExternalBackgroundImage'] = array(
+		'type'       => 'object',
+		'properties' => array(
+			'url'   => array(
+				'type'      => 'string',
+				'format'    => 'uri', // Note: This is excessively lax, as it is used exclusively in rest_sanitize_value_from_schema() and not in rest_validate_value_from_schema().
+				'pattern'   => '^https?://',
+				'required'  => true,
+				'maxLength' => 500, // Image URLs can be quite long.
+			),
+			'tag'   => array(
+				'type'      => 'string',
+				'required'  => true,
+				'minLength' => 1,
+				// The longest HTML tag name is 10 characters (BLOCKQUOTE and FIGCAPTION), but SVG tag names can be longer
+				// (e.g. feComponentTransfer). This maxLength accounts for possible Custom Elements that are even longer,
+				// although the longest known Custom Element from HTTP Archive is 32 characters. See data from <https://almanac.httparchive.org/en/2024/markup#fig-18>.
+				'maxLength' => 100,
+				'pattern'   => '^[a-zA-Z0-9\-]+\z', // Technically emoji can be allowed in a custom element's tag name, but this is not supported here.
+			),
+			'id'    => array(
+				'type'      => array( 'string', 'null' ),
+				'maxLength' => 100, // A reasonable upper-bound length for a long ID.
+				'required'  => true,
+			),
+			'class' => array(
+				'type'      => array( 'string', 'null' ),
+				'maxLength' => 500, // There can be a ton of class names on an element.
+				'required'  => true,
+			),
+		),
+	);
+	return $additional_properties;
+}
+
+/**
+ * Validates URL for a background image.
+ *
+ * @since 0.3.0
+ * @access private
+ *
+ * @param string $url Background image URL.
+ * @return true|WP_Error Validity.
+ */
+function image_prioritizer_validate_background_image_url( string $url ) {
+	$parsed_url = wp_parse_url( $url );
+	if ( false === $parsed_url || ! isset( $parsed_url['host'] ) ) {
+		return new WP_Error(
+			'background_image_url_lacks_host',
+			__( 'Supplied background image URL does not have a host.', 'image-prioritizer' )
+		);
+	}
+
+	$allowed_hosts = array_map(
+		static function ( $host ) {
+			return wp_parse_url( $host, PHP_URL_HOST );
+		},
+		get_allowed_http_origins()
+	);
+
+	// Obtain the host of an image attachment's URL in case a CDN is pointing all images to an origin other than the home or site URLs.
+	$image_attachment_query = new WP_Query(
+		array(
+			'post_type'              => 'attachment',
+			'post_mime_type'         => 'image',
+			'post_status'            => 'inherit',
+			'posts_per_page'         => 1,
+			'fields'                 => 'ids',
+			'no_found_rows'          => true,
+			'update_post_term_cache' => false, // Note that update_post_meta_cache is not included as well because wp_get_attachment_image_src() needs postmeta.
+		)
+	);
+	if ( isset( $image_attachment_query->posts[0] ) && is_int( $image_attachment_query->posts[0] ) ) {
+		$src = wp_get_attachment_image_src( $image_attachment_query->posts[0] );
+		if ( is_array( $src ) ) {
+			$attachment_image_src_host = wp_parse_url( $src[0], PHP_URL_HOST );
+			if ( is_string( $attachment_image_src_host ) ) {
+				$allowed_hosts[] = $attachment_image_src_host;
+			}
+		}
+	}
+
+	// Validate that the background image URL is for an allowed host.
+	if ( ! in_array( $parsed_url['host'], $allowed_hosts, true ) ) {
+		return new WP_Error(
+			'disallowed_background_image_url_host',
+			sprintf(
+				/* translators: %s is the list of allowed hosts */
+				__( 'Background image URL host is not among allowed: %s.', 'image-prioritizer' ),
+				join( ', ', array_unique( $allowed_hosts ) )
+			)
+		);
+	}
+
+	// Validate that the URL points to a valid resource.
+	$r = wp_safe_remote_head(
+		$url,
+		array(
+			'redirection' => 3, // Allow up to 3 redirects.
+		)
+	);
+	if ( $r instanceof WP_Error ) {
+		return $r;
+	}
+	$response_code = wp_remote_retrieve_response_code( $r );
+	if ( $response_code < 200 || $response_code >= 400 ) {
+		return new WP_Error(
+			'background_image_response_not_ok',
+			sprintf(
+				/* translators: %s is the HTTP status code */
+				__( 'HEAD request for background image URL did not return with a success status code: %s.', 'image-prioritizer' ),
+				$response_code
+			)
+		);
+	}
+
+	// Validate that the Content-Type is an image.
+	$content_type = (array) wp_remote_retrieve_header( $r, 'content-type' );
+	if ( ! is_string( $content_type[0] ) || ! str_starts_with( $content_type[0], 'image/' ) ) {
+		return new WP_Error(
+			'background_image_response_not_image',
+			sprintf(
+				/* translators: %s is the content type of the response */
+				__( 'HEAD request for background image URL did not return an image Content-Type: %s.', 'image-prioritizer' ),
+				$content_type[0]
+			)
+		);
+	}
+
+	/*
+	 * Validate that the Content-Length is not too massive, as it would be better to err on the side of
+	 * not preloading something so weighty in case the image won't actually end up as LCP.
+	 * The value of 2MB is chosen because according to Web Almanac 2022, the largest image by byte size
+	 * on a page is 1MB at the 90th percentile: <https://almanac.httparchive.org/en/2022/media#fig-12>.
+	 * The 2MB value is double this 1MB size.
+	 */
+	$content_length = (array) wp_remote_retrieve_header( $r, 'content-length' );
+	if ( ! is_numeric( $content_length[0] ) ) {
+		return new WP_Error(
+			'background_image_content_length_unknown',
+			__( 'HEAD request for background image URL did not include a Content-Length response header.', 'image-prioritizer' )
+		);
+	} elseif ( (int) $content_length[0] > 2 * MB_IN_BYTES ) {
+		return new WP_Error(
+			'background_image_content_length_too_large',
+			sprintf(
+				/* translators: %s is the content length of the response  */
+				__( 'HEAD request for background image URL returned Content-Length greater than 2MB: %s.', 'image-prioritizer' ),
+				$content_length[0]
+			)
+		);
+	}
+
+	return true;
+}
+
+/**
+ * Sanitizes the lcpElementExternalBackgroundImage property from the request URL Metric storage request.
+ *
+ * This removes the lcpElementExternalBackgroundImage from the URL Metric prior to it being stored if the background
+ * image URL is not valid. Removal of the property is preferable to invalidating the entire URL Metric because then
+ * potentially no URL Metrics would ever be collected if, for example, the background image URL is pointing to a
+ * disallowed origin. Then none of the other optimizations would be able to be applied.
+ *
+ * @since 0.3.0
+ * @access private
+ *
+ * @phpstan-param WP_REST_Request<array<string, mixed>> $request
+ *
+ * @param WP_REST_Response|WP_HTTP_Response|WP_Error|mixed $response Result to send to the client.
+ *                                                                   Usually a WP_REST_Response or WP_Error.
+ * @param array<string, mixed>                             $handler  Route handler used for the request.
+ * @param WP_REST_Request                                  $request  Request used to generate the response.
+ *
+ * @return WP_REST_Response|WP_HTTP_Response|WP_Error|mixed Result to send to the client.
+ * @noinspection PhpDocMissingThrowsInspection
+ */
+function image_prioritizer_filter_rest_request_before_callbacks( $response, array $handler, WP_REST_Request $request ) {
+	if (
+		$request->get_method() !== 'POST'
+		||
+		// The strtolower() and outer trim are due to \WP_REST_Server::match_request_to_handler() using case-insensitive pattern match and using '$' instead of '\z'.
+		OD_REST_API_NAMESPACE . OD_URL_METRICS_ROUTE !== rtrim( strtolower( ltrim( $request->get_route(), '/' ) ) )
+	) {
+		return $response;
+	}
+
+	$lcp_external_background_image = $request['lcpElementExternalBackgroundImage'];
+	if ( is_array( $lcp_external_background_image ) && isset( $lcp_external_background_image['url'] ) && is_string( $lcp_external_background_image['url'] ) ) {
+		$image_validity = image_prioritizer_validate_background_image_url( $lcp_external_background_image['url'] );
+		if ( is_wp_error( $image_validity ) ) {
+			/**
+			 * No WP_Exception is thrown by wp_trigger_error() since E_USER_ERROR is not passed as the error level.
+			 *
+			 * @noinspection PhpUnhandledExceptionInspection
+			 */
+			wp_trigger_error(
+				__FUNCTION__,
+				sprintf(
+					/* translators: 1: error message. 2: image url */
+					__( 'Error: %1$s. Background image URL: %2$s.', 'image-prioritizer' ),
+					rtrim( $image_validity->get_error_message(), '.' ),
+					$lcp_external_background_image['url']
+				)
+			);
+			unset( $request['lcpElementExternalBackgroundImage'] );
+		}
+	}
+
+	return $response;
+}
+
+/**
+ * Gets the path to a script or stylesheet.
+ *
+ * @since 0.3.0
+ * @access private
+ *
+ * @param string      $src_path Source path, relative to plugin root.
+ * @param string|null $min_path Minified path. If not supplied, then '.min' is injected before the file extension in the source path.
+ * @return string URL to script or stylesheet.
+ * @noinspection PhpDocMissingThrowsInspection
+ */
+function image_prioritizer_get_asset_path( string $src_path, ?string $min_path = null ): string {
+	if ( null === $min_path ) {
+		// Note: wp_scripts_get_suffix() is not used here because we need access to both the source and minified paths.
+		$min_path = (string) preg_replace( '/(?=\.\w+$)/', '.min', $src_path );
+	}
+
+	$force_src = false;
+	if ( WP_DEBUG && ! file_exists( trailingslashit( __DIR__ ) . $min_path ) ) {
+		$force_src = true;
+		/**
+		 * No WP_Exception is thrown by wp_trigger_error() since E_USER_ERROR is not passed as the error level.
+		 *
+		 * @noinspection PhpUnhandledExceptionInspection
+		 */
+		wp_trigger_error(
+			__FUNCTION__,
+			sprintf(
+				/* translators: %s is the minified asset path */
+				__( 'Minified asset has not been built: %s', 'image-prioritizer' ),
+				$min_path
+			),
+			E_USER_WARNING
+		);
+	}
+
+	if ( SCRIPT_DEBUG || $force_src ) {
+		return $src_path;
+	}
+
+	return $min_path;
+}
+
+/**
+ * Gets the script to lazy-load videos.
+ *
+ * Load a video and its poster image when it approaches the viewport using an IntersectionObserver.
+ *
+ * Handles 'autoplay' and 'preload' attributes accordingly.
+ *
+ * @since 0.2.0
+ * @access private
+ *
+ * @return string Lazy load script.
+ */
+function image_prioritizer_get_video_lazy_load_script(): string {
+	$path = image_prioritizer_get_asset_path( 'lazy-load-video.js' );
+	return (string) file_get_contents( __DIR__ . '/' . $path ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- It's a local filesystem path not a remote request.
+}
+
+/**
+ * Gets the script to lazy-load background images.
+ *
+ * Load the background image when it approaches the viewport using an IntersectionObserver.
+ *
+ * @since 0.3.0
+ * @access private
+ *
+ * @return string Lazy load script.
+ */
+function image_prioritizer_get_lazy_load_bg_image_script(): string {
+	$path = image_prioritizer_get_asset_path( 'lazy-load-bg-image.js' );
+	return (string) file_get_contents( __DIR__ . '/' . $path ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- It's a local filesystem path not a remote request.
+}
+
+/**
+ * Gets the stylesheet to lazy-load background images.
+ *
+ * @since 0.3.0
+ * @access private
+ *
+ * @return string Lazy load stylesheet.
+ */
+function image_prioritizer_get_lazy_load_bg_image_stylesheet(): string {
+	$path = image_prioritizer_get_asset_path( 'lazy-load-bg-image.css' );
+	return (string) file_get_contents( __DIR__ . '/' . $path ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- It's a local filesystem path not a remote request.
+}
Index: hooks.php
===================================================================
--- hooks.php	(revision 3209553)
+++ hooks.php	(working copy)
@@ -11,22 +11,6 @@
 }
 
 add_action( 'od_init', 'image_prioritizer_init' );
-
-/**
- * Gets the script to lazy-load videos.
- *
- * Load a video and its poster image when it approaches the viewport using an IntersectionObserver.
- *
- * Handles 'autoplay' and 'preload' attributes accordingly.
- *
- * @since 0.2.0
- */
-function image_prioritizer_get_lazy_load_script(): string {
-	$script = file_get_contents( __DIR__ . '/lazy-load.js' ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- It's a local filesystem path not a remote request.
-
-	if ( false === $script ) {
-		return '';
-	}
-
-	return $script;
-}
+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' );
+add_filter( 'rest_request_before_callbacks', 'image_prioritizer_filter_rest_request_before_callbacks', 10, 3 );
Index: load.php
===================================================================
--- load.php	(revision 3209553)
+++ load.php	(working copy)
@@ -2,11 +2,11 @@
 /**
  * Plugin Name: Image Prioritizer
  * Plugin URI: https://github.com/WordPress/performance/tree/trunk/plugins/image-prioritizer
- * Description: Optimizes LCP image loading with <code>fetchpriority=high</code> and applies image lazy-loading by leveraging client-side detection with real user metrics.
- * Requires at least: 6.5
+ * Description: Prioritizes the loading of images and videos based on how visible they are to actual visitors; adds <code>fetchpriority</code> and applies lazy-loading.
+ * Requires at least: 6.6
  * Requires PHP: 7.2
  * Requires Plugins: optimization-detective
- * Version: 0.2.0
+ * Version: 0.3.0
  * Author: WordPress Performance Team
  * Author URI: https://make.wordpress.org/performance/
  * License: GPLv2 or later
@@ -71,7 +71,7 @@
 	}
 )(
 	'image_prioritizer_pending_plugin',
-	'0.2.0',
+	'0.3.0',
 	static function ( string $version ): void {
 		if ( defined( 'IMAGE_PRIORITIZER_VERSION' ) ) {
 			return;
Index: readme.txt
===================================================================
--- readme.txt	(revision 3209553)
+++ readme.txt	(working copy)
@@ -2,32 +2,40 @@
 
 Contributors: wordpressdotorg
 Tested up to: 6.7
-Stable tag:   0.2.0
+Stable tag:   0.3.0
 License:      GPLv2 or later
 License URI:  https://www.gnu.org/licenses/gpl-2.0.html
 Tags:         performance, optimization, image, lcp, lazy-load
 
-Optimizes LCP image loading with `fetchpriority=high` and applies image lazy-loading by leveraging client-side detection with real user metrics.
+Prioritizes the loading of images and videos based on how visible they are to actual visitors; adds fetchpriority and applies lazy loading.
 
 == Description ==
 
-This plugin optimizes the loading of images which are the LCP (Largest Contentful Paint) element, including both `img` elements and elements with CSS background images (where there is a `style` attribute with an `background-image` property). Different breakpoints in a theme's responsive design may result in differing elements being the LCP element. Therefore, the LCP element for each breakpoint is captured so that high-fetchpriority preload links with media queries are added which prioritize loading the LCP image specific to the viewport of the visitor.
+This plugin optimizes the loading of images (and videos) with prioritization to improve [Largest Contentful Paint](https://web.dev/articles/lcp) (LCP), lazy loading, and more accurate image size selection.
 
-In addition to prioritizing the loading of the LCP image, this plugin also optimizes image loading by ensuring that `loading=lazy` is omitted from any image that appears in the initial viewport for any of the breakpoints, which by default include:
+The current optimizations include:
 
-1. 0-320 (small smartphone)
-2. 321-480 (normal smartphone)
-3. 481-576 (phablets)
-4. >576 (desktop)
+1. Add breakpoint-specific `fetchpriority=high` preload links (`LINK[rel=preload]`) for image URLs of LCP elements:
+   1. An `IMG` element, including the `srcset`/`sizes` attributes supplied as `imagesrcset`/`imagesizes` on the `LINK`.
+   2. The first `SOURCE` element with a `type` attribute in a `PICTURE` element. (Art-directed `PICTURE` elements using media queries are not supported.)
+   3. An element with a CSS `background-image` inline `style` attribute.
+   4. An element with a CSS `background-image` applied with a stylesheet (when the image is from an allowed origin).
+   5. A `VIDEO` element's `poster` image.
+2. Ensure `fetchpriority=high` is only added to an `IMG` when it is the LCP element across all responsive breakpoints.
+3. Add `fetchpriority=low` to `IMG` tags which appear in the initial viewport but are not visible, such as when they are subsequent carousel slides.
+4. Lazy loading:
+   1. Apply lazy loading to `IMG` tags based on whether they appear in any breakpoint’s initial viewport.
+   2. Implement lazy loading of CSS background images added via inline `style` attributes.
+   3. Lazy-load `VIDEO` tags by setting the appropriate attributes based on whether they appear in the initial viewport. If a `VIDEO` is the LCP element, it gets `preload=auto`; if it is in an initial viewport, the `preload=metadata` default is left; if it is not in an initial viewport, it gets `preload=none`. Lazy-loaded videos also get initial `preload`, `autoplay`, and `poster` attributes restored when the `VIDEO` is going to enter the viewport.
+5. Ensure that [`sizes=auto`](https://make.wordpress.org/core/2024/10/18/auto-sizes-for-lazy-loaded-images-in-wordpress-6-7/) is added to all lazy-loaded `IMG` elements.
+6. Reduce the size of the `poster` image of a `VIDEO` from full size to the size appropriate for the maximum width of the video (on desktop).
 
-If an image does not appear in the initial viewport for any of these viewport groups, then `loading=lazy` is added to the `img` element. 
+**This plugin requires the [Optimization Detective](https://wordpress.org/plugins/optimization-detective/) plugin as a dependency.** Please refer to that plugin for additional background on how this plugin works as well as additional developer options.
 
-👉 **Note:** This plugin optimizes pages for actual visitors, and it depends on visitors to optimize pages (since URL metrics need to be collected). As such, you won't see optimizations applied immediately after activating the plugin. And since administrator users are not normal visitors typically, optimizations are not applied for admins by default.
+👉 **Note:** This plugin optimizes pages for actual visitors, and it depends on visitors to optimize pages. As such, you won't see optimizations applied immediately after activating the plugin. Please wait for URL Metrics to be gathered for both mobile and desktop visits. And since administrator users are not normal visitors typically, optimizations are not applied for admins by default.
 
 There are currently **no settings** and no user interface for this plugin since it is designed to work without any configuration.
 
-This plugin requires the [Optimization Detective](https://wordpress.org/plugins/optimization-detective/) plugin as a dependency. Please refer to that plugin for additional background on how this plugin works as well as additional developer options. 
-
 == Installation ==
 
 = Installation from within WordPress =
@@ -62,6 +70,15 @@
 
 == Changelog ==
 
+= 0.3.0 =
+
+**Enhancements**
+
+* Add preload links LCP picture elements. ([1707](https://github.com/WordPress/performance/pull/1707))
+* Harden validation of user-submitted LCP background image URL. ([1713](https://github.com/WordPress/performance/pull/1713))
+* Lazy load background images added via inline style attributes. ([1708](https://github.com/WordPress/performance/pull/1708))
+* Preload image URLs for LCP elements with external background images. ([1697](https://github.com/WordPress/performance/pull/1697))
+
 = 0.2.0 =
 
 **Enhancements**

optimization-detective

Important

Stable tag change: 0.8.0 → 0.9.0

svn status:

M       class-od-html-tag-processor.php
M       class-od-link-collection.php
M       class-od-url-metric-group-collection.php
M       class-od-url-metric-group.php
M       class-od-url-metric.php
M       detect.js
M       detect.min.js
M       detection.php
M       helper.php
M       load.php
M       optimization.php
M       readme.txt
M       storage/class-od-url-metrics-post-type.php
M       storage/data.php
M       storage/rest-api.php
M       types.ts
svn diff
Index: class-od-html-tag-processor.php
===================================================================
--- class-od-html-tag-processor.php	(revision 3209553)
+++ class-od-html-tag-processor.php	(working copy)
@@ -273,7 +273,7 @@
 			$this->open_stack_tags    = array();
 			$this->open_stack_indices = array();
 
-			// Mark that the end of the document was reached, meaning that get_modified_html() can should now be able to append markup to the HEAD and the BODY.
+			// Mark that the end of the document was reached, meaning that get_modified_html() should now be able to append markup to the HEAD and the BODY.
 			$this->reached_end_of_document = true;
 			return false;
 		}
@@ -296,7 +296,7 @@
 				$i = array_search( 'P', $this->open_stack_tags, true );
 				if ( false !== $i ) {
 					array_splice( $this->open_stack_tags, (int) $i );
-					array_splice( $this->open_stack_indices, count( $this->open_stack_tags ) );
+					array_splice( $this->open_stack_indices, count( $this->open_stack_tags ) + 1 );
 				}
 			}
 
@@ -497,10 +497,11 @@
 	 * A breadcrumb consists of a tag name and its sibling index.
 	 *
 	 * @since 0.4.0
+	 * @since 0.9.0 Renamed from get_breadcrumbs() to get_indexed_breadcrumbs().
 	 *
 	 * @return Generator<array{string, int}> Breadcrumb.
 	 */
-	private function get_breadcrumbs(): Generator {
+	private function get_indexed_breadcrumbs(): Generator {
 		foreach ( $this->open_stack_tags as $i => $breadcrumb_tag_name ) {
 			yield array( $breadcrumb_tag_name, $this->open_stack_indices[ $i ] );
 		}
@@ -507,6 +508,21 @@
 	}
 
 	/**
+	 * Computes the HTML breadcrumbs for the currently-matched node, if matched.
+	 *
+	 * Breadcrumbs start at the outermost parent and descend toward the matched element.
+	 * They always include the entire path from the root HTML node to the matched element.
+	 *
+	 * @since 0.9.0
+	 * @see WP_HTML_Processor::get_breadcrumbs()
+	 *
+	 * @return string[] Array of tag names representing path to matched node.
+	 */
+	public function get_breadcrumbs(): array {
+		return $this->open_stack_tags;
+	}
+
+	/**
 	 * Determines whether currently inside a foreign element (MATH or SVG).
 	 *
 	 * @since 0.4.0
@@ -535,7 +551,7 @@
 	public function get_xpath(): string {
 		if ( null === $this->current_xpath ) {
 			$this->current_xpath = '';
-			foreach ( $this->get_breadcrumbs() as list( $tag_name, $index ) ) {
+			foreach ( $this->get_indexed_breadcrumbs() as list( $tag_name, $index ) ) {
 				$this->current_xpath .= sprintf( '/*[%d][self::%s]', $index + 1, $tag_name );
 			}
 		}
@@ -629,8 +645,15 @@
 	 *
 	 * @param string $function_name Function name.
 	 * @param string $message       Warning message.
+	 *
+	 * @noinspection PhpDocMissingThrowsInspection
 	 */
 	private function warn( string $function_name, string $message ): void {
+		/**
+		 * No WP_Exception is thrown by wp_trigger_error() since E_USER_ERROR is not passed as the error level.
+		 *
+		 * @noinspection PhpUnhandledExceptionInspection
+		 */
 		wp_trigger_error(
 			$function_name,
 			esc_html( $message )
Index: class-od-link-collection.php
===================================================================
--- class-od-link-collection.php	(revision 3209553)
+++ class-od-link-collection.php	(working copy)
@@ -29,6 +29,7 @@
  *                   fetchpriority?: 'high'|'low'|'auto',
  *                   as?: 'audio'|'document'|'embed'|'fetch'|'font'|'image'|'object'|'script'|'style'|'track'|'video'|'worker',
  *                   media?: non-empty-string,
+ *                   type?: non-empty-string,
  *                   integrity?: non-empty-string,
  *                   referrerpolicy?: 'no-referrer'|'no-referrer-when-downgrade'|'origin'|'origin-when-cross-origin'|'unsafe-url'
  *               }
@@ -130,18 +131,29 @@
 	 */
 	private function merge_consecutive_links( array $links ): array {
 
-		// Ensure links are sorted by the minimum_viewport_width.
 		usort(
 			$links,
 			/**
 			 * Comparator.
 			 *
+			 * The links are sorted first by the 'href' attribute to group identical URLs together.
+			 * If the 'href' attributes are the same, the links are then sorted by 'minimum_viewport_width'.
+			 *
 			 * @param Link $a First link.
 			 * @param Link $b Second link.
 			 * @return int Comparison result.
 			 */
 			static function ( array $a, array $b ): int {
-				return $a['minimum_viewport_width'] <=> $b['minimum_viewport_width'];
+				// Get href values, defaulting to empty string if not present.
+				$href_a = $a['attributes']['href'] ?? '';
+				$href_b = $b['attributes']['href'] ?? '';
+
+				$href_comparison = strcmp( $href_a, $href_b );
+				if ( 0 === $href_comparison ) {
+					return $a['minimum_viewport_width'] <=> $b['minimum_viewport_width'];
+				}
+
+				return $href_comparison;
 			}
 		);
 
Index: class-od-url-metric-group-collection.php
===================================================================
--- class-od-url-metric-group-collection.php	(revision 3209553)
+++ class-od-url-metric-group-collection.php	(working copy)
@@ -36,6 +36,14 @@
 	private $groups;
 
 	/**
+	 * The current ETag.
+	 *
+	 * @since 0.9.0
+	 * @var non-empty-string
+	 */
+	private $current_etag;
+
+	/**
 	 * Breakpoints in max widths.
 	 *
 	 * Valid values are from 1 to PHP_INT_MAX - 1. This is because:
@@ -93,12 +101,27 @@
 	 *
 	 * @throws InvalidArgumentException When an invalid argument is supplied.
 	 *
-	 * @param OD_URL_Metric[] $url_metrics   URL Metrics.
-	 * @param int[]           $breakpoints   Breakpoints in max widths.
-	 * @param int             $sample_size   Sample size for the maximum number of viewports in a group between breakpoints.
-	 * @param int             $freshness_ttl Freshness age (TTL) for a given URL Metric.
+	 * @param OD_URL_Metric[]  $url_metrics   URL Metrics.
+	 * @param non-empty-string $current_etag  The current ETag.
+	 * @param int[]            $breakpoints   Breakpoints in max widths.
+	 * @param int              $sample_size   Sample size for the maximum number of viewports in a group between breakpoints.
+	 * @param int              $freshness_ttl Freshness age (TTL) for a given URL Metric.
 	 */
-	public function __construct( array $url_metrics, array $breakpoints, int $sample_size, int $freshness_ttl ) {
+	public function __construct( array $url_metrics, string $current_etag, array $breakpoints, int $sample_size, int $freshness_ttl ) {
+		// Set current ETag.
+		if ( 1 !== preg_match( '/^[a-f0-9]{32}\z/', $current_etag ) ) {
+			throw new InvalidArgumentException(
+				esc_html(
+					sprintf(
+						/* translators: %s is the invalid ETag */
+						__( 'The current ETag must be a valid MD5 hash, but provided: %s', 'optimization-detective' ),
+						$current_etag
+					)
+				)
+			);
+		}
+		$this->current_etag = $current_etag;
+
 		// Set breakpoints.
 		sort( $breakpoints );
 		$breakpoints = array_values( array_unique( $breakpoints, SORT_NUMERIC ) );
@@ -161,6 +184,17 @@
 	}
 
 	/**
+	 * Gets the current ETag.
+	 *
+	 * @since 0.9.0
+	 *
+	 * @return non-empty-string Current ETag.
+	 */
+	public function get_current_etag(): string {
+		return $this->current_etag;
+	}
+
+	/**
 	 * Gets the first URL Metric group.
 	 *
 	 * This group normally represents viewports for mobile devices. This group always has a minimum viewport width of 0
@@ -191,7 +225,7 @@
 	}
 
 	/**
-	 * Clear result cache.
+	 * Clears result cache.
 	 *
 	 * @since 0.3.0
 	 */
@@ -200,7 +234,7 @@
 	}
 
 	/**
-	 * Create groups.
+	 * Creates groups.
 	 *
 	 * @since 0.1.0
 	 *
@@ -393,6 +427,7 @@
 	 * Gets common LCP element.
 	 *
 	 * @since 0.3.0
+	 * @since 0.9.0 An LCP element is also considered common if it is the same in the narrowest and widest viewport groups, and all intermediate groups are empty.
 	 *
 	 * @return OD_Element|null Common LCP element if it exists.
 	 */
@@ -403,38 +438,40 @@
 
 		$result = ( function () {
 
-			// If every group isn't populated, then we can't say whether there is a common LCP element across every viewport group.
-			if ( ! $this->is_every_group_populated() ) {
+			// Ensure both the narrowest (first) and widest (last) viewport groups are populated.
+			$first_group = $this->get_first_group();
+			$last_group  = $this->get_last_group();
+			if ( $first_group->count() === 0 || $last_group->count() === 0 ) {
 				return null;
 			}
 
-			// Look at the LCP elements across all the viewport groups.
-			$groups_by_lcp_element_xpath   = array();
-			$lcp_elements_by_xpath         = array();
-			$group_has_unknown_lcp_element = false;
-			foreach ( $this->groups as $group ) {
-				$lcp_element = $group->get_lcp_element();
-				if ( $lcp_element instanceof OD_Element ) {
-					$groups_by_lcp_element_xpath[ $lcp_element->get_xpath() ][] = $group;
-					$lcp_elements_by_xpath[ $lcp_element->get_xpath() ][]       = $lcp_element;
-				} else {
-					$group_has_unknown_lcp_element = true;
-				}
-			}
+			$first_group_lcp_element = $first_group->get_lcp_element();
+			$last_group_lcp_element  = $last_group->get_lcp_element();
 
+			// Validate LCP elements exist and have matching XPaths in the extreme viewport groups.
 			if (
-				// All breakpoints share the same LCP element.
-				1 === count( $groups_by_lcp_element_xpath )
-				&&
-				// The breakpoints don't share a common lack of a detected LCP element.
-				! $group_has_unknown_lcp_element
+				! $first_group_lcp_element instanceof OD_Element
+				||
+				! $last_group_lcp_element instanceof OD_Element
+				||
+				$first_group_lcp_element->get_xpath() !== $last_group_lcp_element->get_xpath()
 			) {
-				$xpath = key( $lcp_elements_by_xpath );
+				return null; // No common LCP element across the narrowest and widest viewports.
+			}
 
-				return $lcp_elements_by_xpath[ $xpath ][0];
+			// Check intermediate viewport groups for conflicting LCP elements.
+			foreach ( array_slice( $this->groups, 1, -1 ) as $group ) {
+				$group_lcp_element = $group->get_lcp_element();
+				if (
+					$group_lcp_element instanceof OD_Element
+					&&
+					$group_lcp_element->get_xpath() !== $first_group_lcp_element->get_xpath()
+				) {
+					return null; // Conflicting LCP element found in an intermediate group.
+				}
 			}
 
-			return null;
+			return $first_group_lcp_element;
 		} )();
 
 		$this->result_cache[ __FUNCTION__ ] = $result;
@@ -461,9 +498,9 @@
 		$result = ( function () {
 			$all_elements = array();
 			foreach ( $this->groups as $group ) {
-				foreach ( $group as $url_metric ) {
-					foreach ( $url_metric->get_elements() as $element ) {
-						$all_elements[ $element->get_xpath() ][] = $element;
+				foreach ( $group->get_xpath_elements_map() as $xpath => $elements ) {
+					foreach ( $elements as $element ) {
+						$all_elements[ $xpath ][] = $element;
 					}
 				}
 			}
@@ -488,12 +525,13 @@
 
 		$result = ( function () {
 			$elements_max_intersection_ratios = array();
-			foreach ( $this->get_xpath_elements_map() as $xpath => $elements ) {
-				$element_intersection_ratios = array();
-				foreach ( $elements as $element ) {
-					$element_intersection_ratios[] = $element->get_intersection_ratio();
+			foreach ( $this->groups as $group ) {
+				foreach ( $group->get_all_element_max_intersection_ratios() as $xpath => $element_max_intersection_ratio ) {
+					$elements_max_intersection_ratios[ $xpath ] = (float) max(
+						$elements_max_intersection_ratios[ $xpath ] ?? 0,
+						$element_max_intersection_ratio
+					);
 				}
-				$elements_max_intersection_ratios[ $xpath ] = (float) max( $element_intersection_ratios );
 			}
 			return $elements_max_intersection_ratios;
 		} )();
@@ -612,6 +650,7 @@
 	 * @since 0.3.1
 	 *
 	 * @return array{
+	 *             current_etag: non-empty-string,
 	 *             breakpoints: positive-int[],
 	 *             freshness_ttl: 0|positive-int,
 	 *             sample_size: positive-int,
@@ -630,6 +669,7 @@
 	 */
 	public function jsonSerialize(): array {
 		return array(
+			'current_etag'                        => $this->current_etag,
 			'breakpoints'                         => $this->breakpoints,
 			'freshness_ttl'                       => $this->freshness_ttl,
 			'sample_size'                         => $this->sample_size,
Index: class-od-url-metric-group.php
===================================================================
--- class-od-url-metric-group.php	(revision 3209553)
+++ class-od-url-metric-group.php	(working copy)
@@ -24,6 +24,8 @@
 	/**
 	 * URL Metrics.
 	 *
+	 * @since 0.1.0
+	 *
 	 * @var OD_URL_Metric[]
 	 */
 	private $url_metrics;
@@ -31,6 +33,8 @@
 	/**
 	 * Minimum possible viewport width for the group (inclusive).
 	 *
+	 * @since 0.1.0
+	 *
 	 * @var int
 	 * @phpstan-var 0|positive-int
 	 */
@@ -39,6 +43,8 @@
 	/**
 	 * Maximum possible viewport width for the group (inclusive).
 	 *
+	 * @since 0.1.0
+	 *
 	 * @var int
 	 * @phpstan-var positive-int
 	 */
@@ -47,6 +53,8 @@
 	/**
 	 * Sample size for URL Metrics for a given breakpoint.
 	 *
+	 * @since 0.1.0
+	 *
 	 * @var int
 	 * @phpstan-var positive-int
 	 */
@@ -55,6 +63,8 @@
 	/**
 	 * Freshness age (TTL) for a given URL Metric.
 	 *
+	 * @since 0.1.0
+	 *
 	 * @var int
 	 * @phpstan-var 0|positive-int
 	 */
@@ -63,7 +73,9 @@
 	/**
 	 * Collection that this instance belongs to.
 	 *
-	 * @var OD_URL_Metric_Group_Collection|null
+	 * @since 0.3.0
+	 *
+	 * @var OD_URL_Metric_Group_Collection
 	 */
 	private $collection;
 
@@ -70,9 +82,13 @@
 	/**
 	 * Result cache.
 	 *
+	 * @since 0.3.0
+	 *
 	 * @var array{
 	 *          get_lcp_element?: OD_Element|null,
-	 *          is_complete?: bool
+	 *          is_complete?: bool,
+	 *          get_xpath_elements_map?: array<string, non-empty-array<int, OD_Element>>,
+	 *          get_all_element_max_intersection_ratios?: array<string, float>,
 	 *      }
 	 */
 	private $result_cache = array();
@@ -80,16 +96,19 @@
 	/**
 	 * Constructor.
 	 *
+	 * This class should never be directly constructed. It should only be constructed by the {@see OD_URL_Metric_Group_Collection::create_groups()}.
+	 *
+	 * @access private
 	 * @throws InvalidArgumentException If arguments are invalid.
 	 *
-	 * @param OD_URL_Metric[]                     $url_metrics            URL Metrics to add to the group.
-	 * @param int                                 $minimum_viewport_width Minimum possible viewport width for the group. Must be zero or greater.
-	 * @param int                                 $maximum_viewport_width Maximum possible viewport width for the group. Must be greater than zero and the minimum viewport width.
-	 * @param int                                 $sample_size            Sample size for the maximum number of viewports in a group between breakpoints.
-	 * @param int                                 $freshness_ttl          Freshness age (TTL) for a given URL Metric.
-	 * @param OD_URL_Metric_Group_Collection|null $collection             Collection that this instance belongs to. Optional.
+	 * @param OD_URL_Metric[]                $url_metrics            URL Metrics to add to the group.
+	 * @param int                            $minimum_viewport_width Minimum possible viewport width for the group. Must be zero or greater.
+	 * @param int                            $maximum_viewport_width Maximum possible viewport width for the group. Must be greater than zero and the minimum viewport width.
+	 * @param int                            $sample_size            Sample size for the maximum number of viewports in a group between breakpoints.
+	 * @param int                            $freshness_ttl          Freshness age (TTL) for a given URL Metric.
+	 * @param OD_URL_Metric_Group_Collection $collection             Collection that this instance belongs to.
 	 */
-	public function __construct( array $url_metrics, int $minimum_viewport_width, int $maximum_viewport_width, int $sample_size, int $freshness_ttl, ?OD_URL_Metric_Group_Collection $collection = null ) {
+	public function __construct( array $url_metrics, int $minimum_viewport_width, int $maximum_viewport_width, int $sample_size, int $freshness_ttl, OD_URL_Metric_Group_Collection $collection ) {
 		if ( $minimum_viewport_width < 0 ) {
 			throw new InvalidArgumentException(
 				esc_html__( 'The minimum viewport width must be at least zero.', 'optimization-detective' )
@@ -133,17 +152,15 @@
 			);
 		}
 		$this->freshness_ttl = $freshness_ttl;
-
-		if ( ! is_null( $collection ) ) {
-			$this->collection = $collection;
-		}
-
-		$this->url_metrics = $url_metrics;
+		$this->collection    = $collection;
+		$this->url_metrics   = $url_metrics;
 	}
 
 	/**
 	 * Gets the minimum possible viewport width (inclusive).
 	 *
+	 * @since 0.1.0
+	 *
 	 * @todo Eliminate in favor of readonly public property.
 	 * @return int<0, max> Minimum viewport width.
 	 */
@@ -154,6 +171,8 @@
 	/**
 	 * Gets the maximum possible viewport width (inclusive).
 	 *
+	 * @since 0.1.0
+	 *
 	 * @todo Eliminate in favor of readonly public property.
 	 * @return int<1, max> Minimum viewport width.
 	 */
@@ -162,8 +181,36 @@
 	}
 
 	/**
-	 * Checks whether the provided viewport width is within the minimum/maximum range for
+	 * Gets the sample size for URL Metrics for a given breakpoint.
 	 *
+	 * @since 0.9.0
+	 *
+	 * @todo Eliminate in favor of readonly public property.
+	 * @phpstan-return positive-int
+	 * @return int Sample size.
+	 */
+	public function get_sample_size(): int {
+		return $this->sample_size;
+	}
+
+	/**
+	 * Gets the freshness age (TTL) for a given URL Metric.
+	 *
+	 * @since 0.9.0
+	 *
+	 * @todo Eliminate in favor of readonly public property.
+	 * @phpstan-return 0|positive-int
+	 * @return int Freshness age.
+	 */
+	public function get_freshness_ttl(): int {
+		return $this->freshness_ttl;
+	}
+
+	/**
+	 * Checks whether the provided viewport width is within the minimum/maximum range for.
+	 *
+	 * @since 0.1.0
+	 *
 	 * @param int $viewport_width Viewport width.
 	 * @return bool Whether the viewport width is in range.
 	 */
@@ -177,6 +224,8 @@
 	/**
 	 * Adds a URL Metric to the group.
 	 *
+	 * @since 0.1.0
+	 *
 	 * @throws InvalidArgumentException If the viewport width of the URL Metric is not within the min/max bounds of the group.
 	 *
 	 * @param OD_URL_Metric $url_metric URL Metric.
@@ -188,10 +237,7 @@
 			);
 		}
 
-		$this->result_cache = array();
-		if ( ! is_null( $this->collection ) ) {
-			$this->collection->clear_cache();
-		}
+		$this->clear_cache();
 
 		$url_metric->set_group( $this );
 		$this->url_metrics[] = $url_metric;
@@ -218,6 +264,9 @@
 	 * A group is complete if it has the full sample size of URL Metrics
 	 * and all of these URL Metrics are fresh.
 	 *
+	 * @since 0.1.0
+	 * @since 0.9.0 If the current environment's generated ETag does not match the URL Metric's ETag, the URL Metric is considered stale.
+	 *
 	 * @return bool Whether complete.
 	 */
 	public function is_complete(): bool {
@@ -231,9 +280,20 @@
 			}
 			$current_time = microtime( true );
 			foreach ( $this->url_metrics as $url_metric ) {
+				// The URL Metric is too old to be fresh.
 				if ( $current_time > $url_metric->get_timestamp() + $this->freshness_ttl ) {
 					return false;
 				}
+
+				// The ETag is not populated yet, so this is stale. Eventually this will be required.
+				if ( $url_metric->get_etag() === null ) {
+					return false;
+				}
+
+				// The ETag of the URL Metric does not match the current ETag for the collection, so it is stale.
+				if ( ! hash_equals( $url_metric->get_etag(), $this->collection->get_current_etag() ) ) {
+					return false;
+				}
 			}
 
 			return true;
@@ -246,6 +306,8 @@
 	/**
 	 * Gets the LCP element in the viewport group.
 	 *
+	 * @since 0.3.0
+	 *
 	 * @return OD_Element|null LCP element data or null if not available, either because there are no URL Metrics or
 	 *                          the LCP element type is not supported.
 	 */
@@ -321,8 +383,76 @@
 	}
 
 	/**
+	 * Gets all elements from all URL Metrics in the viewport group keyed by the elements' XPaths.
+	 *
+	 * @since 0.9.0
+	 *
+	 * @return array<string, non-empty-array<int, OD_Element>> Keys are XPaths and values are the element instances.
+	 */
+	public function get_xpath_elements_map(): array {
+		if ( array_key_exists( __FUNCTION__, $this->result_cache ) ) {
+			return $this->result_cache[ __FUNCTION__ ];
+		}
+
+		$result = ( function () {
+			$all_elements = array();
+			foreach ( $this->url_metrics as $url_metric ) {
+				foreach ( $url_metric->get_elements() as $element ) {
+					$all_elements[ $element->get_xpath() ][] = $element;
+				}
+			}
+			return $all_elements;
+		} )();
+
+		$this->result_cache[ __FUNCTION__ ] = $result;
+		return $result;
+	}
+
+	/**
+	 * Gets the max intersection ratios of all elements in the viewport group and its captured URL Metrics.
+	 *
+	 * @since 0.9.0
+	 *
+	 * @return array<string, float> Keys are XPaths and values are the intersection ratios.
+	 */
+	public function get_all_element_max_intersection_ratios(): array {
+		if ( array_key_exists( __FUNCTION__, $this->result_cache ) ) {
+			return $this->result_cache[ __FUNCTION__ ];
+		}
+
+		$result = ( function () {
+			$elements_max_intersection_ratios = array();
+			foreach ( $this->get_xpath_elements_map() as $xpath => $elements ) {
+				$element_intersection_ratios = array();
+				foreach ( $elements as $element ) {
+					$element_intersection_ratios[] = $element->get_intersection_ratio();
+				}
+				$elements_max_intersection_ratios[ $xpath ] = (float) max( $element_intersection_ratios );
+			}
+			return $elements_max_intersection_ratios;
+		} )();
+
+		$this->result_cache[ __FUNCTION__ ] = $result;
+		return $result;
+	}
+
+	/**
+	 * Gets the max intersection ratio of an element in the viewport group and its captured URL Metrics.
+	 *
+	 * @since 0.9.0
+	 *
+	 * @param string $xpath XPath for the element.
+	 * @return float|null Max intersection ratio of null if tag is unknown (not captured).
+	 */
+	public function get_element_max_intersection_ratio( string $xpath ): ?float {
+		return $this->get_all_element_max_intersection_ratios()[ $xpath ] ?? null;
+	}
+
+	/**
 	 * Returns an iterator for the URL Metrics in the group.
 	 *
+	 * @since 0.1.0
+	 *
 	 * @return ArrayIterator<int, OD_URL_Metric> ArrayIterator for OD_URL_Metric instances.
 	 */
 	public function getIterator(): ArrayIterator {
@@ -332,6 +462,8 @@
 	/**
 	 * Counts the URL Metrics in the group.
 	 *
+	 * @since 0.1.0
+	 *
 	 * @return int<0, max> URL Metric count.
 	 */
 	public function count(): int {
@@ -339,6 +471,16 @@
 	}
 
 	/**
+	 * Clears result cache.
+	 *
+	 * @since 0.9.0
+	 */
+	public function clear_cache(): void {
+		$this->result_cache = array();
+		$this->collection->clear_cache();
+	}
+
+	/**
 	 * Specifies data which should be serialized to JSON.
 	 *
 	 * @since 0.3.1
Index: class-od-url-metric.php
===================================================================
--- class-od-url-metric.php	(revision 3209553)
+++ class-od-url-metric.php	(working copy)
@@ -38,6 +38,7 @@
  *                            }
  * @phpstan-type Data         array{
  *                                uuid: non-empty-string,
+ *                                etag?: non-empty-string,
  *                                url: non-empty-string,
  *                                timestamp: float,
  *                                viewport: ViewportRect,
@@ -155,6 +156,7 @@
 	 * Gets JSON schema for URL Metric.
 	 *
 	 * @since 0.1.0
+	 * @since 0.9.0 Added the 'etag' property to the schema.
 	 *
 	 * @todo Cache the return value?
 	 *
@@ -208,6 +210,15 @@
 					'required'    => true,
 					'readonly'    => true, // Omit from REST API.
 				),
+				'etag'      => array(
+					'description' => __( 'The ETag for the URL Metric.', 'optimization-detective' ),
+					'type'        => 'string',
+					'pattern'     => '^[0-9a-f]{32}\z',
+					'minLength'   => 32,
+					'maxLength'   => 32,
+					'required'    => false, // To be made required in a future release.
+					'readonly'    => true, // Omit from REST API.
+				),
 				'url'       => array(
 					'description' => __( 'The URL for which the metric was obtained.', 'optimization-detective' ),
 					'type'        => 'string',
@@ -309,7 +320,7 @@
 			$schema['properties']['elements']['items']['properties'] = self::extend_schema_with_optional_properties(
 				$schema['properties']['elements']['items']['properties'],
 				$additional_properties,
-				'od_url_metric_schema_root_additional_properties'
+				'od_url_metric_schema_element_item_additional_properties'
 			);
 		}
 
@@ -418,6 +429,18 @@
 	}
 
 	/**
+	 * Gets ETag.
+	 *
+	 * @since 0.9.0
+	 *
+	 * @return non-empty-string|null ETag.
+	 */
+	public function get_etag(): ?string {
+		// Since the ETag is optional for now, return null for old URL Metrics that do not have one.
+		return $this->data['etag'] ?? null;
+	}
+
+	/**
 	 * Gets URL.
 	 *
 	 * @since 0.1.0
@@ -488,6 +511,15 @@
 	 * @return Data Exports to be serialized by json_encode().
 	 */
 	public function jsonSerialize(): array {
-		return $this->data;
+		$data = $this->data;
+
+		$data['elements'] = array_map(
+			static function ( OD_Element $element ): array {
+				return $element->jsonSerialize();
+			},
+			$this->get_elements()
+		);
+
+		return $data;
 	}
 }
Index: detect.js
===================================================================
--- detect.js	(revision 3209553)
+++ detect.js	(working copy)
@@ -1,6 +1,11 @@
 /**
  * @typedef {import("web-vitals").LCPMetric} LCPMetric
  * @typedef {import("./types.ts").ElementData} ElementData
+ * @typedef {import("./types.ts").OnTTFBFunction} OnTTFBFunction
+ * @typedef {import("./types.ts").OnFCPFunction} OnFCPFunction
+ * @typedef {import("./types.ts").OnLCPFunction} OnLCPFunction
+ * @typedef {import("./types.ts").OnINPFunction} OnINPFunction
+ * @typedef {import("./types.ts").OnCLSFunction} OnCLSFunction
  * @typedef {import("./types.ts").URLMetric} URLMetric
  * @typedef {import("./types.ts").URLMetricGroupStatus} URLMetricGroupStatus
  * @typedef {import("./types.ts").Extension} Extension
@@ -229,6 +234,11 @@
 }
 
 /**
+ * @typedef {{timestamp: number, creationDate: Date}} UrlMetricDebugData
+ * @typedef {{groups: Array<{url_metrics: Array<UrlMetricDebugData>}>}} CollectionDebugData
+ */
+
+/**
  * Detects the LCP element, loaded images, client viewport and store for future optimizations.
  *
  * @param {Object}                 args                            Args.
@@ -237,6 +247,7 @@
  * @param {number}                 args.maxViewportAspectRatio     Maximum aspect ratio allowed for the viewport.
  * @param {boolean}                args.isDebug                    Whether to show debug messages.
  * @param {string}                 args.restApiEndpoint            URL for where to send the detection data.
+ * @param {string}                 args.currentETag                Current ETag.
  * @param {string}                 args.currentUrl                 Current URL.
  * @param {string}                 args.urlMetricSlug              Slug for URL Metric.
  * @param {number|null}            args.cachePurgePostId           Cache purge post ID.
@@ -244,7 +255,7 @@
  * @param {URLMetricGroupStatus[]} args.urlMetricGroupStatuses     URL Metric group statuses.
  * @param {number}                 args.storageLockTTL             The TTL (in seconds) for the URL Metric storage lock.
  * @param {string}                 args.webVitalsLibrarySrc        The URL for the web-vitals library.
- * @param {Object}                 [args.urlMetricGroupCollection] URL Metric group collection, when in debug mode.
+ * @param {CollectionDebugData}    [args.urlMetricGroupCollection] URL Metric group collection, when in debug mode.
  */
 export default async function detect( {
 	minViewportAspectRatio,
@@ -252,6 +263,7 @@
 	isDebug,
 	extensionModuleUrls,
 	restApiEndpoint,
+	currentETag,
 	currentUrl,
 	urlMetricSlug,
 	cachePurgePostId,
@@ -262,7 +274,21 @@
 	urlMetricGroupCollection,
 } ) {
 	if ( isDebug ) {
-		log( 'Stored URL Metric group collection:', urlMetricGroupCollection );
+		const allUrlMetrics = /** @type Array<UrlMetricDebugData> */ [];
+		for ( const group of urlMetricGroupCollection.groups ) {
+			for ( const otherUrlMetric of group.url_metrics ) {
+				otherUrlMetric.creationDate = new Date(
+					otherUrlMetric.timestamp * 1000
+				);
+				allUrlMetrics.push( otherUrlMetric );
+			}
+		}
+		log( 'Stored URL Metric Group Collection:', urlMetricGroupCollection );
+		allUrlMetrics.sort( ( a, b ) => b.timestamp - a.timestamp );
+		log(
+			'Stored URL Metrics in reverse chronological order:',
+			allUrlMetrics
+		);
 	}
 
 	// Abort if the current viewport is not among those which need URL Metrics.
@@ -323,6 +349,24 @@
 		return;
 	}
 
+	// Keep track of whether the window resized. If it resized, we abort sending the URLMetric.
+	let didWindowResize = false;
+	window.addEventListener(
+		'resize',
+		() => {
+			didWindowResize = true;
+		},
+		{ once: true }
+	);
+
+	const {
+		/** @type OnTTFBFunction */ onTTFB,
+		/** @type OnFCPFunction */ onFCP,
+		/** @type OnLCPFunction */ onLCP,
+		/** @type OnINPFunction */ onINP,
+		/** @type OnCLSFunction */ onCLS,
+	} = await import( webVitalsLibrarySrc );
+
 	// TODO: Does this make sense here?
 	// Prevent detection when page is not scrolled to the initial viewport.
 	if ( doc.documentElement.scrollTop > 0 ) {
@@ -340,23 +384,57 @@
 
 	/** @type {Map<string, Extension>} */
 	const extensions = new Map();
+
+	/** @type {Promise[]} */
+	const extensionInitializePromises = [];
+
+	/** @type {string[]} */
+	const initializingExtensionModuleUrls = [];
+
 	for ( const extensionModuleUrl of extensionModuleUrls ) {
 		try {
 			/** @type {Extension} */
 			const extension = await import( extensionModuleUrl );
 			extensions.set( extensionModuleUrl, extension );
-			// TODO: There should to be a way to pass additional args into the module. Perhaps extensionModuleUrls should be a mapping of URLs to args. It's important to pass webVitalsLibrarySrc to the extension so that onLCP, onCLS, or onINP can be obtained.
+			// TODO: There should to be a way to pass additional args into the module. Perhaps extensionModuleUrls should be a mapping of URLs to args.
 			if ( extension.initialize instanceof Function ) {
-				extension.initialize( { isDebug } );
+				const initializePromise = extension.initialize( {
+					isDebug,
+					onTTFB,
+					onFCP,
+					onLCP,
+					onINP,
+					onCLS,
+				} );
+				if ( initializePromise instanceof Promise ) {
+					extensionInitializePromises.push( initializePromise );
+					initializingExtensionModuleUrls.push( extensionModuleUrl );
+				}
 			}
 		} catch ( err ) {
 			error(
-				`Failed to initialize extension '${ extensionModuleUrl }':`,
+				`Failed to start initializing extension '${ extensionModuleUrl }':`,
 				err
 			);
 		}
 	}
 
+	// Wait for all extensions to finish initializing.
+	const settledInitializePromises = await Promise.allSettled(
+		extensionInitializePromises
+	);
+	for ( const [
+		i,
+		settledInitializePromise,
+	] of settledInitializePromises.entries() ) {
+		if ( settledInitializePromise.status === 'rejected' ) {
+			error(
+				`Failed to initialize extension '${ initializingExtensionModuleUrls[ i ] }':`,
+				settledInitializePromise.reason
+			);
+		}
+	}
+
 	const breadcrumbedElements = doc.body.querySelectorAll( '[data-od-xpath]' );
 
 	/** @type {Map<Element, string>} */
@@ -412,8 +490,6 @@
 		} );
 	}
 
-	const { onLCP } = await import( webVitalsLibrarySrc );
-
 	/** @type {LCPMetric[]} */
 	const lcpMetricCandidates = [];
 
@@ -505,7 +581,24 @@
 		);
 	} );
 
+	// Only proceed with submitting the URL Metric if viewport stayed the same size. Changing the viewport size (e.g. due
+	// to resizing a window or changing the orientation of a device) will result in unexpected metrics being collected.
+	if ( didWindowResize ) {
+		if ( isDebug ) {
+			log(
+				'Aborting URL Metric collection due to viewport size change.'
+			);
+		}
+		return;
+	}
+
 	if ( extensions.size > 0 ) {
+		/** @type {Promise[]} */
+		const extensionFinalizePromises = [];
+
+		/** @type {string[]} */
+		const finalizingExtensionModuleUrls = [];
+
 		for ( const [
 			extensionModuleUrl,
 			extension,
@@ -512,7 +605,7 @@
 		] of extensions.entries() ) {
 			if ( extension.finalize instanceof Function ) {
 				try {
-					await extension.finalize( {
+					const finalizePromise = extension.finalize( {
 						isDebug,
 						getRootData,
 						getElementData,
@@ -519,14 +612,36 @@
 						extendElementData,
 						extendRootData,
 					} );
+					if ( finalizePromise instanceof Promise ) {
+						extensionFinalizePromises.push( finalizePromise );
+						finalizingExtensionModuleUrls.push(
+							extensionModuleUrl
+						);
+					}
 				} catch ( err ) {
 					error(
-						`Unable to finalize module '${ extensionModuleUrl }':`,
+						`Unable to start finalizing extension '${ extensionModuleUrl }':`,
 						err
 					);
 				}
 			}
 		}
+
+		// Wait for all extensions to finish finalizing.
+		const settledFinalizePromises = await Promise.allSettled(
+			extensionFinalizePromises
+		);
+		for ( const [
+			i,
+			settledFinalizePromise,
+		] of settledFinalizePromises.entries() ) {
+			if ( settledFinalizePromise.status === 'rejected' ) {
+				error(
+					`Failed to finalize extension '${ finalizingExtensionModuleUrls[ i ] }':`,
+					settledFinalizePromise.reason
+				);
+			}
+		}
 	}
 
 	// Even though the server may reject the REST API request, we still have to set the storage lock
@@ -539,6 +654,7 @@
 
 	const url = new URL( restApiEndpoint );
 	url.searchParams.set( 'slug', urlMetricSlug );
+	url.searchParams.set( 'current_etag', currentETag );
 	if ( typeof cachePurgePostId === 'number' ) {
 		url.searchParams.set(
 			'cache_purge_post_id',
Index: detect.min.js
===================================================================
--- detect.min.js	(revision 3209553)
+++ detect.min.js	(working copy)
@@ -1 +1 @@
-const win=window,doc=win.document,consoleLogPrefix="[Optimization Detective]",storageLockTimeSessionKey="odStorageLockTime";function isStorageLocked(e,t){if(0===t)return!1;try{const n=parseInt(sessionStorage.getItem("odStorageLockTime"));return!isNaN(n)&&e<n+1e3*t}catch(e){return!1}}function setStorageLock(e){try{sessionStorage.setItem("odStorageLockTime",String(e))}catch(e){}}function log(...e){console.log(consoleLogPrefix,...e)}function warn(...e){console.warn(consoleLogPrefix,...e)}function error(...e){console.error(consoleLogPrefix,...e)}function isViewportNeeded(e,t){let n=!1;for(const{minimumViewportWidth:o,complete:r}of t){if(!(e>=o))break;n=!r}return n}function getCurrentTime(){return Date.now()}function recursiveFreeze(e){for(const t of Object.getOwnPropertyNames(e)){const n=e[t];null!==n&&"object"==typeof n&&recursiveFreeze(n)}Object.freeze(e)}let urlMetric;const reservedRootPropertyKeys=new Set(["url","viewport","elements"]);function getRootData(){const e=structuredClone(urlMetric);return recursiveFreeze(e),e}function extendRootData(e){for(const t of Object.getOwnPropertyNames(e))if(reservedRootPropertyKeys.has(t))throw new Error(`Disallowed setting of key '${t}' on root.`);Object.assign(urlMetric,e)}const elementsByXPath=new Map,reservedElementPropertyKeys=new Set(["isLCP","isLCPCandidate","xpath","intersectionRatio","intersectionRect","boundingClientRect"]);function getElementData(e){const t=elementsByXPath.get(e);if(t){const e=structuredClone(t);return recursiveFreeze(e),e}return null}function extendElementData(e,t){if(!elementsByXPath.has(e))throw new Error(`Unknown element with XPath: ${e}`);for(const e of Object.getOwnPropertyNames(t))if(reservedElementPropertyKeys.has(e))throw new Error(`Disallowed setting of key '${e}' on element.`);const n=elementsByXPath.get(e);Object.assign(n,t)}export default async function detect({minViewportAspectRatio:e,maxViewportAspectRatio:t,isDebug:n,extensionModuleUrls:o,restApiEndpoint:r,currentUrl:i,urlMetricSlug:s,cachePurgePostId:a,urlMetricHMAC:c,urlMetricGroupStatuses:l,storageLockTTL:d,webVitalsLibrarySrc:u,urlMetricGroupCollection:g}){if(n&&log("Stored URL Metric group collection:",g),!isViewportNeeded(win.innerWidth,l))return void(n&&log("No need for URL Metrics from the current viewport."));const f=win.innerWidth/win.innerHeight;if(f<e||f>t)return void(n&&warn(`Viewport aspect ratio (${f}) is not in the accepted range of ${e} to ${t}.`));if(await new Promise((e=>{"loading"!==doc.readyState?e():doc.addEventListener("DOMContentLoaded",e,{once:!0})})),await new Promise((e=>{"complete"===doc.readyState?e():win.addEventListener("load",e,{once:!0})})),"function"==typeof requestIdleCallback&&await new Promise((e=>{requestIdleCallback(e)})),isStorageLocked(getCurrentTime(),d))return void(n&&warn("Aborted detection due to storage being locked."));if(doc.documentElement.scrollTop>0)return void(n&&warn("Aborted detection since initial scroll position of page is not at the top."));n&&log("Proceeding with detection");const w=new Map;for(const e of o)try{const t=await import(e);w.set(e,t),t.initialize instanceof Function&&t.initialize({isDebug:n})}catch(t){error(`Failed to initialize extension '${e}':`,t)}const p=doc.body.querySelectorAll("[data-od-xpath]"),m=new Map([...p].map((e=>[e,e.dataset.odXpath]))),h=[];let y;function P(){y instanceof IntersectionObserver&&(y.disconnect(),win.removeEventListener("scroll",P))}m.size>0&&(await new Promise((e=>{y=new IntersectionObserver((t=>{for(const e of t)h.push(e);e()}),{root:null,threshold:0});for(const e of m.keys())y.observe(e)})),win.addEventListener("scroll",P,{once:!0,passive:!0}));const{onLCP:L}=await import(u),b=[];await new Promise((e=>{L((t=>{b.push(t),e()}),{reportAllChanges:!0})})),P(),n&&log("Detection is stopping."),urlMetric={url:i,viewport:{width:win.innerWidth,height:win.innerHeight},elements:[]};const v=b.at(-1);for(const e of h){const t=m.get(e.target);if(!t){n&&error("Unable to look up XPath for element");continue}const o=v?.entries[0]?.element,r={isLCP:e.target===o,isLCPCandidate:!!b.find((t=>{const n=t.entries[0]?.element;return n===e.target})),xpath:t,intersectionRatio:e.intersectionRatio,intersectionRect:e.intersectionRect,boundingClientRect:e.boundingClientRect};urlMetric.elements.push(r),elementsByXPath.set(r.xpath,r)}if(n&&log("Current URL Metric:",urlMetric),await new Promise((e=>{win.addEventListener("pagehide",e,{once:!0}),win.addEventListener("pageswap",e,{once:!0}),doc.addEventListener("visibilitychange",(()=>{"hidden"===document.visibilityState&&e()}),{once:!0})})),w.size>0)for(const[e,t]of w.entries())if(t.finalize instanceof Function)try{await t.finalize({isDebug:n,getRootData,getElementData,extendElementData,extendRootData})}catch(t){error(`Unable to finalize module '${e}':`,t)}setStorageLock(getCurrentTime()),n&&log("Sending URL Metric:",urlMetric);const R=new URL(r);R.searchParams.set("slug",s),"number"==typeof a&&R.searchParams.set("cache_purge_post_id",a.toString()),R.searchParams.set("hmac",c),navigator.sendBeacon(R,new Blob([JSON.stringify(urlMetric)],{type:"application/json"})),m.clear()}
\ No newline at end of file
+const win=window,doc=win.document,consoleLogPrefix="[Optimization Detective]",storageLockTimeSessionKey="odStorageLockTime";function isStorageLocked(e,t){if(0===t)return!1;try{const o=parseInt(sessionStorage.getItem("odStorageLockTime"));return!isNaN(o)&&e<o+1e3*t}catch(e){return!1}}function setStorageLock(e){try{sessionStorage.setItem("odStorageLockTime",String(e))}catch(e){}}function log(...e){console.log(consoleLogPrefix,...e)}function warn(...e){console.warn(consoleLogPrefix,...e)}function error(...e){console.error(consoleLogPrefix,...e)}function isViewportNeeded(e,t){let o=!1;for(const{minimumViewportWidth:n,complete:r}of t){if(!(e>=n))break;o=!r}return o}function getCurrentTime(){return Date.now()}function recursiveFreeze(e){for(const t of Object.getOwnPropertyNames(e)){const o=e[t];null!==o&&"object"==typeof o&&recursiveFreeze(o)}Object.freeze(e)}let urlMetric;const reservedRootPropertyKeys=new Set(["url","viewport","elements"]);function getRootData(){const e=structuredClone(urlMetric);return recursiveFreeze(e),e}function extendRootData(e){for(const t of Object.getOwnPropertyNames(e))if(reservedRootPropertyKeys.has(t))throw new Error(`Disallowed setting of key '${t}' on root.`);Object.assign(urlMetric,e)}const elementsByXPath=new Map,reservedElementPropertyKeys=new Set(["isLCP","isLCPCandidate","xpath","intersectionRatio","intersectionRect","boundingClientRect"]);function getElementData(e){const t=elementsByXPath.get(e);if(t){const e=structuredClone(t);return recursiveFreeze(e),e}return null}function extendElementData(e,t){if(!elementsByXPath.has(e))throw new Error(`Unknown element with XPath: ${e}`);for(const e of Object.getOwnPropertyNames(t))if(reservedElementPropertyKeys.has(e))throw new Error(`Disallowed setting of key '${e}' on element.`);const o=elementsByXPath.get(e);Object.assign(o,t)}export default async function detect({minViewportAspectRatio:e,maxViewportAspectRatio:t,isDebug:o,extensionModuleUrls:n,restApiEndpoint:r,currentETag:i,currentUrl:s,urlMetricSlug:a,cachePurgePostId:c,urlMetricHMAC:l,urlMetricGroupStatuses:d,storageLockTTL:u,webVitalsLibrarySrc:g,urlMetricGroupCollection:f}){if(o){const e=[];for(const t of f.groups)for(const o of t.url_metrics)o.creationDate=new Date(1e3*o.timestamp),e.push(o);log("Stored URL Metric Group Collection:",f),e.sort(((e,t)=>t.timestamp-e.timestamp)),log("Stored URL Metrics in reverse chronological order:",e)}if(!isViewportNeeded(win.innerWidth,d))return void(o&&log("No need for URL Metrics from the current viewport."));const p=win.innerWidth/win.innerHeight;if(p<e||p>t)return void(o&&warn(`Viewport aspect ratio (${p}) is not in the accepted range of ${e} to ${t}.`));if(await new Promise((e=>{"loading"!==doc.readyState?e():doc.addEventListener("DOMContentLoaded",e,{once:!0})})),await new Promise((e=>{"complete"===doc.readyState?e():win.addEventListener("load",e,{once:!0})})),"function"==typeof requestIdleCallback&&await new Promise((e=>{requestIdleCallback(e)})),isStorageLocked(getCurrentTime(),u))return void(o&&warn("Aborted detection due to storage being locked."));let w=!1;window.addEventListener("resize",(()=>{w=!0}),{once:!0});const{onTTFB:m,onFCP:h,onLCP:P,onINP:L,onCLS:y}=await import(g);if(doc.documentElement.scrollTop>0)return void(o&&warn("Aborted detection since initial scroll position of page is not at the top."));o&&log("Proceeding with detection");const v=new Map,b=[],S=[];for(const e of n)try{const t=await import(e);if(v.set(e,t),t.initialize instanceof Function){const n=t.initialize({isDebug:o,onTTFB:m,onFCP:h,onLCP:P,onINP:L,onCLS:y});n instanceof Promise&&(b.push(n),S.push(e))}}catch(t){error(`Failed to start initializing extension '${e}':`,t)}const C=await Promise.allSettled(b);for(const[e,t]of C.entries())"rejected"===t.status&&error(`Failed to initialize extension '${S[e]}':`,t.reason);const R=doc.body.querySelectorAll("[data-od-xpath]"),M=new Map([...R].map((e=>[e,e.dataset.odXpath]))),D=[];let E;function x(){E instanceof IntersectionObserver&&(E.disconnect(),win.removeEventListener("scroll",x))}M.size>0&&(await new Promise((e=>{E=new IntersectionObserver((t=>{for(const e of t)D.push(e);e()}),{root:null,threshold:0});for(const e of M.keys())E.observe(e)})),win.addEventListener("scroll",x,{once:!0,passive:!0}));const k=[];await new Promise((e=>{P((t=>{k.push(t),e()}),{reportAllChanges:!0})})),x(),o&&log("Detection is stopping."),urlMetric={url:s,viewport:{width:win.innerWidth,height:win.innerHeight},elements:[]};const z=k.at(-1);for(const e of D){const t=M.get(e.target);if(!t){o&&error("Unable to look up XPath for element");continue}const n=z?.entries[0]?.element,r={isLCP:e.target===n,isLCPCandidate:!!k.find((t=>{const o=t.entries[0]?.element;return o===e.target})),xpath:t,intersectionRatio:e.intersectionRatio,intersectionRect:e.intersectionRect,boundingClientRect:e.boundingClientRect};urlMetric.elements.push(r),elementsByXPath.set(r.xpath,r)}if(o&&log("Current URL Metric:",urlMetric),await new Promise((e=>{win.addEventListener("pagehide",e,{once:!0}),win.addEventListener("pageswap",e,{once:!0}),doc.addEventListener("visibilitychange",(()=>{"hidden"===document.visibilityState&&e()}),{once:!0})})),w)return void(o&&log("Aborting URL Metric collection due to viewport size change."));if(v.size>0){const e=[],t=[];for(const[n,r]of v.entries())if(r.finalize instanceof Function)try{const i=r.finalize({isDebug:o,getRootData,getElementData,extendElementData,extendRootData});i instanceof Promise&&(e.push(i),t.push(n))}catch(e){error(`Unable to start finalizing extension '${n}':`,e)}const n=await Promise.allSettled(e);for(const[e,o]of n.entries())"rejected"===o.status&&error(`Failed to finalize extension '${t[e]}':`,o.reason)}setStorageLock(getCurrentTime()),o&&log("Sending URL Metric:",urlMetric);const T=new URL(r);T.searchParams.set("slug",a),T.searchParams.set("current_etag",i),"number"==typeof c&&T.searchParams.set("cache_purge_post_id",c.toString()),T.searchParams.set("hmac",l),navigator.sendBeacon(T,new Blob([JSON.stringify(urlMetric)],{type:"application/json"})),M.clear()}
\ No newline at end of file
Index: detection.php
===================================================================
--- detection.php	(revision 3209553)
+++ detection.php	(working copy)
@@ -34,6 +34,8 @@
  * @since 0.8.0
  * @access private
  *
+ * @global WP_Query $wp_query WordPress Query object.
+ *
  * @return int|null Post ID or null if none found.
  */
 function od_get_cache_purge_post_id(): ?int {
@@ -83,6 +85,9 @@
 	$cache_purge_post_id = od_get_cache_purge_post_id();
 
 	$current_url = od_get_current_url();
+
+	$current_etag = $group_collection->get_current_etag();
+
 	$detect_args = array(
 		'minViewportAspectRatio' => od_get_minimum_viewport_aspect_ratio(),
 		'maxViewportAspectRatio' => od_get_maximum_viewport_aspect_ratio(),
@@ -89,10 +94,11 @@
 		'isDebug'                => WP_DEBUG,
 		'extensionModuleUrls'    => $extension_module_urls,
 		'restApiEndpoint'        => rest_url( OD_REST_API_NAMESPACE . OD_URL_METRICS_ROUTE ),
+		'currentETag'            => $current_etag,
 		'currentUrl'             => $current_url,
 		'urlMetricSlug'          => $slug,
 		'cachePurgePostId'       => od_get_cache_purge_post_id(),
-		'urlMetricHMAC'          => od_get_url_metrics_storage_hmac( $slug, $current_url, $cache_purge_post_id ),
+		'urlMetricHMAC'          => od_get_url_metrics_storage_hmac( $slug, $current_etag, $current_url, $cache_purge_post_id ),
 		'urlMetricGroupStatuses' => array_map(
 			static function ( OD_URL_Metric_Group $group ): array {
 				return array(
@@ -112,7 +118,7 @@
 	return wp_get_inline_script_tag(
 		sprintf(
 			'import detect from %s; detect( %s );',
-			wp_json_encode( add_query_arg( 'ver', OPTIMIZATION_DETECTIVE_VERSION, plugin_dir_url( __FILE__ ) . sprintf( 'detect%s.js', wp_scripts_get_suffix() ) ) ),
+			wp_json_encode( add_query_arg( 'ver', OPTIMIZATION_DETECTIVE_VERSION, plugin_dir_url( __FILE__ ) . od_get_asset_path( 'detect.js' ) ) ),
 			wp_json_encode( $detect_args )
 		),
 		array( 'type' => 'module' )
Index: helper.php
===================================================================
--- helper.php	(revision 3209553)
+++ helper.php	(working copy)
@@ -37,7 +37,7 @@
  */
 function od_generate_media_query( ?int $minimum_viewport_width, ?int $maximum_viewport_width ): ?string {
 	if ( is_int( $minimum_viewport_width ) && is_int( $maximum_viewport_width ) && $minimum_viewport_width > $maximum_viewport_width ) {
-		_doing_it_wrong( __FUNCTION__, esc_html__( 'The minimum width must be greater than the maximum width.', 'optimization-detective' ), 'Optimization Detective 0.7.0' );
+		_doing_it_wrong( __FUNCTION__, esc_html__( 'The minimum width cannot be greater than the maximum width.', 'optimization-detective' ), 'Optimization Detective 0.7.0' );
 		return null;
 	}
 	$media_attributes = array();
@@ -64,3 +64,46 @@
 	// Use the plugin slug as it is immutable.
 	echo '<meta name="generator" content="optimization-detective ' . esc_attr( OPTIMIZATION_DETECTIVE_VERSION ) . '">' . "\n";
 }
+
+/**
+ * Gets the path to a script or stylesheet.
+ *
+ * @since 0.9.0
+ *
+ * @param string      $src_path Source path, relative to plugin root.
+ * @param string|null $min_path Minified path. If not supplied, then '.min' is injected before the file extension in the source path.
+ * @return string URL to script or stylesheet.
+ *
+ * @noinspection PhpDocMissingThrowsInspection
+ */
+function od_get_asset_path( string $src_path, ?string $min_path = null ): string {
+	if ( null === $min_path ) {
+		// Note: wp_scripts_get_suffix() is not used here because we need access to both the source and minified paths.
+		$min_path = (string) preg_replace( '/(?=\.\w+$)/', '.min', $src_path );
+	}
+
+	$force_src = false;
+	if ( WP_DEBUG && ! file_exists( trailingslashit( __DIR__ ) . $min_path ) ) {
+		$force_src = true;
+		/**
+		 * No WP_Exception is thrown by wp_trigger_error() since E_USER_ERROR is not passed as the error level.
+		 *
+		 * @noinspection PhpUnhandledExceptionInspection
+		 */
+		wp_trigger_error(
+			__FUNCTION__,
+			sprintf(
+				/* translators: %s is the minified asset path */
+				__( 'Minified asset has not been built: %s', 'optimization-detective' ),
+				$min_path
+			),
+			E_USER_WARNING
+		);
+	}
+
+	if ( SCRIPT_DEBUG || $force_src ) {
+		return $src_path;
+	}
+
+	return $min_path;
+}
Index: load.php
===================================================================
--- load.php	(revision 3209553)
+++ load.php	(working copy)
@@ -3,9 +3,9 @@
  * Plugin Name: Optimization Detective
  * Plugin URI: https://github.com/WordPress/performance/tree/trunk/plugins/optimization-detective
  * Description: Provides an API for leveraging real user metrics to detect optimizations to apply on the frontend to improve page performance.
- * Requires at least: 6.5
+ * Requires at least: 6.6
  * Requires PHP: 7.2
- * Version: 0.8.0
+ * Version: 0.9.0
  * Author: WordPress Performance Team
  * Author URI: https://make.wordpress.org/performance/
  * License: GPLv2 or later
@@ -70,7 +70,7 @@
 	}
 )(
 	'optimization_detective_pending_plugin',
-	'0.8.0',
+	'0.9.0',
 	static function ( string $version ): void {
 		if ( defined( 'OPTIMIZATION_DETECTIVE_VERSION' ) ) {
 			return;
Index: optimization.php
===================================================================
--- optimization.php	(revision 3209553)
+++ optimization.php	(working copy)
@@ -98,6 +98,8 @@
  * Determines whether the current response can be optimized.
  *
  * @since 0.1.0
+ * @since 0.9.0 Response is optimized for admin users as well when in 'plugin' development mode.
+ *
  * @access private
  *
  * @return bool Whether response can be optimized.
@@ -116,11 +118,12 @@
 		is_customize_preview() ||
 		// Since the images detected in the response body of a POST request cannot, by definition, be cached.
 		( isset( $_SERVER['REQUEST_METHOD'] ) && 'GET' !== $_SERVER['REQUEST_METHOD'] ) ||
-		// The aim is to optimize pages for the majority of site visitors, not those who administer the site. For admin
-		// users, additional elements will be present like the script from wp_customize_support_script() which will
-		// interfere with the XPath indices. Note that od_get_normalized_query_vars() is varied by is_user_logged_in()
-		// so membership sites and e-commerce sites will still be able to be optimized for their normal visitors.
-		current_user_can( 'customize' ) ||
+		// The aim is to optimize pages for the majority of site visitors, not for those who administer the site, unless
+		// in 'plugin' development mode. For admin users, additional elements will be present, like the script from
+		// wp_customize_support_script(), which will interfere with the XPath indices. Note that
+		// od_get_normalized_query_vars() is varied by is_user_logged_in(), so membership sites and e-commerce sites
+		// will still be able to be optimized for their normal visitors.
+		( current_user_can( 'customize' ) && ! wp_is_development_mode( 'plugin' ) ) ||
 		// Page caching plugins can only reliably be told to invalidate a cached page when a post is available to trigger
 		// the relevant actions on.
 		null === od_get_cache_purge_post_id()
@@ -167,10 +170,14 @@
  * @since 0.1.0
  * @access private
  *
+ * @global WP_Query $wp_the_query WP_Query object.
+ *
  * @param string $buffer Template output buffer.
  * @return string Filtered template output buffer.
  */
 function od_optimize_template_output_buffer( string $buffer ): string {
+	global $wp_the_query;
+
 	// If the content-type is not HTML or the output does not start with '<', then abort since the buffer is definitely not HTML.
 	if (
 		! od_is_response_html_content_type() ||
@@ -192,16 +199,6 @@
 	$slug = od_get_url_metrics_slug( od_get_normalized_query_vars() );
 	$post = OD_URL_Metrics_Post_Type::get_post( $slug );
 
-	$group_collection = new OD_URL_Metric_Group_Collection(
-		$post instanceof WP_Post ? OD_URL_Metrics_Post_Type::get_url_metrics_from_post( $post ) : array(),
-		od_get_breakpoint_max_widths(),
-		od_get_url_metrics_breakpoint_sample_size(),
-		od_get_url_metric_freshness_ttl()
-	);
-
-	// Whether we need to add the data-od-xpath attribute to elements and whether the detection script should be injected.
-	$needs_detection = ! $group_collection->is_every_group_complete();
-
 	$tag_visitor_registry = new OD_Tag_Visitor_Registry();
 
 	/**
@@ -213,10 +210,22 @@
 	 */
 	do_action( 'od_register_tag_visitors', $tag_visitor_registry );
 
+	$current_etag         = od_get_current_url_metrics_etag( $tag_visitor_registry, $wp_the_query, od_get_current_theme_template() );
+	$group_collection     = new OD_URL_Metric_Group_Collection(
+		$post instanceof WP_Post ? OD_URL_Metrics_Post_Type::get_url_metrics_from_post( $post ) : array(),
+		$current_etag,
+		od_get_breakpoint_max_widths(),
+		od_get_url_metrics_breakpoint_sample_size(),
+		od_get_url_metric_freshness_ttl()
+	);
 	$link_collection      = new OD_Link_Collection();
 	$tag_visitor_context  = new OD_Tag_Visitor_Context( $processor, $group_collection, $link_collection );
 	$current_tag_bookmark = 'optimization_detective_current_tag';
 	$visitors             = iterator_to_array( $tag_visitor_registry );
+
+	// Whether we need to add the data-od-xpath attribute to elements and whether the detection script should be injected.
+	$needs_detection = ! $group_collection->is_every_group_complete();
+
 	do {
 		$tracked_in_url_metrics = false;
 		$processor->set_bookmark( $current_tag_bookmark ); // TODO: Should we break if this returns false?
Index: readme.txt
===================================================================
--- readme.txt	(revision 3209553)
+++ readme.txt	(working copy)
@@ -2,7 +2,7 @@
 
 Contributors: wordpressdotorg
 Tested up to: 6.7
-Stable tag:   0.8.0
+Stable tag:   0.9.0
 License:      GPLv2 or later
 License URI:  https://www.gnu.org/licenses/gpl-2.0.html
 Tags:         performance, optimization, rum
@@ -17,7 +17,7 @@
 
 = Background =
 
-WordPress uses [server-side heuristics](https://make.wordpress.org/core/2023/07/13/image-performance-enhancements-in-wordpress-6-3/) to make educated guesses about which images are likely to be in the initial viewport. Likewise, it uses server-side heuristics to identify a hero image which is likely to be the Largest Contentful Paint (LCP) element. To optimize page loading, it avoids lazy-loading any of these images while also adding `fetchpriority=high` to the hero image. When these heuristics are applied successfully, the LCP metric for page loading can be improved 5-10%. Unfortunately, however, there are limitations to the heuristics that make the correct identification of which image is the LCP element only about 50% effective. See [Analyzing the Core Web Vitals performance impact of WordPress 6.3 in the field](https://make.wordpress.org/core/2023/09/19/analyzing-the-core-web-vitals-performance-impact-of-wordpress-6-3-in-the-field/). For example, it is [common](https://github.com/GoogleChromeLabs/wpp-research/pull/73) for the LCP element to vary between different viewport widths, such as desktop versus mobile. Since WordPress's heuristics are completely server-side it has no knowledge of how the page is actually laid out, and it cannot prioritize loading of images according to the client's viewport width.
+WordPress uses [server-side heuristics](https://make.wordpress.org/core/2023/07/13/image-performance-enhancements-in-wordpress-6-3/) to make educated guesses about which images are likely to be in the initial viewport. Likewise, it uses server-side heuristics to identify a hero image which is likely to be the Largest Contentful Paint (LCP) element. To optimize page loading, it avoids lazy loading any of these images while also adding `fetchpriority=high` to the hero image. When these heuristics are applied successfully, the LCP metric for page loading can be improved 5-10%. Unfortunately, however, there are limitations to the heuristics that make the correct identification of which image is the LCP element only about 50% effective. See [Analyzing the Core Web Vitals performance impact of WordPress 6.3 in the field](https://make.wordpress.org/core/2023/09/19/analyzing-the-core-web-vitals-performance-impact-of-wordpress-6-3-in-the-field/). For example, it is [common](https://github.com/GoogleChromeLabs/wpp-research/pull/73) for the LCP element to vary between different viewport widths, such as desktop versus mobile. Since WordPress's heuristics are completely server-side it has no knowledge of how the page is actually laid out, and it cannot prioritize loading of images according to the client's viewport width.
 
 In order to increase the accuracy of identifying the LCP element, including across various client viewport widths, this plugin gathers metrics from real users (RUM) to detect the actual LCP element and then use this information to optimize the page for future visitors so that the loading of the LCP element is properly prioritized. This is the purpose of Optimization Detective. The approach is heavily inspired by Philip Walton’s [Dynamic LCP Priority: Learning from Past Visits](https://philipwalton.com/articles/dynamic-lcp-priority/). See also the initial exploration document that laid out this project: [Image Loading Optimization via Client-side Detection](https://docs.google.com/document/u/1/d/16qAJ7I_ljhEdx2Cn2VlK7IkiixobY9zNn8FXxN9T9Ls/view).
 
@@ -40,6 +40,33 @@
 
 When the `WP_DEBUG` constant is enabled, additional logging for Optimization Detective is added to the browser console.
 
+= Use Cases and Examples =
+
+As mentioned above, this plugin is a dependency that doesn't provide features on its own. Dependent plugins leverage the collected URL Metrics to apply optimizations. What follows us a running list of the optimizations which are enabled by Optimization Detective, along with a links to the related code used for the implementation:
+
+**[Image Prioritizer](https://wordpress.org/plugins/image-prioritizer/) ([GitHub](https://github.com/WordPress/performance/tree/trunk/plugins/image-prioritizer)):**
+
+1. Add breakpoint-specific `fetchpriority=high` preload links (`LINK[rel=preload]`) for image URLs of LCP elements:
+   1. An `IMG` element, including the `srcset`/`sizes` attributes supplied as `imagesrcset`/`imagesizes` on the `LINK`. ([1](https://github.com/WordPress/performance/blob/f5f50f9179c26deadeef966734367d199ba6de6f/plugins/image-prioritizer/class-image-prioritizer-img-tag-visitor.php#L167-L177), [2](https://github.com/WordPress/performance/blob/f5f50f9179c26deadeef966734367d199ba6de6f/plugins/image-prioritizer/class-image-prioritizer-img-tag-visitor.php#L304-L349))
+   2. The first `SOURCE` element with a `type` attribute in a `PICTURE` element. (Art-directed `PICTURE` elements using media queries are not supported.) ([1](https://github.com/WordPress/performance/blob/f5f50f9179c26deadeef966734367d199ba6de6f/plugins/image-prioritizer/class-image-prioritizer-img-tag-visitor.php#L192-L275), [2](https://github.com/WordPress/performance/blob/f5f50f9179c26deadeef966734367d199ba6de6f/plugins/image-prioritizer/class-image-prioritizer-img-tag-visitor.php#L304-L349))
+   3. An element with a CSS `background-image` inline `style` attribute. ([1](https://github.com/WordPress/performance/blob/f5f50f9179c26deadeef966734367d199ba6de6f/plugins/image-prioritizer/class-image-prioritizer-background-image-styled-tag-visitor.php#L62-L92), [2](https://github.com/WordPress/performance/blob/f5f50f9179c26deadeef966734367d199ba6de6f/plugins/image-prioritizer/class-image-prioritizer-background-image-styled-tag-visitor.php#L182-L203))
+   4. An element with a CSS `background-image` applied with a stylesheet (when the image is from an allowed origin). ([1](https://github.com/WordPress/performance/blob/f5f50f9179c26deadeef966734367d199ba6de6f/plugins/image-prioritizer/hooks.php#L14-L16), [2](https://github.com/WordPress/performance/blob/f5f50f9179c26deadeef966734367d199ba6de6f/plugins/image-prioritizer/class-image-prioritizer-background-image-styled-tag-visitor.php#L82-L83), [3](https://github.com/WordPress/performance/blob/f5f50f9179c26deadeef966734367d199ba6de6f/plugins/image-prioritizer/class-image-prioritizer-background-image-styled-tag-visitor.php#L135-L203), [4](https://github.com/WordPress/performance/blob/f5f50f9179c26deadeef966734367d199ba6de6f/plugins/image-prioritizer/helper.php#L83-L320), [5](https://github.com/WordPress/performance/blob/f5f50f9179c26deadeef966734367d199ba6de6f/plugins/image-prioritizer/detect.js))
+   5. A `VIDEO` element's `poster` image. ([1](https://github.com/WordPress/performance/blob/f5f50f9179c26deadeef966734367d199ba6de6f/plugins/image-prioritizer/class-image-prioritizer-video-tag-visitor.php#L127-L161))
+2. Ensure `fetchpriority=high` is only added to an `IMG` when it is the LCP element across all responsive breakpoints. ([1](https://github.com/WordPress/performance/blob/f5f50f9179c26deadeef966734367d199ba6de6f/plugins/image-prioritizer/class-image-prioritizer-img-tag-visitor.php#L65-L91), [2](https://github.com/WordPress/performance/blob/f5f50f9179c26deadeef966734367d199ba6de6f/plugins/image-prioritizer/class-image-prioritizer-img-tag-visitor.php#L137-L146))
+3. Add `fetchpriority=low` to `IMG` tags which appear in the initial viewport but are not visible, such as when they are subsequent carousel slides. ([1](https://github.com/WordPress/performance/blob/f5f50f9179c26deadeef966734367d199ba6de6f/plugins/image-prioritizer/class-image-prioritizer-img-tag-visitor.php#L105-L123), [2](https://github.com/WordPress/performance/blob/f5f50f9179c26deadeef966734367d199ba6de6f/plugins/image-prioritizer/class-image-prioritizer-img-tag-visitor.php#L137-L146))
+4. Lazy loading:
+   1. Apply lazy loading to `IMG` tags based on whether they appear in any breakpoint’s initial viewport. ([1](https://github.com/WordPress/performance/blob/f5f50f9179c26deadeef966734367d199ba6de6f/plugins/image-prioritizer/class-image-prioritizer-img-tag-visitor.php#L124-L133))
+   2. Implement lazy loading of CSS background images added via inline `style` attributes. ([1](https://github.com/WordPress/performance/blob/f5f50f9179c26deadeef966734367d199ba6de6f/plugins/image-prioritizer/class-image-prioritizer-background-image-styled-tag-visitor.php#L205-L238), [2](https://github.com/WordPress/performance/blob/f5f50f9179c26deadeef966734367d199ba6de6f/plugins/image-prioritizer/helper.php#L365-L380), [3](https://github.com/WordPress/performance/blob/f5f50f9179c26deadeef966734367d199ba6de6f/plugins/image-prioritizer/lazy-load-bg-image.js))
+   3. Lazy-load `VIDEO` tags by setting the appropriate attributes based on whether they appear in the initial viewport. If a `VIDEO` is the LCP element, it gets `preload=auto`; if it is in an initial viewport, the `preload=metadata` default is left; if it is not in an initial viewport, it gets `preload=none`. Lazy-loaded videos also get initial `preload`, `autoplay`, and `poster` attributes restored when the `VIDEO` is going to enter the viewport. ([1](https://github.com/WordPress/performance/blob/f5f50f9179c26deadeef966734367d199ba6de6f/plugins/image-prioritizer/class-image-prioritizer-video-tag-visitor.php#L163-L246), [2](https://github.com/WordPress/performance/blob/f5f50f9179c26deadeef966734367d199ba6de6f/plugins/image-prioritizer/helper.php#L365-L380), [3](https://github.com/WordPress/performance/blob/f5f50f9179c26deadeef966734367d199ba6de6f/plugins/image-prioritizer/lazy-load-video.js))
+5. Ensure that [`sizes=auto`](https://make.wordpress.org/core/2024/10/18/auto-sizes-for-lazy-loaded-images-in-wordpress-6-7/) is added to all lazy-loaded `IMG` elements. ([1](https://github.com/WordPress/performance/blob/f5f50f9179c26deadeef966734367d199ba6de6f/plugins/image-prioritizer/class-image-prioritizer-img-tag-visitor.php#L148-L163))
+6. Reduce the size of the `poster` image of a `VIDEO` from full size to the size appropriate for the maximum width of the video (on desktop). ([1](https://github.com/WordPress/performance/blob/f5f50f9179c26deadeef966734367d199ba6de6f/plugins/image-prioritizer/class-image-prioritizer-video-tag-visitor.php#L84-L125))
+
+**[Embed Optimizer](https://wordpress.org/plugins/embed-optimizer/) ([GitHub](https://github.com/WordPress/performance/tree/trunk/plugins/embed-optimizer)):**
+
+1. Lazy loading embeds just before they come into view. ([1](https://github.com/WordPress/performance/blob/f5f50f9179c26deadeef966734367d199ba6de6f/plugins/embed-optimizer/class-embed-optimizer-tag-visitor.php#L191-L194), [2](https://github.com/WordPress/performance/blob/f5f50f9179c26deadeef966734367d199ba6de6f/plugins/embed-optimizer/hooks.php#L168-L336))
+2. Adding preconnect links for embeds in the initial viewport. ([1](https://github.com/WordPress/performance/blob/f5f50f9179c26deadeef966734367d199ba6de6f/plugins/embed-optimizer/class-embed-optimizer-tag-visitor.php#L114-L190))
+3. Reserving space for embeds that resize to reduce layout shifting. ([1](https://github.com/WordPress/performance/blob/f5f50f9179c26deadeef966734367d199ba6de6f/plugins/embed-optimizer/hooks.php#L64-L65), [2](https://github.com/WordPress/performance/blob/f5f50f9179c26deadeef966734367d199ba6de6f/plugins/embed-optimizer/hooks.php#L81-L144), [3](https://github.com/WordPress/performance/blob/f5f50f9179c26deadeef966734367d199ba6de6f/plugins/embed-optimizer/detect.js), [4](https://github.com/WordPress/performance/blob/f5f50f9179c26deadeef966734367d199ba6de6f/plugins/embed-optimizer/class-embed-optimizer-tag-visitor.php#L218-L285))
+
 = Hooks =
 
 **Action:** `od_init` (argument: plugin version)
@@ -92,9 +119,10 @@
 2. It’s not a post embed template (`is_embed()`).
 3. It’s not the Customizer preview (`is_customize_preview()`)
 4. It’s not the response to a `POST` request.
-5. The user is not an administrator (`current_user_can( 'customize' )`).
+5. The user is not an administrator (`current_user_can( 'customize' )`), unless you're in plugin development mode (`wp_is_development_mode( 'plugin' )`).
+6. There is at least one queried post on the page. This is used to facilitate the purging of page caches after a new URL Metric is stored.
 
-During development, you may want to force this to always be enabled:
+To force every response to be optimized regardless of the conditions above, you can do:
 
 `
 <?php
@@ -103,7 +131,7 @@
 
 **Filter:** `od_url_metrics_breakpoint_sample_size` (default: 3)
 
-Filters the sample size for a breakpoint's URL Metrics on a given URL. The sample size must be greater than zero. During development, it may be helpful to reduce the sample size to 1:
+Filters the sample size for a breakpoint's URL Metrics on a given URL. The sample size must be greater than zero. You can increase the sample size if you want better guarantees that the applied optimizations will be accurate. During development, it may be helpful to reduce the sample size to 1:
 
 `
 <?php
@@ -125,19 +153,37 @@
 
 **Filter:** `od_url_metric_freshness_ttl` (default: 1 day in seconds)
 
-Filters the freshness age (TTL) for a given URL Metric. The freshness TTL must be at least zero, in which it considers URL Metrics to always be stale. In practice, the value should be at least an hour. During development, this can be useful to set to zero:
+Filters the freshness age (TTL) for a given URL Metric. The freshness TTL must be at least zero, in which it considers URL Metrics to always be stale. In practice, the value should be at least an hour. If your site content does not change frequently, you may want to increase the TTL to a week:
 
 `
 <?php
-add_filter( 'od_url_metric_freshness_ttl', '__return_zero' );
+add_filter( 'od_url_metric_freshness_ttl', static function (): int {
+    return WEEK_IN_SECONDS;
+} );
 `
 
+During development, this can be useful to set to zero so that you don't have to wait for new URL Metrics to be requested when engineering a new optimization:
+
+`
+<?php
+add_filter( 'od_url_metric_freshness_ttl', static function (): int {
+    return 0;
+} );
+`
+
 **Filter:** `od_minimum_viewport_aspect_ratio` (default: 0.4)
 
 Filters the minimum allowed viewport aspect ratio for URL Metrics.
 
-The 0.4 value is intended to accommodate the phone with the greatest known aspect ratio at 21:9 when rotated 90 degrees to 9:21 (0.429).
+The 0.4 value is intended to accommodate the phone with the greatest known aspect ratio at 21:9 when rotated 90 degrees to 9:21 (0.429). During development when you have the DevTools console open on the right, the viewport aspect ratio will be smaller than normal. In this case, you may want to set this to 0:
 
+`
+<?php
+add_filter( 'od_minimum_viewport_aspect_ratio', static function (): int {
+    return 0;
+} );
+`
+
 **Filter:** `od_maximum_viewport_aspect_ratio` (default: 2.5)
 
 Filters the maximum allowed viewport aspect ratio for URL Metrics.
@@ -144,11 +190,11 @@
 
 The 2.5 value is intended to accommodate the phone with the greatest known aspect ratio at 21:9 (2.333).
 
-During development when you have the DevTools console open, for example, the viewport aspect ratio will be wider than normal. In this case, you may want to increase the maximum aspect ratio:
+During development when you have the DevTools console open on the bottom, for example, the viewport aspect ratio will be larger than normal. In this case, you may want to increase the maximum aspect ratio:
 
 `
 <?php
-add_filter( 'od_maximum_viewport_aspect_ratio', function () {
+add_filter( 'od_maximum_viewport_aspect_ratio', static function (): int {
 	return 5;
 } );
 `
@@ -219,6 +265,14 @@
 
 See also [example usage](https://github.com/WordPress/performance/blob/6bb8405c5c446e3b66c2bfa3ae03ba61b188bca2/plugins/embed-optimizer/hooks.php#L128-L144) in Embed Optimizer. Note in particular the structure of the plugin’s [detect.js](https://github.com/WordPress/performance/blob/trunk/plugins/embed-optimizer/detect.js) script module, how it exports `initialize` and `finalize` functions which Optimization Detective then calls when the page loads and when the page unloads, at which time the URL Metric is constructed and sent to the server for storage. Refer also to the [TypeScript type definitions](https://github.com/WordPress/performance/blob/trunk/plugins/optimization-detective/types.ts).
 
+**Filter:** `od_current_url_metrics_etag_data` (default: array with `tag_visitors` key)
+
+Filters the data that goes into computing the current ETag for URL Metrics.
+
+The ETag is a unique identifier that changes whenever the underlying data used to generate it changes. By default, the ETag calculation includes the names of registered tag visitors. This ensures that when a new Optimization Detective-dependent plugin is activated (like Image Prioritizer or Embed Optimizer), any existing URL Metrics are immediately considered stale. This happens because the newly registered tag visitors alter the ETag calculation, making it different from the stored ones.
+
+When the ETag for URL Metrics in a complete viewport group no longer matches the current environment's ETag, new URL Metrics will then begin to be collected until there are no more stored URL Metrics with the old ETag. These new URL Metrics will include data relevant to the newly activated plugins and their tag visitors.
+
 **Action:** `od_url_metric_stored` (argument: `OD_URL_Metric_Store_Request_Context`)
 
 Fires whenever a URL Metric was successfully stored.
@@ -265,6 +319,25 @@
 
 == Changelog ==
 
+= 0.9.0 =
+
+**Enhancements**
+
+* Add `fetchpriority=high` to `IMG` when it is the LCP element on desktop and mobile with other viewport groups empty. ([1723](https://github.com/WordPress/performance/pull/1723))
+* Improve debugging stored URL Metrics in Optimization Detective. ([1656](https://github.com/WordPress/performance/pull/1656))
+* Incorporate page state into ETag computation. ([1722](https://github.com/WordPress/performance/pull/1722))
+* Mark existing URL Metrics as stale when a new tag visitor is registered. ([1705](https://github.com/WordPress/performance/pull/1705))
+* Set development mode to 'plugin' in the dev environment and allow pages to be optimized when admin is logged-in (when in plugin dev mode). ([1700](https://github.com/WordPress/performance/pull/1700))
+* Add `get_xpath_elements_map()` helper methods to `OD_URL_Metric_Group_Collection` and `OD_URL_Metric_Group`, and add `get_all_element_max_intersection_ratios`/`get_element_max_intersection_ratio` methods to `OD_URL_Metric_Group`. ([1654](https://github.com/WordPress/performance/pull/1654))
+* Add `get_breadcrumbs()` method to `OD_HTML_Tag_Processor`. ([1707](https://github.com/WordPress/performance/pull/1707))
+* Add `get_sample_size()` and `get_freshness_ttl()` methods to `OD_URL_Metric_Group`. ([1697](https://github.com/WordPress/performance/pull/1697))
+* Expose `onTTFB`, `onFCP`, `onLCP`, `onINP`, and `onCLS` from web-vitals.js to extension JS modules via args their `initialize` functions. ([1697](https://github.com/WordPress/performance/pull/1697))
+
+**Bug Fixes**
+
+* Prevent submitting URL Metric if viewport size changed. ([1712](https://github.com/WordPress/performance/pull/1712))
+* Fix construction of XPath expressions for implicitly closed paragraphs. ([1707](https://github.com/WordPress/performance/pull/1707))
+
 = 0.8.0 =
 
 **Enhancements**
Index: storage/class-od-url-metrics-post-type.php
===================================================================
--- storage/class-od-url-metrics-post-type.php	(revision 3209553)
+++ storage/class-od-url-metrics-post-type.php	(working copy)
@@ -115,6 +115,7 @@
 	 *
 	 * @param WP_Post $post URL Metrics post.
 	 * @return OD_URL_Metric[] URL Metrics.
+	 * @noinspection PhpDocMissingThrowsInspection
 	 */
 	public static function get_url_metrics_from_post( WP_Post $post ): array {
 		$this_function = __METHOD__;
@@ -123,6 +124,11 @@
 			if ( ! in_array( $error_level, array( E_USER_NOTICE, E_USER_WARNING, E_USER_ERROR, E_USER_DEPRECATED ), true ) ) {
 				$error_level = E_USER_NOTICE;
 			}
+			/**
+			 * No WP_Exception is thrown by wp_trigger_error() since E_USER_ERROR is not passed as the error level.
+			 *
+			 * @noinspection PhpUnhandledExceptionInspection
+			 */
 			wp_trigger_error( $this_function, esc_html( $message ), $error_level );
 		};
 
@@ -217,8 +223,18 @@
 			$url_metrics            = array();
 		}
 
+		$etag = $new_url_metric->get_etag();
+		if ( null === $etag ) {
+			// This case actually will never occur in practice because the store_url_metric function is only called
+			// in the REST API endpoint where the ETag parameter is required. It is here exclusively for the sake of
+			// PHPStan's static analysis. This entire condition can be removed in a future release when the 'etag'
+			// property becomes required.
+			return new WP_Error( 'missing_etag' );
+		}
+
 		$group_collection = new OD_URL_Metric_Group_Collection(
 			$url_metrics,
+			$etag,
 			od_get_breakpoint_max_widths(),
 			od_get_url_metrics_breakpoint_sample_size(),
 			od_get_url_metric_freshness_ttl()
Index: storage/data.php
===================================================================
--- storage/data.php	(revision 3209553)
+++ storage/data.php	(working copy)
@@ -141,24 +141,132 @@
 }
 
 /**
+ * Gets the current template for a block theme or a classic theme.
+ *
+ * @since 0.9.0
+ * @access private
+ *
+ * @global string|null $_wp_current_template_id Current template ID.
+ * @global string|null $template                Template file path.
+ *
+ * @return string|WP_Block_Template|null Template.
+ */
+function od_get_current_theme_template() {
+	global $template, $_wp_current_template_id;
+
+	if ( wp_is_block_theme() && isset( $_wp_current_template_id ) ) {
+		$block_template = get_block_template( $_wp_current_template_id, 'wp_template' );
+		if ( $block_template instanceof WP_Block_Template ) {
+			return $block_template;
+		}
+	}
+	if ( isset( $template ) && is_string( $template ) ) {
+		return basename( $template );
+	}
+	return null;
+}
+
+/**
+ * Gets the current ETag for URL Metrics.
+ *
+ * Generates a hash based on the IDs of registered tag visitors, the queried object,
+ * posts in The Loop, and theme information in the current environment. This ETag
+ * is used to assess if the URL Metrics are stale when its value changes.
+ *
+ * @since 0.9.0
+ * @access private
+ *
+ * @param OD_Tag_Visitor_Registry       $tag_visitor_registry Tag visitor registry.
+ * @param WP_Query|null                 $wp_query             The WP_Query instance.
+ * @param string|WP_Block_Template|null $current_template     The current template being used.
+ * @return non-empty-string Current ETag.
+ */
+function od_get_current_url_metrics_etag( OD_Tag_Visitor_Registry $tag_visitor_registry, ?WP_Query $wp_query, $current_template ): string {
+	$queried_object      = $wp_query instanceof WP_Query ? $wp_query->get_queried_object() : null;
+	$queried_object_data = array(
+		'id'   => null,
+		'type' => null,
+	);
+
+	if ( $queried_object instanceof WP_Post ) {
+		$queried_object_data['id']                = $queried_object->ID;
+		$queried_object_data['type']              = 'post';
+		$queried_object_data['post_modified_gmt'] = $queried_object->post_modified_gmt;
+	} elseif ( $queried_object instanceof WP_Term ) {
+		$queried_object_data['id']   = $queried_object->term_id;
+		$queried_object_data['type'] = 'term';
+	} elseif ( $queried_object instanceof WP_User ) {
+		$queried_object_data['id']   = $queried_object->ID;
+		$queried_object_data['type'] = 'user';
+	} elseif ( $queried_object instanceof WP_Post_Type ) {
+		$queried_object_data['type'] = $queried_object->name;
+	}
+
+	$data = array(
+		'tag_visitors'     => array_keys( iterator_to_array( $tag_visitor_registry ) ),
+		'queried_object'   => $queried_object_data,
+		'queried_posts'    => array_filter(
+			array_map(
+				static function ( $post ): ?array {
+					if ( is_int( $post ) ) {
+						$post = get_post( $post );
+					}
+					if ( ! ( $post instanceof WP_Post ) ) {
+						return null;
+					}
+					return array(
+						'id'                => $post->ID,
+						'post_modified_gmt' => $post->post_modified_gmt,
+					);
+				},
+				( $wp_query instanceof WP_Query && $wp_query->post_count > 0 ) ? $wp_query->posts : array()
+			)
+		),
+		'active_theme'     => array(
+			'template'   => array(
+				'name'    => get_template(),
+				'version' => wp_get_theme( get_template() )->get( 'Version' ),
+			),
+			'stylesheet' => array(
+				'name'    => get_stylesheet(),
+				'version' => wp_get_theme()->get( 'Version' ),
+			),
+		),
+		'current_template' => $current_template instanceof WP_Block_Template ? get_object_vars( $current_template ) : $current_template,
+	);
+
+	/**
+	 * Filters the data that goes into computing the current ETag for URL Metrics.
+	 *
+	 * @since 0.9.0
+	 *
+	 * @param array<string, mixed> $data Data.
+	 */
+	$data = (array) apply_filters( 'od_current_url_metrics_etag_data', $data );
+
+	return md5( (string) wp_json_encode( $data ) );
+}
+
+/**
  * Computes HMAC for storing URL Metrics for a specific slug.
  *
  * This is used in the REST API to authenticate the storage of new URL Metrics from a given URL.
  *
  * @since 0.8.0
+ * @since 0.9.0 Introduced the `$current_etag` parameter.
  * @access private
  *
  * @see od_verify_url_metrics_storage_hmac()
  * @see od_get_url_metrics_slug()
- * @todo This should also include an ETag as a parameter. See <https://github.com/WordPress/performance/issues/1466>.
  *
- * @param string   $slug                Slug (hash of normalized query vars).
- * @param string   $url                 URL.
- * @param int|null $cache_purge_post_id Cache purge post ID.
+ * @param string           $slug                Slug (hash of normalized query vars).
+ * @param non-empty-string $current_etag        Current ETag.
+ * @param string           $url                 URL.
+ * @param int|null         $cache_purge_post_id Cache purge post ID.
  * @return string HMAC.
  */
-function od_get_url_metrics_storage_hmac( string $slug, string $url, ?int $cache_purge_post_id = null ): string {
-	$action = "store_url_metric:$slug:$url:$cache_purge_post_id";
+function od_get_url_metrics_storage_hmac( string $slug, string $current_etag, string $url, ?int $cache_purge_post_id = null ): string {
+	$action = "store_url_metric:$slug:$current_etag:$url:$cache_purge_post_id";
 	return wp_hash( $action, 'nonce' );
 }
 
@@ -166,19 +274,21 @@
  * Verifies HMAC for storing URL Metrics for a specific slug.
  *
  * @since 0.8.0
+ * @since 0.9.0 Introduced the `$current_etag` parameter.
  * @access private
  *
  * @see od_get_url_metrics_storage_hmac()
  * @see od_get_url_metrics_slug()
  *
- * @param string   $hmac                HMAC.
- * @param string   $slug                Slug (hash of normalized query vars).
- * @param String   $url                 URL.
- * @param int|null $cache_purge_post_id Cache purge post ID.
+ * @param string           $hmac                HMAC.
+ * @param string           $slug                Slug (hash of normalized query vars).
+ * @param non-empty-string $current_etag        Current ETag.
+ * @param string           $url                 URL.
+ * @param int|null         $cache_purge_post_id Cache purge post ID.
  * @return bool Whether the HMAC is valid.
  */
-function od_verify_url_metrics_storage_hmac( string $hmac, string $slug, string $url, ?int $cache_purge_post_id = null ): bool {
-	return hash_equals( od_get_url_metrics_storage_hmac( $slug, $url, $cache_purge_post_id ), $hmac );
+function od_verify_url_metrics_storage_hmac( string $hmac, string $slug, string $current_etag, string $url, ?int $cache_purge_post_id = null ): bool {
+	return hash_equals( od_get_url_metrics_storage_hmac( $slug, $current_etag, $url, $cache_purge_post_id ), $hmac );
 }
 
 /**
Index: storage/rest-api.php
===================================================================
--- storage/rest-api.php	(revision 3209553)
+++ storage/rest-api.php	(working copy)
@@ -44,8 +44,18 @@
 			'type'        => 'string',
 			'description' => __( 'An MD5 hash of the query args.', 'optimization-detective' ),
 			'required'    => true,
-			'pattern'     => '^[0-9a-f]{32}$',
+			'pattern'     => '^[0-9a-f]{32}\z',
+			'minLength'   => 32,
+			'maxLength'   => 32,
 		),
+		'current_etag'        => array(
+			'type'        => 'string',
+			'description' => __( 'ETag for the current environment.', 'optimization-detective' ),
+			'required'    => true,
+			'pattern'     => '^[0-9a-f]{32}\z',
+			'minLength'   => 32,
+			'maxLength'   => 32,
+		),
 		'cache_purge_post_id' => array(
 			'type'        => 'integer',
 			'description' => __( 'Cache purge post ID.', 'optimization-detective' ),
@@ -56,9 +66,9 @@
 			'type'              => 'string',
 			'description'       => __( 'HMAC originally computed by server required to authorize the request.', 'optimization-detective' ),
 			'required'          => true,
-			'pattern'           => '^[0-9a-f]+$',
+			'pattern'           => '^[0-9a-f]+\z',
 			'validate_callback' => static function ( string $hmac, WP_REST_Request $request ) {
-				if ( ! od_verify_url_metrics_storage_hmac( $hmac, $request['slug'], $request['url'], $request['cache_purge_post_id'] ?? null ) ) {
+				if ( ! od_verify_url_metrics_storage_hmac( $hmac, $request['slug'], $request['current_etag'], $request['url'], $request['cache_purge_post_id'] ?? null ) ) {
 					return new WP_Error( 'invalid_hmac', __( 'URL Metrics HMAC verification failure.', 'optimization-detective' ) );
 				}
 				return true;
@@ -84,7 +94,7 @@
 					return new WP_Error(
 						'url_metric_storage_locked',
 						__( 'URL Metric storage is presently locked for the current IP.', 'optimization-detective' ),
-						array( 'status' => 403 )
+						array( 'status' => 403 ) // TODO: Consider 423 Locked status code.
 					);
 				}
 				return true;
@@ -141,6 +151,7 @@
 
 	$url_metric_group_collection = new OD_URL_Metric_Group_Collection(
 		$post instanceof WP_Post ? OD_URL_Metrics_Post_Type::get_url_metrics_from_post( $post ) : array(),
+		$request->get_param( 'current_etag' ),
 		od_get_breakpoint_max_widths(),
 		od_get_url_metrics_breakpoint_sample_size(),
 		od_get_url_metric_freshness_ttl()
@@ -152,6 +163,7 @@
 			$request->get_param( 'viewport' )['width']
 		);
 	} catch ( InvalidArgumentException $exception ) {
+		// Note: This should never happen because an exception only occurs if a viewport width is less than zero, and the JSON Schema enforces that the viewport.width have a minimum of zero.
 		return new WP_Error( 'invalid_viewport_width', $exception->getMessage() );
 	}
 	if ( $url_metric_group->is_complete() ) {
@@ -182,6 +194,7 @@
 					// Now supply the readonly args which were omitted from the REST API params due to being `readonly`.
 					'timestamp' => microtime( true ),
 					'uuid'      => wp_generate_uuid4(),
+					'etag'      => $request->get_param( 'current_etag' ),
 				)
 			)
 		);
@@ -189,7 +202,7 @@
 		return new WP_Error(
 			'rest_invalid_param',
 			sprintf(
-				/* translators: %s is exception name */
+				/* translators: %s is exception message */
 				__( 'Failed to validate URL Metric: %s', 'optimization-detective' ),
 				$e->getMessage()
 			),
@@ -202,9 +215,19 @@
 		$request->get_param( 'slug' ),
 		$url_metric
 	);
-
 	if ( $result instanceof WP_Error ) {
-		return $result;
+		$error_data = array(
+			'status' => 500,
+		);
+		if ( WP_DEBUG ) {
+			$error_data['error_code']    = $result->get_error_code();
+			$error_data['error_message'] = $result->get_error_message();
+		}
+		return new WP_Error(
+			'unable_to_store_url_metric',
+			__( 'Unable to store URL Metric.', 'optimization-detective' ),
+			$error_data
+		);
 	}
 	$post_id = $result;
 
Index: types.ts
===================================================================
--- types.ts	(revision 3209553)
+++ types.ts	(working copy)
@@ -1,6 +1,8 @@
 // h/t https://stackoverflow.com/a/59801602/93579
 type ExcludeProps< T > = { [ k: string ]: any } & { [ K in keyof T ]?: never };
 
+import { onTTFB, onFCP, onLCP, onINP, onCLS } from 'web-vitals';
+
 export interface ElementData {
 	isLCP: boolean;
 	isLCPCandidate: boolean;
@@ -28,11 +30,22 @@
 	complete: boolean;
 }
 
+export type OnTTFBFunction = typeof onTTFB;
+export type OnFCPFunction = typeof onFCP;
+export type OnLCPFunction = typeof onLCP;
+export type OnINPFunction = typeof onINP;
+export type OnCLSFunction = typeof onCLS;
+
 export type InitializeArgs = {
 	readonly isDebug: boolean;
+	readonly onTTFB: OnTTFBFunction;
+	readonly onFCP: OnFCPFunction;
+	readonly onLCP: OnLCPFunction;
+	readonly onINP: OnINPFunction;
+	readonly onCLS: OnCLSFunction;
 };
 
-export type InitializeCallback = ( args: InitializeArgs ) => void;
+export type InitializeCallback = ( args: InitializeArgs ) => Promise< void >;
 
 export type FinalizeArgs = {
 	readonly getRootData: () => URLMetric;

performance-lab

Important

Stable tag change: 3.6.1 → 3.7.0

svn status:

M       includes/admin/load.php
?       includes/admin/plugin-activate-ajax.min.js
M       includes/admin/plugins.php
M       load.php
M       readme.txt
svn diff
Index: includes/admin/load.php
===================================================================
--- includes/admin/load.php	(revision 3209553)
+++ includes/admin/load.php	(working copy)
@@ -214,6 +214,42 @@
 add_action( 'wp_ajax_dismiss-wp-pointer', 'perflab_dismiss_wp_pointer_wrapper', 0 );
 
 /**
+ * Gets the path to a script or stylesheet.
+ *
+ * @since 3.7.0
+ *
+ * @param string      $src_path Source path.
+ * @param string|null $min_path Minified path. If not supplied, then '.min' is injected before the file extension in the source path.
+ * @return string URL to script or stylesheet.
+ */
+function perflab_get_asset_path( string $src_path, ?string $min_path = null ): string {
+	if ( null === $min_path ) {
+		// Note: wp_scripts_get_suffix() is not used here because we need access to both the source and minified paths.
+		$min_path = (string) preg_replace( '/(?=\.\w+$)/', '.min', $src_path );
+	}
+
+	$force_src = false;
+	if ( WP_DEBUG && ! file_exists( trailingslashit( PERFLAB_PLUGIN_DIR_PATH ) . $min_path ) ) {
+		$force_src = true;
+		wp_trigger_error(
+			__FUNCTION__,
+			sprintf(
+				/* translators: %s is the minified asset path */
+				__( 'Minified asset has not been built: %s', 'performance-lab' ),
+				$min_path
+			),
+			E_USER_WARNING
+		);
+	}
+
+	if ( SCRIPT_DEBUG || $force_src ) {
+		return $src_path;
+	}
+
+	return $min_path;
+}
+
+/**
  * Callback function to handle admin scripts.
  *
  * @since 2.8.0
@@ -228,7 +264,7 @@
 	// Enqueue plugin activate AJAX script and localize script data.
 	wp_enqueue_script(
 		'perflab-plugin-activate-ajax',
-		plugin_dir_url( PERFLAB_MAIN_FILE ) . 'includes/admin/plugin-activate-ajax.js',
+		plugin_dir_url( PERFLAB_MAIN_FILE ) . perflab_get_asset_path( 'includes/admin/plugin-activate-ajax.js' ),
 		array( 'wp-i18n', 'wp-a11y', 'wp-api-fetch' ),
 		PERFLAB_VERSION,
 		true
Index: includes/admin/plugins.php
===================================================================
--- includes/admin/plugins.php	(revision 3209553)
+++ includes/admin/plugins.php	(working copy)
@@ -16,18 +16,18 @@
  * @since 2.8.0
  *
  * @param string $plugin_slug The string identifier for the plugin in questions slug.
- * @return array{name: string, slug: string, short_description: string, requires: string|false, requires_php: string|false, requires_plugins: string[], download_link: string, version: string}|WP_Error Array of plugin data or WP_Error if failed.
+ * @return array{name: string, slug: string, short_description: string, requires: string|false, requires_php: string|false, requires_plugins: string[], version: string}|WP_Error Array of plugin data or WP_Error if failed.
  */
 function perflab_query_plugin_info( string $plugin_slug ) {
 	$transient_key = 'perflab_plugins_info';
 	$plugins       = get_transient( $transient_key );
 
-	if ( is_array( $plugins ) ) {
-		// If the specific plugin_slug is not in the cache, return an error.
-		if ( ! isset( $plugins[ $plugin_slug ] ) ) {
+	if ( is_array( $plugins ) && isset( $plugins[ $plugin_slug ] ) ) {
+		if ( isset( $plugins[ $plugin_slug ]['error'] ) ) {
+			// Plugin was requested before but an error occurred for it.
 			return new WP_Error(
-				'plugin_not_found',
-				__( 'Plugin not found in cached API response.', 'performance-lab' )
+				$plugins[ $plugin_slug ]['error']['code'],
+				$plugins[ $plugin_slug ]['error']['message']
 			);
 		}
 		return $plugins[ $plugin_slug ]; // Return cached plugin info if found.
@@ -40,7 +40,6 @@
 		'requires',
 		'requires_php',
 		'requires_plugins',
-		'download_link',
 		'version', // Needed by install_plugin_install_status().
 	);
 
@@ -55,40 +54,94 @@
 		)
 	);
 
+	$has_errors = false;
+	$plugins    = array();
+
 	if ( is_wp_error( $response ) ) {
-		return new WP_Error(
-			'api_error',
-			sprintf(
-				/* translators: %s: API error message */
-				__( 'Failed to retrieve plugins data from WordPress.org API: %s', 'performance-lab' ),
-				$response->get_error_message()
-			)
+		$plugins[ $plugin_slug ] = array(
+			'error' => array(
+				'code'    => 'api_error',
+				'message' => sprintf(
+					/* translators: %s: API error message */
+					__( 'Failed to retrieve plugins data from WordPress.org API: %s', 'performance-lab' ),
+					$response->get_error_message()
+				),
+			),
 		);
-	}
 
-	// Check if the response contains plugins.
-	if ( ! ( is_object( $response ) && property_exists( $response, 'plugins' ) ) ) {
-		return new WP_Error( 'no_plugins', __( 'No plugins found in the API response.', 'performance-lab' ) );
-	}
+		foreach ( perflab_get_standalone_plugins() as $standalone_plugin ) {
+			$plugins[ $standalone_plugin ] = $plugins[ $plugin_slug ];
+		}
 
-	$plugins            = array();
-	$standalone_plugins = array_merge(
-		array_flip( perflab_get_standalone_plugins() ),
-		array( 'optimization-detective' => array() ) // TODO: Programmatically discover the plugin dependencies and add them here. See <https://github.com/WordPress/performance/issues/1616>.
-	);
-	foreach ( $response->plugins as $plugin_data ) {
-		if ( ! isset( $standalone_plugins[ $plugin_data['slug'] ] ) ) {
-			continue;
+		$has_errors = true;
+	} elseif ( ! is_object( $response ) || ! property_exists( $response, 'plugins' ) ) {
+		$plugins[ $plugin_slug ] = array(
+			'error' => array(
+				'code'    => 'no_plugins',
+				'message' => __( 'No plugins found in the API response.', 'performance-lab' ),
+			),
+		);
+
+		foreach ( perflab_get_standalone_plugins() as $standalone_plugin ) {
+			$plugins[ $standalone_plugin ] = $plugins[ $plugin_slug ];
 		}
-		$plugins[ $plugin_data['slug'] ] = wp_array_slice_assoc( $plugin_data, $fields );
+
+		$has_errors = true;
+	} else {
+		$plugin_queue = perflab_get_standalone_plugins();
+
+		// Index the plugins from the API response by their slug for efficient lookup.
+		$all_performance_plugins = array_column( $response->plugins, null, 'slug' );
+
+		// Start processing the plugins using a queue-based approach.
+		while ( count( $plugin_queue ) > 0 ) { // phpcs:ignore Squiz.PHP.DisallowSizeFunctionsInLoops.Found
+			$current_plugin_slug = array_shift( $plugin_queue );
+
+			// Skip already-processed plugins.
+			if ( isset( $plugins[ $current_plugin_slug ] ) ) {
+				continue;
+			}
+
+			if ( ! isset( $all_performance_plugins[ $current_plugin_slug ] ) ) {
+				// Cache the fact that the plugin was not found.
+				$plugins[ $current_plugin_slug ] = array(
+					'error' => array(
+						'code'    => 'plugin_not_found',
+						'message' => __( 'Plugin not found in API response.', 'performance-lab' ),
+					),
+				);
+
+				$has_errors = true;
+			} else {
+				$plugin_data                     = $all_performance_plugins[ $current_plugin_slug ];
+				$plugins[ $current_plugin_slug ] = wp_array_slice_assoc( $plugin_data, $fields );
+
+				// Enqueue the required plugins slug by adding it to the queue.
+				if ( isset( $plugin_data['requires_plugins'] ) && is_array( $plugin_data['requires_plugins'] ) ) {
+					$plugin_queue = array_merge( $plugin_queue, $plugin_data['requires_plugins'] );
+				}
+			}
+		}
+
+		if ( ! isset( $plugins[ $plugin_slug ] ) ) {
+			// Cache the fact that the plugin was not found.
+			$plugins[ $plugin_slug ] = array(
+				'error' => array(
+					'code'    => 'plugin_not_found',
+					'message' => __( 'The requested plugin is not part of Performance Lab plugins.', 'performance-lab' ),
+				),
+			);
+
+			$has_errors = true;
+		}
 	}
 
-	set_transient( $transient_key, $plugins, HOUR_IN_SECONDS );
+	set_transient( $transient_key, $plugins, $has_errors ? MINUTE_IN_SECONDS : HOUR_IN_SECONDS );
 
-	if ( ! isset( $plugins[ $plugin_slug ] ) ) {
+	if ( isset( $plugins[ $plugin_slug ]['error'] ) ) {
 		return new WP_Error(
-			'plugin_not_found',
-			__( 'Plugin not found in API response.', 'performance-lab' )
+			$plugins[ $plugin_slug ]['error']['code'],
+			$plugins[ $plugin_slug ]['error']['message']
 		);
 	}
 
@@ -95,7 +148,7 @@
 	/**
 	 * Validated (mostly) plugin data.
 	 *
-	 * @var array<string, array{name: string, slug: string, short_description: string, requires: string|false, requires_php: string|false, requires_plugins: string[], download_link: string, version: string}> $plugins
+	 * @var array<string, array{name: string, slug: string, short_description: string, requires: string|false, requires_php: string|false, requires_plugins: string[], version: string}> $plugins
 	 */
 	return $plugins[ $plugin_slug ];
 }
@@ -239,6 +292,31 @@
 		<div class="clear"></div>
 	</div>
 	<?php
+	if ( current_user_can( 'activate_plugins' ) ) {
+		?>
+		<p>
+			<?php
+			$plugins_url = add_query_arg(
+				array(
+					's'             => 'WordPress Performance Team',
+					'plugin_status' => 'all',
+				),
+				admin_url( 'plugins.php' )
+			);
+			echo wp_kses(
+				sprintf(
+					/* translators: %s is the URL to the plugins screen */
+					__( 'Performance features are installed as plugins. To update features or remove them, <a href="%s">manage them on the plugins screen</a>.', 'performance-lab' ),
+					esc_url( $plugins_url )
+				),
+				array(
+					'a' => array( 'href' => true ),
+				)
+			);
+			?>
+		</p>
+		<?php
+	}
 }
 
 /**
@@ -325,11 +403,27 @@
 	}
 	$processed_plugins[] = $plugin_slug;
 
-	$plugin_data = perflab_query_plugin_info( $plugin_slug );
+	// Get the freshest data (including the most recent download_link) as opposed what is cached by perflab_query_plugin_info().
+	$plugin_data = plugins_api(
+		'plugin_information',
+		array(
+			'slug'   => $plugin_slug,
+			'fields' => array(
+				'download_link'    => true,
+				'requires_plugins' => true,
+				'sections'         => false, // Omit the bulk of the response which we don't need.
+			),
+		)
+	);
+
 	if ( $plugin_data instanceof WP_Error ) {
 		return $plugin_data;
 	}
 
+	if ( is_object( $plugin_data ) ) {
+		$plugin_data = (array) $plugin_data;
+	}
+
 	// Add recommended plugins (soft dependencies) to the list of plugins installed and activated.
 	if ( 'embed-optimizer' === $plugin_slug ) {
 		$plugin_data['requires_plugins'][] = 'optimization-detective';
Index: load.php
===================================================================
--- load.php	(revision 3209553)
+++ load.php	(working copy)
@@ -3,9 +3,9 @@
  * Plugin Name: Performance Lab
  * Plugin URI: https://github.com/WordPress/performance
  * Description: Performance plugin from the WordPress Performance Team, which is a collection of standalone performance features.
- * Requires at least: 6.5
+ * Requires at least: 6.6
  * Requires PHP: 7.2
- * Version: 3.6.1
+ * Version: 3.7.0
  * Author: WordPress Performance Team
  * Author URI: https://make.wordpress.org/performance/
  * License: GPLv2 or later
@@ -19,7 +19,7 @@
 	exit; // Exit if accessed directly.
 }
 
-define( 'PERFLAB_VERSION', '3.6.1' );
+define( 'PERFLAB_VERSION', '3.7.0' );
 define( 'PERFLAB_MAIN_FILE', __FILE__ );
 define( 'PERFLAB_PLUGIN_DIR_PATH', plugin_dir_path( PERFLAB_MAIN_FILE ) );
 define( 'PERFLAB_SCREEN', 'performance-lab' );
Index: readme.txt
===================================================================
--- readme.txt	(revision 3209553)
+++ readme.txt	(working copy)
@@ -2,7 +2,7 @@
 
 Contributors: wordpressdotorg
 Tested up to: 6.7
-Stable tag:   3.6.1
+Stable tag:   3.7.0
 License:      GPLv2 or later
 License URI:  https://www.gnu.org/licenses/gpl-2.0.html
 Tags:         performance, site health, measurement, optimization, diagnostics
@@ -71,6 +71,19 @@
 
 == Changelog ==
 
+= 3.7.0 =
+
+**Enhancements**
+
+* Add guidance for managing Performance feature plugins. ([1734](https://github.com/WordPress/performance/pull/1734))
+* Automatically discover plugin dependencies when obtaining Performance feature plugins from WordPress.org. ([1680](https://github.com/WordPress/performance/pull/1680))
+* Disregard transient cache in `perflab_query_plugin_info()` when a plugin is absent. ([1694](https://github.com/WordPress/performance/pull/1694))
+* Minify script used for ajax activation of features; warn if absent and serve original file when SCRIPT_DEBUG is enabled. ([1658](https://github.com/WordPress/performance/pull/1658))
+
+**Bug Fixes**
+
+* Fix latest plugin version not being downloaded consistently. ([1693](https://github.com/WordPress/performance/pull/1693))
+
 = 3.6.1 =
 
 **Bug Fixes**

speculation-rules

Warning

Stable tag is unchanged at 1.3.1, so no plugin release will occur.

svn status:

M       class-plsr-url-pattern-prefixer.php
M       helper.php
M       hooks.php
M       load.php
M       readme.txt
M       settings.php
svn diff
Index: class-plsr-url-pattern-prefixer.php
===================================================================
--- class-plsr-url-pattern-prefixer.php	(revision 3209553)
+++ class-plsr-url-pattern-prefixer.php	(working copy)
@@ -35,7 +35,7 @@
 	 *                                        by the {@see PLSR_URL_Pattern_Prefixer::get_default_contexts()} method.
 	 */
 	public function __construct( array $contexts = array() ) {
-		if ( $contexts ) {
+		if ( count( $contexts ) > 0 ) {
 			$this->contexts = array_map(
 				static function ( string $str ): string {
 					return self::escape_pattern_string( trailingslashit( $str ) );
Index: helper.php
===================================================================
--- helper.php	(revision 3209553)
+++ helper.php	(working copy)
@@ -19,22 +19,11 @@
  *
  * @since 1.0.0
  *
- * @return array<string, array<int, array<string, mixed>>> Associative array of speculation rules by type.
+ * @return non-empty-array<string, array<int, array<string, mixed>>> Associative array of speculation rules by type.
  */
 function plsr_get_speculation_rules(): array {
-	$option = get_option( 'plsr_speculation_rules' );
-
-	/*
-	 * This logic is only relevant for edge-cases where the setting may not be registered,
-	 * a.k.a. defensive coding.
-	 */
-	if ( ! $option || ! is_array( $option ) ) {
-		$option = plsr_get_setting_default();
-	} else {
-		$option = array_merge( plsr_get_setting_default(), $option );
-	}
-
-	$mode      = (string) $option['mode'];
+	$option    = plsr_get_stored_setting_value();
+	$mode      = $option['mode'];
 	$eagerness = $option['eagerness'];
 
 	$prefixer = new PLSR_URL_Pattern_Prefixer();
Index: hooks.php
===================================================================
--- hooks.php	(revision 3209553)
+++ hooks.php	(working copy)
@@ -19,30 +19,10 @@
  * @since 1.0.0
  */
 function plsr_print_speculation_rules(): void {
-	$rules = plsr_get_speculation_rules();
-	if ( empty( $rules ) ) {
-		return;
-	}
-
-	// This workaround is needed for WP 6.4. See <https://core.trac.wordpress.org/ticket/60320>.
-	$needs_html5_workaround = (
-		! current_theme_supports( 'html5', 'script' ) &&
-		version_compare( (string) strtok( (string) get_bloginfo( 'version' ), '-' ), '6.4', '>=' ) &&
-		version_compare( (string) strtok( (string) get_bloginfo( 'version' ), '-' ), '6.5', '<' )
-	);
-	if ( $needs_html5_workaround ) {
-		$backup_wp_theme_features = $GLOBALS['_wp_theme_features'];
-		add_theme_support( 'html5', array( 'script' ) );
-	}
-
 	wp_print_inline_script_tag(
-		(string) wp_json_encode( $rules ),
+		(string) wp_json_encode( plsr_get_speculation_rules() ),
 		array( 'type' => 'speculationrules' )
 	);
-
-	if ( $needs_html5_workaround ) {
-		$GLOBALS['_wp_theme_features'] = $backup_wp_theme_features; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
-	}
 }
 add_action( 'wp_footer', 'plsr_print_speculation_rules' );
 
Index: load.php
===================================================================
--- load.php	(revision 3209553)
+++ load.php	(working copy)
@@ -2,8 +2,8 @@
 /**
  * Plugin Name: Speculative Loading
  * Plugin URI: https://github.com/WordPress/performance/tree/trunk/plugins/speculation-rules
- * Description: Enables browsers to speculatively prerender or prefetch pages when hovering over links.
- * Requires at least: 6.4
+ * Description: Enables browsers to speculatively prerender or prefetch pages to achieve near-instant loads based on user interaction.
+ * Requires at least: 6.6
  * Requires PHP: 7.2
  * Version: 1.3.1
  * Author: WordPress Performance Team
Index: readme.txt
===================================================================
--- readme.txt	(revision 3209553)
+++ readme.txt	(working copy)
@@ -1,34 +1,30 @@
 === Speculative Loading ===
 
-Contributors:      wordpressdotorg
-Requires at least: 6.4
-Tested up to:      6.5
-Requires PHP:      7.2
-Stable tag:        1.3.1
-License:           GPLv2 or later
-License URI:       https://www.gnu.org/licenses/gpl-2.0.html
-Tags:              performance, javascript, speculation rules, prerender, prefetch
+Contributors: wordpressdotorg
+Tested up to: 6.7
+Stable tag:   1.3.1
+License:      GPLv2 or later
+License URI:  https://www.gnu.org/licenses/gpl-2.0.html
+Tags:         performance, javascript, speculation rules, prerender, prefetch
 
-Enables browsers to speculatively prerender or prefetch pages when hovering over links.
+Enables browsers to speculatively prerender or prefetch pages to achieve near-instant loads based on user interaction.
 
 == Description ==
 
-This plugin adds support for the [Speculation Rules API](https://developer.mozilla.org/en-US/docs/Web/API/Speculation_Rules_API), which allows defining rules by which certain URLs are dynamically prefetched or prerendered based on user interaction.
+This plugin adds support for the [Speculation Rules API](https://developer.mozilla.org/en-US/docs/Web/API/Speculation_Rules_API), which allows defining rules by which certain URLs are dynamically prefetched or prerendered.
 
 See the [Speculation Rules WICG specification draft](https://wicg.github.io/nav-speculation/speculation-rules.html).
 
-By default, the plugin is configured to prerender WordPress frontend URLs when the user hovers over a relevant link. This can be customized via the "Speculative Loading" section under _Settings > Reading_.
+By default, the plugin is configured to prerender WordPress frontend URLs when the user interacts with a relevant link. This can be customized via the "Speculative Loading" section in the _Settings > Reading_ admin screen.
 
-A filter can be used to exclude certain URL paths from being eligible for prefetching and prerendering (see FAQ section). Alternatively, you can add the 'no-prerender' CSS class to any link (`<a>` tag) that should not be prerendered. See FAQ for more information.
+A filter can be used to exclude certain URL paths from being eligible for prefetching and prerendering (see FAQ section). Alternatively, you can add the `no-prerender` CSS class to any link (`<a>` tag) that should not be prerendered. See FAQ for more information.
 
 = Browser support =
 
-The Speculation Rules API is a new web API, and the functionality used by the plugin is supported in Chromium-based browsers such as Chrome, Edge, or Opera using version 121 or above. Other browsers such as Safari and Firefox will ignore the functionality with no ill effects but will not benefit from the speculative loading. Note that extensions may disable preloading by default (for example, uBlock Origin does this).
+The Speculation Rules API is a new web API, and the functionality used by the plugin is supported in Chromium-based browsers such as Chrome, Edge, or Opera using version 121 or above. Other browsers such as Safari and Firefox will ignore the functionality with no ill effects; they will simply not benefit from the speculative loading. Note that certain browser extensions may disable preloading by default.
 
-Other browsers will not see any adverse effects, however the feature will not work for those clients.
-
 * [Browser support for the Speculation Rules API in general](https://caniuse.com/mdn-html_elements_script_type_speculationrules)
-* [Information on document rules syntax support used by the plugin](https://developer.chrome.com/blog/chrome-121-beta#speculation_rules_api)
+* [Information on document rules syntax support used by the plugin](https://developer.chrome.com/docs/web-platform/prerender-pages)
 
 _This plugin was formerly known as Speculation Rules._
 
@@ -50,12 +46,11 @@
 
 = How can I prevent certain URLs from being prefetched and prerendered? =
 
-Not every URL can be reasonably prerendered. Prerendering static content is typically reliable, however prerendering interactive content, such as a logout URL, can lead to issues. For this reason, certain WordPress core URLs such as `/wp-login.php` and `/wp-admin/*` are excluded from prefetching and prerendering. Additionally, any URL generated with `wp_nonce_url()` (or which contain the `_wpnonce` query var) is also ignored. You can exclude additional URL patterns by using the `plsr_speculation_rules_href_exclude_paths` filter.
+Not every URL can be reasonably prerendered. Prerendering static content is typically reliable, however prerendering interactive content, such as a logout URL, can lead to issues. For this reason, certain WordPress core URLs such as `/wp-login.php` and `/wp-admin/*` are excluded from prefetching and prerendering. Additionally, any URLs generated with `wp_nonce_url()` (or which contains the `_wpnonce` query var) and `nofollow` links are also ignored. You can exclude additional URL patterns by using the `plsr_speculation_rules_href_exclude_paths` filter.
 
-This example would ensure that URLs like `https://example.com/cart/` or `https://example.com/cart/foo` would be excluded from prefetching and prerendering.
+The following example ensures that URLs like `https://example.com/cart/` or `https://example.com/cart/foo` are excluded from prefetching and prerendering:
 `
 <?php
-
 add_filter(
 	'plsr_speculation_rules_href_exclude_paths',
 	function ( array $exclude_paths ): array {
@@ -69,10 +64,9 @@
 
 For this purpose, the `plsr_speculation_rules_href_exclude_paths` filter receives the current mode (either "prefetch" or "prerender") to provide conditional exclusions.
 
-The following example would ensure that URLs like `https://example.com/products/...` cannot be prerendered, while still allowing them to be prefetched.
+The following example ensures that URLs like `https://example.com/products/...` cannot be prerendered, while still allowing them to be prefetched:
 `
 <?php
-
 add_filter(
 	'plsr_speculation_rules_href_exclude_paths',
 	function ( array $exclude_paths, string $mode ): array {
@@ -92,11 +86,11 @@
 
 Prerendering can affect analytics and personalization.
 
-For client-side JavaScript, is recommended to delay these until the page clicks and some solutions (like Google Analytics) already do this automatically for prerender. See [Impact on Analytics](https://developer.chrome.com/docs/web-platform/prerender-pages#impact-on-analytics). Additionally, cross-origin iframes are not loaded until activation which can further avoid issues here.
+For client-side JavaScript, is recommended to delay these until the prerender is activated (for example by clicking on the link). Some solutions (like Google Analytics) already do this automatically, see [Impact on Analytics](https://developer.chrome.com/docs/web-platform/prerender-pages#impact-on-analytics). Additionally, cross-origin iframes are not loaded until activation which can further avoid issues here.
 
-Speculating on hover (moderate) increases the chance the page will be loaded, over preloading without this signal, and thus reduces the risk here. Alternatively, the plugin offers to only speculate on mouse/pointer down (conservative) which further reduces the risk here and is an option for sites which are concerned about this, at the cost of having less of a lead time and so less of a performance gain.
+Speculating with the default `moderate` eagerness decreases the risk that the prerendered page will not be visited by the user and therefore will avoid any side effects of loading such a link in advance. In contrast, `eager` speculation increases the risk that prerendered pages may not be loaded. Alternatively, the plugin offers to only speculate on mouse/pointer down (conservative) which reduces the risk even further and is an option for sites which are concerned about this, at the cost of having less of a lead time and so less of a performance gain.
 
-A prerendered page is linked to the page that prerenders it, so personalisation may already be known by this point and changes (e.g. browsing other products, or logging in/out) may require a new page load, and hence a new prerender anyway, which will take these into account. But it definitely is something to be aware of and test!
+A prerendered page is linked to the page that prerenders it, so personalisation may already be known by this point and changes (e.g. browsing other products, or logging in/out) often require a new page load, and hence a new prerender, which will then take these into account. But it definitely is something to be aware of and test! Prerendered pages can be canceled by removing the speculation rules `<script>` element from the page using standard JavaScript DOM APIs should this be needed when state changes without a new page load.
 
 = Where can I submit my plugin feedback? =
 
Index: settings.php
===================================================================
--- settings.php	(revision 3209553)
+++ settings.php	(working copy)
@@ -16,7 +16,7 @@
  *
  * @since 1.0.0
  *
- * @return array<string, string> Associative array of `$mode => $label` pairs.
+ * @return array{ prefetch: string, prerender: string } Associative array of `$mode => $label` pairs.
  */
 function plsr_get_mode_labels(): array {
 	return array(
@@ -30,7 +30,7 @@
  *
  * @since 1.0.0
  *
- * @return array<string, string> Associative array of `$eagerness => $label` pairs.
+ * @return array{ conservative: string, moderate: string, eager: string } Associative array of `$eagerness => $label` pairs.
  */
 function plsr_get_eagerness_labels(): array {
 	return array(
@@ -45,7 +45,7 @@
  *
  * @since 1.0.0
  *
- * @return array<string, string> {
+ * @return array{ mode: 'prerender', eagerness: 'moderate' } {
  *     Default setting value.
  *
  *     @type string $mode      Mode.
@@ -60,12 +60,29 @@
 }
 
 /**
+ * Returns the stored setting value for Speculative Loading configuration.
+ *
+ * @since n.e.x.t
+ *
+ * @return array{ mode: 'prefetch'|'prerender', eagerness: 'conservative'|'moderate'|'eager' } {
+ *     Stored setting value.
+ *
+ *     @type string $mode      Mode.
+ *     @type string $eagerness Eagerness.
+ * }
+ */
+function plsr_get_stored_setting_value(): array {
+	return plsr_sanitize_setting( get_option( 'plsr_speculation_rules' ) );
+}
+
+/**
  * Sanitizes the setting for Speculative Loading configuration.
  *
  * @since 1.0.0
+ * @todo  Consider whether the JSON schema for the setting could be reused here.
  *
  * @param mixed $input Setting to sanitize.
- * @return array<string, string> {
+ * @return array{ mode: 'prefetch'|'prerender', eagerness: 'conservative'|'moderate'|'eager' } {
  *     Sanitized setting.
  *
  *     @type string $mode      Mode.
@@ -79,17 +96,14 @@
 		return $default_value;
 	}
 
-	$mode_labels      = plsr_get_mode_labels();
-	$eagerness_labels = plsr_get_eagerness_labels();
-
 	// Ensure only valid keys are present.
-	$value = array_intersect_key( $input, $default_value );
+	$value = array_intersect_key( array_merge( $default_value, $input ), $default_value );
 
-	// Set any missing or invalid values to their defaults.
-	if ( ! isset( $value['mode'] ) || ! isset( $mode_labels[ $value['mode'] ] ) ) {
+	// Constrain values to what is allowed.
+	if ( ! in_array( $value['mode'], array_keys( plsr_get_mode_labels() ), true ) ) {
 		$value['mode'] = $default_value['mode'];
 	}
-	if ( ! isset( $value['eagerness'] ) || ! isset( $eagerness_labels[ $value['eagerness'] ] ) ) {
+	if ( ! in_array( $value['eagerness'], array_keys( plsr_get_eagerness_labels() ), true ) ) {
 		$value['eagerness'] = $default_value['eagerness'];
 	}
 
@@ -113,7 +127,8 @@
 			'default'           => plsr_get_setting_default(),
 			'show_in_rest'      => array(
 				'schema' => array(
-					'properties' => array(
+					'type'                 => 'object',
+					'properties'           => array(
 						'mode'      => array(
 							'description' => __( 'Whether to prefetch or prerender URLs.', 'speculation-rules' ),
 							'type'        => 'string',
@@ -125,6 +140,7 @@
 							'enum'        => array_keys( plsr_get_eagerness_labels() ),
 						),
 					),
+					'additionalProperties' => false,
 				),
 			),
 		)
@@ -188,7 +204,7 @@
  * @since 1.0.0
  * @access private
  *
- * @param array<string, string> $args {
+ * @param array{ field: 'mode'|'eagerness', title: non-empty-string, description: non-empty-string } $args {
  *     Associative array of arguments.
  *
  *     @type string $field       The slug of the sub setting controlled by the field.
@@ -197,28 +213,24 @@
  * }
  */
 function plsr_render_settings_field( array $args ): void {
-	if ( empty( $args['field'] ) || empty( $args['title'] ) ) { // Invalid.
-		return;
-	}
+	$option = plsr_get_stored_setting_value();
 
-	$option = get_option( 'plsr_speculation_rules' );
-	if ( ! isset( $option[ $args['field'] ] ) ) { // Invalid.
-		return;
+	switch ( $args['field'] ) {
+		case 'mode':
+			$choices = plsr_get_mode_labels();
+			break;
+		case 'eagerness':
+			$choices = plsr_get_eagerness_labels();
+			break;
+		default:
+			return; // Invalid (and this case should never occur).
 	}
 
-	$value    = $option[ $args['field'] ];
-	$callback = "plsr_get_{$args['field']}_labels";
-	if ( ! is_callable( $callback ) ) {
-		return;
-	}
-	$choices = call_user_func( $callback );
-
+	$value = $option[ $args['field'] ];
 	?>
 	<fieldset>
 		<legend class="screen-reader-text"><?php echo esc_html( $args['title'] ); ?></legend>
-		<?php
-		foreach ( $choices as $slug => $label ) {
-			?>
+		<?php foreach ( $choices as $slug => $label ) : ?>
 			<p>
 				<label>
 					<input
@@ -230,17 +242,11 @@
 					<?php echo esc_html( $label ); ?>
 				</label>
 			</p>
-			<?php
-		}
+		<?php endforeach; ?>
 
-		if ( ! empty( $args['description'] ) ) {
-			?>
-			<p class="description" style="max-width: 800px;">
-				<?php echo esc_html( $args['description'] ); ?>
-			</p>
-			<?php
-		}
-		?>
+		<p class="description" style="max-width: 800px;">
+			<?php echo esc_html( $args['description'] ); ?>
+		</p>
 	</fieldset>
 	<?php
 }

web-worker-offloading

Important

Stable tag change: 0.1.1 → 0.2.0

svn status:

M       build/debug/partytown-atomics.js
M       build/debug/partytown-media.js
M       build/debug/partytown-sandbox-sw.js
M       build/debug/partytown-sw.js
M       build/debug/partytown-ww-atomics.js
M       build/debug/partytown-ww-sw.js
M       build/debug/partytown.js
M       build/partytown-atomics.js
M       build/partytown-media.js
M       build/partytown-sw.js
M       build/partytown.js
M       hooks.php
M       load.php
M       readme.txt
?       third-party/google-site-kit.php
?       third-party/seo-by-rank-math.php
M       third-party/woocommerce.php
M       third-party.php
svn diff

Large diffs omitted:

M       build/debug/partytown-atomics.js
M       build/debug/partytown-media.js
M       build/debug/partytown-sandbox-sw.js
M       build/debug/partytown-sw.js
M       build/debug/partytown-ww-atomics.js
M       build/debug/partytown-ww-sw.js
M       build/debug/partytown.js
M       build/partytown-atomics.js
M       build/partytown-media.js
M       build/partytown-sw.js
M       build/partytown.js
Index: hooks.php
===================================================================
--- hooks.php	(revision 3209553)
+++ hooks.php	(working copy)
@@ -21,7 +21,13 @@
 function plwwo_register_default_scripts( WP_Scripts $scripts ): void {
 	// The source code for partytown.js is built from <https://github.com/BuilderIO/partytown/blob/b292a14047a0c12ca05ba97df1833935d42fdb66/src/lib/main/snippet.ts>.
 	// See webpack config in the WordPress/performance repo: <https://github.com/WordPress/performance/blob/282a068f3eb2575d37aeb9034e894e7140fcddca/webpack.config.js#L84-L130>.
-	$partytown_js = file_get_contents( __DIR__ . '/build/partytown.js' ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- It's a local filesystem path not a remote request.
+	if ( defined( 'SCRIPT_DEBUG' ) && SCRIPT_DEBUG ) {
+		$partytown_js_path = '/build/debug/partytown.js';
+	} else {
+		$partytown_js_path = '/build/partytown.js';
+	}
+
+	$partytown_js = file_get_contents( __DIR__ . $partytown_js_path ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- It's a local filesystem path not a remote request.
 	if ( false === $partytown_js ) {
 		return;
 	}
Index: load.php
===================================================================
--- load.php	(revision 3209553)
+++ load.php	(working copy)
@@ -3,9 +3,9 @@
  * Plugin Name: Web Worker Offloading
  * Plugin URI: https://github.com/WordPress/performance/issues/176
  * Description: Offloads select JavaScript execution to a Web Worker to reduce work on the main thread and improve the Interaction to Next Paint (INP) metric.
- * Requires at least: 6.5
+ * Requires at least: 6.6
  * Requires PHP: 7.2
- * Version: 0.1.1
+ * Version: 0.2.0
  * Author: WordPress Performance Team
  * Author URI: https://make.wordpress.org/performance/
  * License: GPLv2 or later
@@ -43,7 +43,7 @@
 	);
 }
 
-define( 'WEB_WORKER_OFFLOADING_VERSION', '0.1.1' );
+define( 'WEB_WORKER_OFFLOADING_VERSION', '0.2.0' );
 
 require_once __DIR__ . '/helper.php';
 require_once __DIR__ . '/hooks.php';
Index: readme.txt
===================================================================
--- readme.txt	(revision 3209553)
+++ readme.txt	(working copy)
@@ -2,7 +2,7 @@
 
 Contributors: wordpressdotorg
 Tested up to: 6.7
-Stable tag:   0.1.1
+Stable tag:   0.2.0
 License:      GPLv2 or later
 License URI:  https://www.gnu.org/licenses/gpl-2.0.html
 Tags:         performance, JavaScript, web worker, partytown, analytics
@@ -26,10 +26,10 @@
 
 Otherwise, the plugin currently ships with built-in integrations to offload Google Analytics to a web worker for the following plugin:
 
+* [Rank Math SEO](https://wordpress.org/plugins/seo-by-rank-math/)
+* [Site Kit by Google](https://wordpress.org/plugins/google-site-kit/)
 * [WooCommerce](https://wordpress.org/plugins/woocommerce/)
 
-Support for [Site Kit by Google](https://wordpress.org/plugins/google-site-kit/) and [Rank Math SEO](https://wordpress.org/plugins/seo-by-rank-math/) are [planned](https://github.com/WordPress/performance/issues/1455).
-
 Please monitor your analytics once activating to ensure all the expected events are being logged. At the same time, monitor your INP scores to check for improvement.
 
 This plugin relies on the [Partytown 🎉](https://partytown.builder.io/) library by Builder.io, released under the MIT license. This library is in beta and there are quite a few [open bugs](https://github.com/BuilderIO/partytown/issues?q=is%3Aopen+is%3Aissue+label%3Abug).
@@ -94,6 +94,17 @@
 
 == Changelog ==
 
+= 0.2.0 =
+
+**Enhancements**
+
+* Integrate Web Worker Offloading with Google Site Kit. ([1686](https://github.com/WordPress/performance/pull/1686))
+* Integrate Web Worker Offloading with Rank Math SEO. ([1685](https://github.com/WordPress/performance/pull/1685))
+
+**Bug Fixes**
+
+* Fix tracking events like add_to_cart in WooCommerce integration. ([1740](https://github.com/WordPress/performance/pull/1740))
+
 = 0.1.1 =
 
 **Enhancements**
Index: third-party/woocommerce.php
===================================================================
--- third-party/woocommerce.php	(revision 3209553)
+++ third-party/woocommerce.php	(working copy)
@@ -23,18 +23,20 @@
 function plwwo_woocommerce_configure( $configuration ): array {
 	$configuration = (array) $configuration;
 
-	$configuration['mainWindowAccessors'][] = 'wp';   // Because woocommerce-google-analytics-integration needs to access wp.i18n.
-	$configuration['mainWindowAccessors'][] = 'ga4w'; // Because woocommerce-google-analytics-integration needs to access window.ga4w.
-	$configuration['globalFns'][]           = 'gtag'; // Because gtag() is defined in one script and called in another.
-	$configuration['forward'][]             = 'dataLayer.push'; // Because the Partytown integration has this in its example config.
+	$configuration['globalFns'][] = 'gtag'; // Allow calling from other Partytown scripts.
+
+	// Expose on the main tread. See <https://partytown.builder.io/forwarding-event>.
+	$configuration['forward'][] = 'dataLayer.push';
+	$configuration['forward'][] = 'gtag';
+
 	return $configuration;
 }
 add_filter( 'plwwo_configuration', 'plwwo_woocommerce_configure' );
 
 plwwo_mark_scripts_for_offloading(
+	// Note: 'woocommerce-google-analytics-integration' is intentionally not included because for some reason events like add_to_cart don't get tracked.
 	array(
 		'google-tag-manager',
-		'woocommerce-google-analytics-integration',
 		'woocommerce-google-analytics-integration-gtag',
 	)
 );
Index: third-party.php
===================================================================
--- third-party.php	(revision 3209553)
+++ third-party.php	(working copy)
@@ -39,9 +39,13 @@
  */
 function plwwo_load_third_party_integrations(): void {
 	$plugins_with_integrations = array(
-		// TODO: google-site-kit.
-		// TODO: seo-by-rank-math.
-		'woocommerce' => static function (): bool {
+		'google-site-kit'  => static function (): bool {
+			return defined( 'GOOGLESITEKIT_VERSION' );
+		},
+		'seo-by-rank-math' => static function (): bool {
+			return class_exists( 'RankMath' );
+		},
+		'woocommerce'      => static function (): bool {
 			// See <https://woocommerce.com/document/query-whether-woocommerce-is-activated/>.
 			return class_exists( 'WooCommerce' );
 		},

webp-uploads

Important

Stable tag change: 2.3.0 → 2.4.0

svn status:

M       helper.php
M       hooks.php
M       load.php
M       readme.txt
M       settings.php
M       uninstall.php
svn diff
Index: helper.php
===================================================================
--- helper.php	(revision 3209553)
+++ helper.php	(working copy)
@@ -28,7 +28,7 @@
 
 	$default_transforms = array(
 		'image/jpeg' => array( 'image/' . $output_format ),
-		'image/webp' => array( 'image/webp' ),
+		'image/webp' => array( 'image/' . $output_format ),
 		'image/avif' => array( 'image/avif' ),
 		'image/png'  => array( 'image/' . $output_format ),
 	);
@@ -412,6 +412,17 @@
 }
 
 /**
+ * Checks if the `perflab_generate_all_fallback_sizes` option is enabled.
+ *
+ * @since 2.4.0
+ *
+ * @return bool Whether the option is enabled. Default is false.
+ */
+function webp_uploads_should_generate_all_fallback_sizes(): bool {
+	return (bool) get_option( 'perflab_generate_all_fallback_sizes', 0 );
+}
+
+/**
  * Retrieves the image URL for a specified MIME type from the attachment metadata.
  *
  * This function attempts to locate an alternate image source URL in the
Index: hooks.php
===================================================================
--- hooks.php	(revision 3209553)
+++ hooks.php	(working copy)
@@ -780,3 +780,50 @@
 	}
 }
 add_action( 'init', 'webp_uploads_init' );
+
+/**
+ * Automatically opt into extra image sizes when generating fallback images.
+ *
+ * @since 2.4.0
+ *
+ * @global array $_wp_additional_image_sizes Associative array of additional image sizes.
+ */
+function webp_uploads_opt_in_extra_image_sizes(): void {
+	if ( ! webp_uploads_is_fallback_enabled() ) {
+		return;
+	}
+
+	global $_wp_additional_image_sizes;
+
+	// Modify global to mimic the "hypothetical" WP core API behavior via an additional `add_image_size()` parameter.
+
+	if ( isset( $_wp_additional_image_sizes['1536x1536'] ) && ! isset( $_wp_additional_image_sizes['1536x1536']['provide_additional_mime_types'] ) ) {
+		$_wp_additional_image_sizes['1536x1536']['provide_additional_mime_types'] = true; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
+	}
+
+	if ( isset( $_wp_additional_image_sizes['2048x2048'] ) && ! isset( $_wp_additional_image_sizes['2048x2048']['provide_additional_mime_types'] ) ) {
+		$_wp_additional_image_sizes['2048x2048']['provide_additional_mime_types'] = true; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
+	}
+}
+add_action( 'plugins_loaded', 'webp_uploads_opt_in_extra_image_sizes' );
+
+/**
+ * Enables additional MIME type support for all image sizes based on the generate all fallback sizes settings.
+ *
+ * @since 2.4.0
+ *
+ * @param array<string, bool> $allowed_sizes A map of image size names and whether they are allowed to have additional MIME types.
+ * @return array<string, bool> Modified map of image sizes with additional MIME type support.
+ */
+function webp_uploads_enable_additional_mime_type_support_for_all_sizes( array $allowed_sizes ): array {
+	if ( ! webp_uploads_should_generate_all_fallback_sizes() ) {
+		return $allowed_sizes;
+	}
+
+	foreach ( array_keys( $allowed_sizes ) as $size ) {
+		$allowed_sizes[ $size ] = true;
+	}
+
+	return $allowed_sizes;
+}
+add_filter( 'webp_uploads_image_sizes_with_additional_mime_type_support', 'webp_uploads_enable_additional_mime_type_support_for_all_sizes' );
Index: load.php
===================================================================
--- load.php	(revision 3209553)
+++ load.php	(working copy)
@@ -3,9 +3,9 @@
  * Plugin Name: Modern Image Formats
  * Plugin URI: https://github.com/WordPress/performance/tree/trunk/plugins/webp-uploads
  * Description: Converts images to more modern formats such as WebP or AVIF during upload.
- * Requires at least: 6.5
+ * Requires at least: 6.6
  * Requires PHP: 7.2
- * Version: 2.3.0
+ * Version: 2.4.0
  * Author: WordPress Performance Team
  * Author URI: https://make.wordpress.org/performance/
  * License: GPLv2 or later
@@ -25,7 +25,7 @@
 	return;
 }
 
-define( 'WEBP_UPLOADS_VERSION', '2.3.0' );
+define( 'WEBP_UPLOADS_VERSION', '2.4.0' );
 define( 'WEBP_UPLOADS_MAIN_FILE', plugin_basename( __FILE__ ) );
 
 require_once __DIR__ . '/helper.php';
Index: readme.txt
===================================================================
--- readme.txt	(revision 3209553)
+++ readme.txt	(working copy)
@@ -2,7 +2,7 @@
 
 Contributors: wordpressdotorg
 Tested up to: 6.7
-Stable tag:   2.3.0
+Stable tag:   2.4.0
 License:      GPLv2 or later
 License URI:  https://www.gnu.org/licenses/gpl-2.0.html
 Tags:         performance, images, webp, avif, modern image formats
@@ -60,6 +60,14 @@
 
 == Changelog ==
 
+= 2.4.0 =
+
+**Enhancements**
+
+* Automatically opt into 1536x1536 and 2048x2048 sizes when generating fallback images. ([1679](https://github.com/WordPress/performance/pull/1679))
+* Convert WebP to AVIF on upload. ([1724](https://github.com/WordPress/performance/pull/1724))
+* Enable end user opt-in to generate all sizes in fallback format. ([1689](https://github.com/WordPress/performance/pull/1689))
+
 = 2.3.0 =
 
 **Enhancements**
Index: settings.php
===================================================================
--- settings.php	(revision 3209553)
+++ settings.php	(working copy)
@@ -40,6 +40,18 @@
 			'show_in_rest' => false,
 		)
 	);
+
+	// Add a setting to generate fallback images in all sizes including custom sizes.
+	register_setting(
+		'media',
+		'perflab_generate_all_fallback_sizes',
+		array(
+			'type'         => 'boolean',
+			'default'      => false,
+			'show_in_rest' => false,
+		)
+	);
+
 	// Add a setting to use the picture element.
 	register_setting(
 		'media',
@@ -96,6 +108,16 @@
 		array( 'class' => 'perflab-generate-webp-and-jpeg' )
 	);
 
+	// Add setting field for generating fallback images in all sizes including custom sizes.
+	add_settings_field(
+		'perflab_generate_all_fallback_sizes',
+		__( 'Generate all fallback image sizes', 'webp-uploads' ),
+		'webp_uploads_generate_all_fallback_sizes_callback',
+		'media',
+		'perflab_modern_image_format_settings',
+		array( 'class' => 'perflab-generate-fallback-all-sizes' )
+	);
+
 	// Add picture element support settings field.
 	add_settings_field(
 		'webp_uploads_use_picture_element',
@@ -178,7 +200,95 @@
 	<?php
 }
 
+
 /**
+ * Renders the settings field for generating all fallback image sizes.
+ *
+ * @since 2.4.0
+ */
+function webp_uploads_generate_all_fallback_sizes_callback(): void {
+	$all_fallback_sizes_enabled   = webp_uploads_should_generate_all_fallback_sizes();
+	$fallback_enabled             = webp_uploads_is_fallback_enabled();
+	$all_fallback_sizes_hidden_id = 'perflab_generate_all_fallback_sizes_hidden';
+
+	?>
+	<style>
+		#perflab_generate_all_fallback_sizes_fieldset.disabled label,
+		#perflab_generate_all_fallback_sizes_fieldset.disabled p {
+			opacity: 0.7;
+		}
+	</style>
+	<div id="perflab_generate_all_fallback_sizes_notice" class="notice notice-info inline" <?php echo $fallback_enabled ? 'hidden' : ''; ?>>
+		<p><?php esc_html_e( 'This setting requires fallback image output to be enabled.', 'webp-uploads' ); ?></p>
+	</div>
+	<div id="perflab_generate_all_fallback_sizes_fieldset" class="<?php echo ! $fallback_enabled ? 'disabled' : ''; ?>">
+		<label for="perflab_generate_all_fallback_sizes" id="perflab_generate_all_fallback_sizes_label">
+			<input
+				type="checkbox"
+				id="perflab_generate_all_fallback_sizes"
+				name="perflab_generate_all_fallback_sizes"
+				aria-describedby="perflab_generate_all_fallback_sizes_description"
+				value="1"
+				<?php checked( $all_fallback_sizes_enabled ); ?>
+				<?php disabled( ! $fallback_enabled ); ?>
+			>
+			<?php
+			/*
+			 * If the checkbox is disabled, but the option is enabled, include a hidden input to continue sending the
+			 * same value upon form submission.
+			 */
+			if ( ! $fallback_enabled && $all_fallback_sizes_enabled ) {
+				?>
+				<input
+					type="hidden"
+					id="<?php echo esc_attr( $all_fallback_sizes_hidden_id ); ?>"
+					name="perflab_generate_all_fallback_sizes"
+					value="1"
+				>
+				<?php
+			}
+			esc_html_e( 'Generate all fallback image sizes including custom sizes', 'webp-uploads' );
+			?>
+		</label>
+		<p class="description" id="perflab_generate_all_fallback_sizes_description"><?php esc_html_e( 'Enabling this option will generate all fallback image sizes including custom sizes. Note: uses even more storage space.', 'webp-uploads' ); ?></p>
+	</div>
+	<script>
+	( function ( allFallbackSizesHiddenId ) {
+		const fallbackCheckbox = document.getElementById( 'perflab_generate_webp_and_jpeg' );
+		const allFallbackSizesCheckbox = document.getElementById( 'perflab_generate_all_fallback_sizes' );
+		const allFallbackSizesFieldset = document.getElementById( 'perflab_generate_all_fallback_sizes_fieldset' );
+		const allFallbackSizesNotice = document.getElementById( 'perflab_generate_all_fallback_sizes_notice' );
+
+		function toggleAllFallbackSizes() {
+			const fallbackEnabled = fallbackCheckbox.checked;
+			allFallbackSizesFieldset.classList.toggle( 'disabled', ! fallbackEnabled );
+			allFallbackSizesCheckbox.disabled = ! fallbackEnabled;
+			allFallbackSizesNotice.hidden = fallbackEnabled;
+
+			// Remove or inject hidden input to preserve original setting value as needed.
+			if ( fallbackEnabled ) {
+				const hiddenInput = document.getElementById( allFallbackSizesHiddenId );
+				if ( hiddenInput ) {
+					hiddenInput.parentElement.removeChild( hiddenInput );
+				}
+			} else if ( allFallbackSizesCheckbox.checked && ! document.getElementById( allFallbackSizesHiddenId ) ) {
+				// The hidden input is only needed if the value was originally set (i.e., the checkbox enabled).
+				const hiddenInput = document.createElement( 'input' );
+				hiddenInput.type = 'hidden';
+				hiddenInput.id = allFallbackSizesHiddenId;
+				hiddenInput.name = allFallbackSizesCheckbox.name;
+				hiddenInput.value = allFallbackSizesCheckbox.value;
+				allFallbackSizesCheckbox.parentElement.insertBefore( hiddenInput, allFallbackSizesCheckbox.nextSibling );
+			}
+		}
+
+		fallbackCheckbox.addEventListener( 'change', toggleAllFallbackSizes );
+	} )( <?php echo wp_json_encode( $all_fallback_sizes_hidden_id ); ?> );
+	</script>
+	<?php
+}
+
+/**
  * Renders the settings field for the 'webp_uploads_use_picture_element' setting.
  *
  * @since 2.0.0
Index: uninstall.php
===================================================================
--- uninstall.php	(revision 3209553)
+++ uninstall.php	(working copy)
@@ -38,4 +38,5 @@
  */
 function webp_uploads_delete_plugin_option(): void {
 	delete_option( 'perflab_generate_webp_and_jpeg' );
+	delete_option( 'perflab_generate_all_fallback_sizes' );
 }

Copy link

github-actions bot commented Dec 17, 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 <[email protected]>
Co-authored-by: mukeshpanchal27 <[email protected]>
Co-authored-by: felixarntz <[email protected]>

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

@mukeshpanchal27
Copy link
Member

@westonruter The commit c4cc9e8 is also part of PR #1666. Once this PR is committed and merged, we’ll need to update PR #1666 accordingly.

@westonruter
Copy link
Member Author

A new round of builds for testing:

  1. webp-uploads.zip
  2. auto-sizes.zip
  3. dominant-color-images.zip (updated)
  4. embed-optimizer.zip
  5. image-prioritizer.zip
  6. optimization-detective.zip
  7. performance-lab.zip
  8. web-worker-offloading.zip

@westonruter westonruter merged commit 3b786fc into release/3.7.0 Dec 18, 2024
16 checks passed
@westonruter westonruter deleted the publish/3.7.0 branch December 18, 2024 18:08
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Infrastructure Issues for the overall performance plugin infrastructure skip changelog PRs that should not be mentioned in changelogs [Type] Documentation Documentation to be added or enhanced
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants