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.8.0 release #1827

Open
wants to merge 5 commits into
base: release/3.8.0
Choose a base branch
from
Open

Prepare 3.8.0 release #1827

wants to merge 5 commits into from

Conversation

westonruter
Copy link
Member

@westonruter westonruter commented Jan 25, 2025

Fixes #1796.
Previously #1754.

  • Bump plugin versions
  • Run npm run since
  • Remove -beta1 from 1.0.0 since tags
  • Generate changelogs

The following plugins are included in this release:

  1. speculation-rules: v1.4.0
  2. optimization-detective: v1.0.0-beta1
  3. webp-uploads: v2.5.0
  4. embed-optimizer: v0.4.1
  5. image-prioritizer: v0.3.1
  6. performance-lab: v3.8.0

@westonruter westonruter added this to the performance-lab 3.8.0 milestone Jan 25, 2025
@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 Jan 25, 2025
Copy link

codecov bot commented Jan 25, 2025

Codecov Report

Attention: Patch coverage is 0% with 6 lines in your changes missing coverage. Please review.

Project coverage is 65.86%. Comparing base (e2effe6) to head (9dbf16d).

Files with missing lines Patch % Lines
plugins/embed-optimizer/load.php 0.00% 1 Missing ⚠️
plugins/image-prioritizer/load.php 0.00% 1 Missing ⚠️
plugins/optimization-detective/load.php 0.00% 1 Missing ⚠️
plugins/performance-lab/load.php 0.00% 1 Missing ⚠️
plugins/speculation-rules/load.php 0.00% 1 Missing ⚠️
plugins/webp-uploads/load.php 0.00% 1 Missing ⚠️
Additional details and impacted files
@@              Coverage Diff               @@
##           release/3.8.0    #1827   +/-   ##
==============================================
  Coverage          65.86%   65.86%           
==============================================
  Files                 88       88           
  Lines               6855     6855           
==============================================
  Hits                4515     4515           
  Misses              2340     2340           
Flag Coverage Δ
multisite 65.86% <0.00%> (ø)
single 38.45% <0.00%> (ø)

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

@westonruter
Copy link
Member Author

westonruter commented Jan 25, 2025

@westonruter
Copy link
Member Author

westonruter commented Jan 25, 2025

Pending release diffs (including #1826):

auto-sizes

Warning

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

svn status:

M       auto-sizes.php
M       hooks.php
svn diff
Index: auto-sizes.php
===================================================================
--- auto-sizes.php	(revision 3228614)
+++ auto-sizes.php	(working copy)
@@ -15,10 +15,11 @@
  * @package auto-sizes
  */
 
-// Exit if accessed directly.
+// @codeCoverageIgnoreStart
 if ( ! defined( 'ABSPATH' ) ) {
-	exit;
+	exit; // Exit if accessed directly.
 }
+// @codeCoverageIgnoreEnd
 
 // Define the constant.
 if ( defined( 'IMAGE_AUTO_SIZES_VERSION' ) ) {
Index: hooks.php
===================================================================
--- hooks.php	(revision 3228614)
+++ hooks.php	(working copy)
@@ -6,9 +6,11 @@
  * @since 1.0.0
  */
 
+// @codeCoverageIgnoreStart
 if ( ! defined( 'ABSPATH' ) ) {
 	exit; // Exit if accessed directly.
 }
+// @codeCoverageIgnoreEnd
 
 /**
  * Displays the HTML generator tag for the plugin.

dominant-color-images

Warning

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

svn status:

M       hooks.php
M       load.php
svn diff
Index: hooks.php
===================================================================
--- hooks.php	(revision 3228614)
+++ hooks.php	(working copy)
@@ -7,9 +7,11 @@
  * @since 1.0.0
  */
 
+// @codeCoverageIgnoreStart
 if ( ! defined( 'ABSPATH' ) ) {
 	exit; // Exit if accessed directly.
 }
+// @codeCoverageIgnoreEnd
 
 /**
  * Add the dominant color metadata to the attachment.
Index: load.php
===================================================================
--- load.php	(revision 3228614)
+++ load.php	(working copy)
@@ -15,10 +15,11 @@
  * @package dominant-color-images
  */
 
-// Exit if accessed directly.
+// @codeCoverageIgnoreStart
 if ( ! defined( 'ABSPATH' ) ) {
-	exit;
+	exit; // Exit if accessed directly.
 }
+// @codeCoverageIgnoreEnd
 
 // Define required constants.
 if ( defined( 'DOMINANT_COLOR_IMAGES_VERSION' ) ) {

embed-optimizer

Important

Stable tag change: 0.4.0 → 0.4.1

svn status:

M       class-embed-optimizer-tag-visitor.php
M       detect.js
M       hooks.php
M       load.php
M       readme.txt
svn diff
Index: class-embed-optimizer-tag-visitor.php
===================================================================
--- class-embed-optimizer-tag-visitor.php	(revision 3228614)
+++ class-embed-optimizer-tag-visitor.php	(working copy)
@@ -6,10 +6,11 @@
  * @since 0.2.0
  */
 
-// Exit if accessed directly.
+// @codeCoverageIgnoreStart
 if ( ! defined( 'ABSPATH' ) ) {
-	exit;
+	exit; // Exit if accessed directly.
 }
+// @codeCoverageIgnoreEnd
 
 /**
  * Tag visitor that optimizes embeds.
@@ -26,7 +27,7 @@
 	 *
 	 * @var bool
 	 */
-	protected $added_lazy_script = false;
+	private $added_lazy_script = false;
 
 	/**
 	 * Determines whether the processor is currently at a figure.wp-block-embed tag.
@@ -103,97 +104,9 @@
 		}
 
 		$this->reduce_layout_shifts( $context );
+		$this->add_preconnect_links( $context );
+		$this->lazy_load_embeds( $context );
 
-		// 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
-		) {
-			$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.
-				 * See <https://colab.sandbox.google.com/drive/1nSpg3qoCLY-cBTV2zOUkgUCU7R7X2f_R?resourcekey=0-MgT7Ur0pT__vw-5_AHjgWQ#scrollTo=utZv59sXzXvS>.
-				 * The list of hosts being preconnected to was obtained by inserting an embed into a post and then looking
-				 * at the network log on the frontend as the embed renders. Each should include the host of the iframe src
-				 * as well as URLs for assets used by the embed, _if_ the URL looks like it is not geotargeted (e.g. '-us')
-				 * or load-balanced (e.g. 's0.example.com'). For the load balancing case, attempt to load the asset by
-				 * incrementing the number appearing in the subdomain (e.g. s1.example.com). If the asset still loads, then
-				 * it is a likely case of a load balancing domain name which cannot be safely preconnected since it could
-				 * not end up being the load balanced domain used for the embed. Lastly, these domains are only for the URLs
-				 * for GET requests, as POST requests are not likely to be part of the critical rendering path.
-				 */
-				$preconnect_hrefs = array();
-				$has_class        = static function ( string $wanted_class ) use ( $processor ): bool {
-					return true === $processor->has_class( $wanted_class );
-				};
-				if ( $has_class( 'wp-block-embed-youtube' ) ) {
-					$preconnect_hrefs[] = 'https://www.youtube.com';
-					$preconnect_hrefs[] = 'https://i.ytimg.com';
-				} elseif ( $has_class( 'wp-block-embed-twitter' ) ) {
-					$preconnect_hrefs[] = 'https://syndication.twitter.com';
-					$preconnect_hrefs[] = 'https://pbs.twimg.com';
-				} elseif ( $has_class( 'wp-block-embed-vimeo' ) ) {
-					$preconnect_hrefs[] = 'https://player.vimeo.com';
-					$preconnect_hrefs[] = 'https://f.vimeocdn.com';
-					$preconnect_hrefs[] = 'https://i.vimeocdn.com';
-				} elseif ( $has_class( 'wp-block-embed-spotify' ) ) {
-					$preconnect_hrefs[] = 'https://apresolve.spotify.com';
-					$preconnect_hrefs[] = 'https://embed-cdn.spotifycdn.com';
-					$preconnect_hrefs[] = 'https://encore.scdn.co';
-					$preconnect_hrefs[] = 'https://i.scdn.co';
-				} elseif ( $has_class( 'wp-block-embed-videopress' ) || $has_class( 'wp-block-embed-wordpress-tv' ) ) {
-					$preconnect_hrefs[] = 'https://video.wordpress.com';
-					$preconnect_hrefs[] = 'https://public-api.wordpress.com';
-					$preconnect_hrefs[] = 'https://videos.files.wordpress.com';
-					$preconnect_hrefs[] = 'https://v0.wordpress.com'; // This does not appear to be a load-balanced domain since v1.wordpress.com is not valid.
-				} elseif ( $has_class( 'wp-block-embed-instagram' ) ) {
-					$preconnect_hrefs[] = 'https://www.instagram.com';
-					$preconnect_hrefs[] = 'https://static.cdninstagram.com';
-					$preconnect_hrefs[] = 'https://scontent.cdninstagram.com';
-				} elseif ( $has_class( 'wp-block-embed-tiktok' ) ) {
-					$preconnect_hrefs[] = 'https://www.tiktok.com';
-					// Note: The other domains used for TikTok embeds include https://lf16-tiktok-web.tiktokcdn-us.com,
-					// https://lf16-cdn-tos.tiktokcdn-us.com, and https://lf16-tiktok-common.tiktokcdn-us.com among others
-					// which either appear to be geo-targeted ('-us') _or_ load-balanced ('lf16'). So these are not added
-					// to the preconnected hosts.
-				} elseif ( $has_class( 'wp-block-embed-amazon' ) ) {
-					$preconnect_hrefs[] = 'https://read.amazon.com';
-					$preconnect_hrefs[] = 'https://m.media-amazon.com';
-				} elseif ( $has_class( 'wp-block-embed-soundcloud' ) ) {
-					$preconnect_hrefs[] = 'https://w.soundcloud.com';
-					$preconnect_hrefs[] = 'https://widget.sndcdn.com';
-					// Note: There is also https://i1.sndcdn.com which is for the album art, but the '1' indicates it may be geotargeted/load-balanced.
-				} elseif ( $has_class( 'wp-block-embed-pinterest' ) ) {
-					$preconnect_hrefs[] = 'https://assets.pinterest.com';
-					$preconnect_hrefs[] = 'https://widgets.pinterest.com';
-					$preconnect_hrefs[] = 'https://i.pinimg.com';
-				}
-
-				foreach ( $preconnect_hrefs as $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' ) ) );
-				$this->added_lazy_script = true;
-			}
-		}
-
 		/*
 		 * At this point the tag is a figure.wp-block-embed, and we can return false because this does not need to be
 		 * measured and stored in URL Metrics. Only the child div.wp-block-embed__wrapper tag is measured and stored
@@ -208,8 +121,8 @@
 	 *
 	 * @since 0.3.0
 	 *
-	 * @param string $embed_block_xpath XPath for the embed block FIGURE tag. For example: `/*[1][self::HTML]/*[2][self::BODY]/*[1][self::FIGURE]`.
-	 * @return string XPath for the child DIV. For example: `/*[1][self::HTML]/*[2][self::BODY]/*[1][self::FIGURE]/*[1][self::DIV]`
+	 * @param string $embed_block_xpath XPath for the embed block FIGURE tag. For example: `/HTML/BODY/DIV/*[1][self::FIGURE]`.
+	 * @return string XPath for the child DIV. For example: `/HTML/BODY/DIV/*[1][self::FIGURE]/*[1][self::DIV]`
 	 */
 	private static function get_embed_wrapper_xpath( string $embed_block_xpath ): string {
 		return $embed_block_xpath . '/*[1][self::DIV]';
@@ -283,4 +196,131 @@
 			$processor->append_head_html( sprintf( "<style>\n%s\n</style>\n", join( "\n", $style_rules ) ) );
 		}
 	}
+
+	/**
+	 * Gets preconnect URLs based on embed type.
+	 *
+	 * The following embeds have been chosen for optimization due to their relative popularity among all embed types.
+	 * The list of hosts being preconnected to was obtained by inserting an embed into a post and then looking
+	 * at the network log on the frontend as the embed renders. Each should include the host of the iframe src
+	 * as well as URLs for assets used by the embed, _if_ the URL looks like it is not geotargeted (e.g. '-us')
+	 * or load-balanced (e.g. 's0.example.com'). For the load balancing case, attempt to load the asset by
+	 * incrementing the number appearing in the subdomain (e.g. s1.example.com). If the asset still loads, then
+	 * it is a likely case of a load balancing domain name which cannot be safely preconnected since it could
+	 * not end up being the load balanced domain used for the embed. Lastly, these domains are only for the URLs
+	 * for GET requests, as POST requests are not likely to be part of the critical rendering path.
+	 *
+	 * @since 0.4.1
+	 *
+	 * @param OD_HTML_Tag_Processor $processor Processor, with the cursor currently at an embed block.
+	 * @return array<non-empty-string> Array of URLs to preconnect to.
+	 */
+	private function get_preconnect_urls( OD_HTML_Tag_Processor $processor ): array {
+		$urls      = array();
+		$has_class = static function ( string $wanted_class ) use ( $processor ): bool {
+			return true === $processor->has_class( $wanted_class );
+		};
+
+		if ( $has_class( 'wp-block-embed-youtube' ) ) {
+			$urls[] = 'https://www.youtube.com';
+			$urls[] = 'https://i.ytimg.com';
+		} elseif ( $has_class( 'wp-block-embed-twitter' ) ) {
+			$urls[] = 'https://syndication.twitter.com';
+			$urls[] = 'https://pbs.twimg.com';
+		} elseif ( $has_class( 'wp-block-embed-vimeo' ) ) {
+			$urls[] = 'https://player.vimeo.com';
+			$urls[] = 'https://f.vimeocdn.com';
+			$urls[] = 'https://i.vimeocdn.com';
+		} elseif ( $has_class( 'wp-block-embed-spotify' ) ) {
+			$urls[] = 'https://apresolve.spotify.com';
+			$urls[] = 'https://embed-cdn.spotifycdn.com';
+			$urls[] = 'https://encore.scdn.co';
+			$urls[] = 'https://i.scdn.co';
+		} elseif ( $has_class( 'wp-block-embed-videopress' ) || $has_class( 'wp-block-embed-wordpress-tv' ) ) {
+			$urls[] = 'https://video.wordpress.com';
+			$urls[] = 'https://public-api.wordpress.com';
+			$urls[] = 'https://videos.files.wordpress.com';
+			$urls[] = 'https://v0.wordpress.com'; // This does not appear to be a load-balanced domain since v1.wordpress.com is not valid.
+		} elseif ( $has_class( 'wp-block-embed-instagram' ) ) {
+			$urls[] = 'https://www.instagram.com';
+			$urls[] = 'https://static.cdninstagram.com';
+			$urls[] = 'https://scontent.cdninstagram.com';
+		} elseif ( $has_class( 'wp-block-embed-tiktok' ) ) {
+			$urls[] = 'https://www.tiktok.com';
+			// Note: The other domains used for TikTok embeds include https://lf16-tiktok-web.tiktokcdn-us.com,
+			// https://lf16-cdn-tos.tiktokcdn-us.com, and https://lf16-tiktok-common.tiktokcdn-us.com among others
+			// which either appear to be geo-targeted ('-us') _or_ load-balanced ('lf16'). So these are not added
+			// to the preconnected hosts.
+		} elseif ( $has_class( 'wp-block-embed-amazon' ) ) {
+			$urls[] = 'https://read.amazon.com';
+			$urls[] = 'https://m.media-amazon.com';
+		} elseif ( $has_class( 'wp-block-embed-soundcloud' ) ) {
+			$urls[] = 'https://w.soundcloud.com';
+			$urls[] = 'https://widget.sndcdn.com';
+			// Note: There is also https://i1.sndcdn.com which is for the album art, but the '1' indicates it may be geotargeted/load-balanced.
+		} elseif ( $has_class( 'wp-block-embed-pinterest' ) ) {
+			$urls[] = 'https://assets.pinterest.com';
+			$urls[] = 'https://widgets.pinterest.com';
+			$urls[] = 'https://i.pinimg.com';
+		}
+
+		return $urls;
+	}
+
+	/**
+	 * Adds preconnect links for embed resources.
+	 *
+	 * @since 0.4.1
+	 *
+	 * @param OD_Tag_Visitor_Context $context Tag visitor context, with the cursor currently at an embed block.
+	 */
+	private function add_preconnect_links( OD_Tag_Visitor_Context $context ): void {
+		$processor           = $context->processor;
+		$embed_wrapper_xpath = self::get_embed_wrapper_xpath( $processor->get_xpath() );
+
+		foreach ( $this->get_preconnect_urls( $processor ) as $preconnect_url ) {
+			foreach ( $context->url_metric_group_collection as $group ) {
+				if ( $group->get_element_max_intersection_ratio( $embed_wrapper_xpath ) < PHP_FLOAT_EPSILON ) {
+					continue;
+				}
+
+				$context->link_collection->add_link(
+					array(
+						'rel'  => 'preconnect',
+						'href' => $preconnect_url,
+					),
+					$group->get_minimum_viewport_width(),
+					$group->get_maximum_viewport_width()
+				);
+			}
+		}
+	}
+
+	/**
+	 * Optimizes an embed based on whether it is displayed in any initial viewport.
+	 *
+	 * @since 0.4.1
+	 *
+	 * @param OD_Tag_Visitor_Context $context Tag visitor context, with the cursor currently at an embed block.
+	 */
+	private function lazy_load_embeds( 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;
+		}
+
+		$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 < PHP_FLOAT_EPSILON && 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' ) ) );
+			$this->added_lazy_script = true;
+		}
+	}
 }
Index: detect.js
===================================================================
--- detect.js	(revision 3228614)
+++ detect.js	(working copy)
@@ -57,7 +57,7 @@
 		'.wp-block-embed > .wp-block-embed__wrapper[data-od-xpath]'
 	);
 
-	for ( const embedWrapper of embedWrappers ) {
+	for ( /** @type {HTMLElement} */ const embedWrapper of embedWrappers ) {
 		monitorEmbedWrapperForResizes( embedWrapper, isDebug );
 	}
 
Index: hooks.php
===================================================================
--- hooks.php	(revision 3228614)
+++ hooks.php	(working copy)
@@ -6,9 +6,11 @@
  * @package embed-optimizer
  */
 
+// @codeCoverageIgnoreStart
 if ( ! defined( 'ABSPATH' ) ) {
 	exit; // Exit if accessed directly.
 }
+// @codeCoverageIgnoreEnd
 
 /**
  * Add hooks.
@@ -121,7 +123,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__ ) . embed_optimizer_get_asset_path( 'detect.js' ) );
+	$extension_module_urls[] = plugins_url( add_query_arg( 'ver', EMBED_OPTIMIZER_VERSION, embed_optimizer_get_asset_path( 'detect.js' ) ), __FILE__ );
 	return $extension_module_urls;
 }
 
Index: load.php
===================================================================
--- load.php	(revision 3228614)
+++ load.php	(working copy)
@@ -5,7 +5,7 @@
  * 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.4.0
+ * Version: 0.4.1
  * Author: WordPress Performance Team
  * Author URI: https://make.wordpress.org/performance/
  * License: GPLv2 or later
@@ -15,10 +15,11 @@
  * @package embed-optimizer
  */
 
-// Exit if accessed directly.
+// @codeCoverageIgnoreStart
 if ( ! defined( 'ABSPATH' ) ) {
-	exit;
+	exit; // Exit if accessed directly.
 }
+// @codeCoverageIgnoreEnd
 
 (
 	/**
@@ -70,7 +71,7 @@
 	}
 )(
 	'embed_optimizer_pending_plugin',
-	'0.4.0',
+	'0.4.1',
 	static function ( string $version ): void {
 		if ( defined( 'EMBED_OPTIMIZER_VERSION' ) ) {
 			return;
Index: readme.txt
===================================================================
--- readme.txt	(revision 3228614)
+++ readme.txt	(working copy)
@@ -2,7 +2,7 @@
 
 Contributors: wordpressdotorg
 Tested up to: 6.7
-Stable tag:   0.4.0
+Stable tag:   0.4.1
 License:      GPLv2 or later
 License URI:  https://www.gnu.org/licenses/gpl-2.0.html
 Tags:         performance, embeds
@@ -19,7 +19,7 @@
 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.
+**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.)
 
@@ -29,9 +29,9 @@
 
 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.
+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 a Classic Block.
 
-There are currently **no settings** and no user interface for this plugin since it is designed to work without any configuration.
+Your site must have the **REST API accessible** to unauthenticated frontend visitors since this is how metrics are collected about how a page should be optimized. There are currently **no settings** and no user interface for this plugin since it is designed to work without any configuration.
 
 == Installation ==
 
@@ -67,6 +67,12 @@
 
 == Changelog ==
 
+= 0.4.1 =
+
+**Bug Fixes**
+
+* Remove requirement for both mobile and desktop URL metrics to be collected for `preconnect` links to be added. ([1764](https://github.com/WordPress/performance/pull/1764))
+
 = 0.4.0 =
 
 **Enhancements**

image-prioritizer

Important

Stable tag change: 0.3.0 → 0.3.1

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
M       detect.js
M       detect.min.js
M       helper.php
M       hooks.php
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 3228614)
+++ class-image-prioritizer-background-image-styled-tag-visitor.php	(working copy)
@@ -6,10 +6,11 @@
  * @since 0.1.0
  */
 
-// Exit if accessed directly.
+// @codeCoverageIgnoreStart
 if ( ! defined( 'ABSPATH' ) ) {
-	exit;
+	exit; // Exit if accessed directly.
 }
+// @codeCoverageIgnoreEnd
 
 /**
  * Tag visitor that optimizes elements with background-image styles.
Index: class-image-prioritizer-img-tag-visitor.php
===================================================================
--- class-image-prioritizer-img-tag-visitor.php	(revision 3228614)
+++ class-image-prioritizer-img-tag-visitor.php	(working copy)
@@ -6,10 +6,11 @@
  * @since 0.1.0
  */
 
-// Exit if accessed directly.
+// @codeCoverageIgnoreStart
 if ( ! defined( 'ABSPATH' ) ) {
-	exit;
+	exit; // Exit if accessed directly.
 }
+// @codeCoverageIgnoreEnd
 
 /**
  * Tag visitor that optimizes IMG tags.
Index: class-image-prioritizer-tag-visitor.php
===================================================================
--- class-image-prioritizer-tag-visitor.php	(revision 3228614)
+++ class-image-prioritizer-tag-visitor.php	(working copy)
@@ -6,10 +6,11 @@
  * @since 0.1.0
  */
 
-// Exit if accessed directly.
+// @codeCoverageIgnoreStart
 if ( ! defined( 'ABSPATH' ) ) {
-	exit;
+	exit; // Exit if accessed directly.
 }
+// @codeCoverageIgnoreEnd
 
 /**
  * Tag visitor that optimizes image tags.
Index: class-image-prioritizer-video-tag-visitor.php
===================================================================
--- class-image-prioritizer-video-tag-visitor.php	(revision 3228614)
+++ class-image-prioritizer-video-tag-visitor.php	(working copy)
@@ -8,10 +8,11 @@
  * @since 0.2.0
  */
 
-// Exit if accessed directly.
+// @codeCoverageIgnoreStart
 if ( ! defined( 'ABSPATH' ) ) {
-	exit;
+	exit; // Exit if accessed directly.
 }
+// @codeCoverageIgnoreEnd
 
 /**
  * Image Prioritizer: Image_Prioritizer_Video_Tag_Visitor class
Index: detect.js
===================================================================
--- detect.js	(revision 3228614)
+++ detect.js	(working copy)
@@ -44,18 +44,6 @@
 }
 
 /**
- * Logs a warning.
- *
- * @since 0.3.0
- *
- * @param {...*} message
- */
-function warn( ...message ) {
-	// eslint-disable-next-line no-console
-	console.warn( consoleLogPrefix, ...message );
-}
-
-/**
  * Initializes extension.
  *
  * @since 0.3.0
@@ -78,27 +66,6 @@
 }
 
 /**
- * Gets the performance resource entry for a given URL.
- *
- * @since 0.3.0
- *
- * @param {string} url - Resource URL.
- * @return {PerformanceResourceTiming|null} Resource entry or null.
- */
-function getPerformanceResourceByURL( url ) {
-	const entries =
-		/** @type PerformanceResourceTiming[] */ performance.getEntriesByType(
-			'resource'
-		);
-	for ( const entry of entries ) {
-		if ( entry.name === url ) {
-			return entry;
-		}
-	}
-	return null;
-}
-
-/**
  * Handles a new LCP metric being reported.
  *
  * @since 0.3.0
@@ -129,21 +96,6 @@
 			continue;
 		}
 
-		// Now only consider proceeding with the URL if its loading was initiated with stylesheet or preload link.
-		const resourceEntry = getPerformanceResourceByURL( entry.url );
-		if (
-			! resourceEntry ||
-			! [ 'css', 'link' ].includes( resourceEntry.initiatorType )
-		) {
-			if ( isDebug ) {
-				warn(
-					`Skipped considering URL (${ entry.url }) due to unexpected performance resource timing entry:`,
-					resourceEntry
-				);
-			}
-			return;
-		}
-
 		// Skip URLs that are excessively long. This is the maxLength defined in image_prioritizer_add_element_item_schema_properties().
 		if ( entry.url.length > 500 ) {
 			if ( isDebug ) {
Index: detect.min.js
===================================================================
--- detect.min.js	(revision 3228614)
+++ detect.min.js	(working copy)
@@ -1 +1 @@
-const consoleLogPrefix="[Image Prioritizer]",externalBackgroundImages=[];function log(...e){console.log(consoleLogPrefix,...e)}function warn(...e){console.warn(consoleLogPrefix,...e)}export async function initialize({isDebug:e,onLCP:n}){n((n=>{handleLCPMetric(n,e)}),{reportAllChanges:!0})}function getPerformanceResourceByURL(e){const n=performance.getEntriesByType("resource");for(const t of n)if(t.name===e)return t;return null}function handleLCPMetric(e,n){for(const t of e.entries){if(!t.url||!(t.element instanceof HTMLElement)||t.element instanceof HTMLImageElement||t.element instanceof HTMLVideoElement)continue;if(t.url.startsWith("data:"))continue;if(t.element.style.backgroundImage)continue;const e=getPerformanceResourceByURL(t.url);if(!e||!["css","link"].includes(e.initiatorType))return void(n&&warn(`Skipped considering URL (${t.url}) due to unexpected performance resource timing entry:`,e));if(t.url.length>500)return void(n&&log(`Skipping very long URL: ${t.url}`));if(t.element.tagName.length>100)return void(n&&log(`Skipping very long tag name: ${t.element.tagName}`));const o=t.element.getAttribute("id");if("string"==typeof o&&o.length>100)return void(n&&log(`Skipping very long ID: ${o}`));const r=t.element.getAttribute("class");if("string"==typeof r&&r.length>500)return void(n&&log(`Skipping very long className: ${r}`));const i={url:t.url,tag:t.element.tagName,id:o,class:r};n&&log("Detected external LCP background image:",i),externalBackgroundImages.push(i)}}export async function finalize({extendRootData:e,isDebug:n}){if(0===externalBackgroundImages.length)return;const t=externalBackgroundImages.pop();n&&log("Sending external background image for LCP element:",t),e({lcpElementExternalBackgroundImage:t})}
\ No newline at end of file
+const consoleLogPrefix="[Image Prioritizer]",externalBackgroundImages=[];function log(...e){console.log(consoleLogPrefix,...e)}export async function initialize({isDebug:e,onLCP:n}){n((n=>{handleLCPMetric(n,e)}),{reportAllChanges:!0})}function handleLCPMetric(e,n){for(const t of e.entries){if(!t.url||!(t.element instanceof HTMLElement)||t.element instanceof HTMLImageElement||t.element instanceof HTMLVideoElement)continue;if(t.url.startsWith("data:"))continue;if(t.element.style.backgroundImage)continue;if(t.url.length>500)return void(n&&log(`Skipping very long URL: ${t.url}`));if(t.element.tagName.length>100)return void(n&&log(`Skipping very long tag name: ${t.element.tagName}`));const e=t.element.getAttribute("id");if("string"==typeof e&&e.length>100)return void(n&&log(`Skipping very long ID: ${e}`));const o=t.element.getAttribute("class");if("string"==typeof o&&o.length>500)return void(n&&log(`Skipping very long className: ${o}`));const l={url:t.url,tag:t.element.tagName,id:e,class:o};n&&log("Detected external LCP background image:",l),externalBackgroundImages.push(l)}}export async function finalize({extendRootData:e,isDebug:n}){if(0===externalBackgroundImages.length)return;const t=externalBackgroundImages.pop();n&&log("Sending external background image for LCP element:",t),e({lcpElementExternalBackgroundImage:t})}
\ No newline at end of file
Index: helper.php
===================================================================
--- helper.php	(revision 3228614)
+++ helper.php	(working copy)
@@ -6,9 +6,11 @@
  * @since 0.1.0
  */
 
+// @codeCoverageIgnoreStart
 if ( ! defined( 'ABSPATH' ) ) {
 	exit; // Exit if accessed directly.
 }
+// @codeCoverageIgnoreEnd
 
 /**
  * Initializes Image Prioritizer when Optimization Detective has loaded.
@@ -45,6 +47,9 @@
 
 	add_action( 'wp_head', 'image_prioritizer_render_generator_meta_tag' );
 	add_action( 'od_register_tag_visitors', 'image_prioritizer_register_tag_visitors' );
+	add_filter( 'od_extension_module_urls', 'image_prioritizer_filter_extension_module_urls' );
+	add_filter( 'od_url_metric_schema_root_additional_properties', 'image_prioritizer_add_root_schema_properties' );
+	add_filter( 'rest_request_before_callbacks', 'image_prioritizer_filter_rest_request_before_callbacks', 10, 3 );
 }
 
 /**
@@ -93,20 +98,24 @@
 	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' ) );
+	$extension_module_urls[] = plugins_url( add_query_arg( 'ver', IMAGE_PRIORITIZER_VERSION, image_prioritizer_get_asset_path( 'detect.js' ) ), __FILE__ );
 	return $extension_module_urls;
 }
 
 /**
- * Filters additional properties for the element item schema for Optimization Detective.
+ * Filters additional properties for the root schema for Optimization Detective.
  *
  * @since 0.3.0
  * @access private
  *
- * @param array<string, array{type: string}> $additional_properties Additional properties.
+ * @param array<string, array{type: string}>|mixed $additional_properties Additional properties.
  * @return array<string, array{type: string}> Additional properties.
  */
-function image_prioritizer_add_element_item_schema_properties( array $additional_properties ): array {
+function image_prioritizer_add_root_schema_properties( $additional_properties ): array {
+	if ( ! is_array( $additional_properties ) ) {
+		$additional_properties = array();
+	}
+
 	$additional_properties['lcpElementExternalBackgroundImage'] = array(
 		'type'       => 'object',
 		'properties' => array(
Index: hooks.php
===================================================================
--- hooks.php	(revision 3228614)
+++ hooks.php	(working copy)
@@ -6,11 +6,10 @@
  * @since 0.1.0
  */
 
+// @codeCoverageIgnoreStart
 if ( ! defined( 'ABSPATH' ) ) {
 	exit; // Exit if accessed directly.
 }
+// @codeCoverageIgnoreEnd
 
 add_action( 'od_init', 'image_prioritizer_init' );
-add_filter( 'od_extension_module_urls', 'image_prioritizer_filter_extension_module_urls' );
-add_filter( 'od_url_metric_schema_root_additional_properties', 'image_prioritizer_add_element_item_schema_properties' );
-add_filter( 'rest_request_before_callbacks', 'image_prioritizer_filter_rest_request_before_callbacks', 10, 3 );
Index: load.php
===================================================================
--- load.php	(revision 3228614)
+++ load.php	(working copy)
@@ -6,7 +6,7 @@
  * Requires at least: 6.6
  * Requires PHP: 7.2
  * Requires Plugins: optimization-detective
- * Version: 0.3.0
+ * Version: 0.3.1
  * Author: WordPress Performance Team
  * Author URI: https://make.wordpress.org/performance/
  * License: GPLv2 or later
@@ -16,10 +16,11 @@
  * @package image-prioritizer
  */
 
-// Exit if accessed directly.
+// @codeCoverageIgnoreStart
 if ( ! defined( 'ABSPATH' ) ) {
-	exit;
+	exit; // Exit if accessed directly.
 }
+// @codeCoverageIgnoreEnd
 
 (
 	/**
@@ -71,7 +72,7 @@
 	}
 )(
 	'image_prioritizer_pending_plugin',
-	'0.3.0',
+	'0.3.1',
 	static function ( string $version ): void {
 		if ( defined( 'IMAGE_PRIORITIZER_VERSION' ) ) {
 			return;
Index: readme.txt
===================================================================
--- readme.txt	(revision 3228614)
+++ readme.txt	(working copy)
@@ -2,7 +2,7 @@
 
 Contributors: wordpressdotorg
 Tested up to: 6.7
-Stable tag:   0.3.0
+Stable tag:   0.3.1
 License:      GPLv2 or later
 License URI:  https://www.gnu.org/licenses/gpl-2.0.html
 Tags:         performance, optimization, image, lcp, lazy-load
@@ -34,7 +34,7 @@
 
 👉 **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.
+Your site must have the **REST API accessible** to unauthenticated frontend visitors since this is how metrics are collected about how a page should be optimized. There are currently **no settings** and no user interface for this plugin since it is designed to work without any configuration.
 
 == Installation ==
 
@@ -70,6 +70,12 @@
 
 == Changelog ==
 
+= 0.3.1 =
+
+**Bug Fixes**
+
+* Remove erroneous check for resource initiator type when considering whether to submit LCP background image. ([1760](https://github.com/WordPress/performance/pull/1760))
+
 = 0.3.0 =
 
 **Enhancements**

optimization-detective

Important

Stable tag change: 0.9.0 → 1.0.0-beta1

svn status:

?       build/web-vitals-attribution.js
M       class-od-data-validation-exception.php
M       class-od-element.php
M       class-od-html-tag-processor.php
M       class-od-link-collection.php
M       class-od-strict-url-metric.php
M       class-od-tag-visitor-context.php
M       class-od-tag-visitor-registry.php
M       class-od-url-metric-group-collection.php
M       class-od-url-metric-group.php
M       class-od-url-metric.php
?       class-od-visited-tag-state.php
M       detect.js
M       detection.php
?       docs
M       helper.php
M       hooks.php
M       load.php
M       optimization.php
M       readme.txt
?       site-health.php
M       storage/class-od-storage-lock.php
M       storage/class-od-url-metric-store-request-context.php
M       storage/class-od-url-metrics-post-type.php
M       storage/data.php
M       storage/rest-api.php
M       types.ts
M       uninstall.php
svn diff
Index: class-od-data-validation-exception.php
===================================================================
--- class-od-data-validation-exception.php	(revision 3228614)
+++ class-od-data-validation-exception.php	(working copy)
@@ -6,10 +6,11 @@
  * @since 0.1.0
  */
 
-// Exit if accessed directly.
+// @codeCoverageIgnoreStart
 if ( ! defined( 'ABSPATH' ) ) {
-	exit;
+	exit; // Exit if accessed directly.
 }
+// @codeCoverageIgnoreEnd
 
 /**
  * Exception thrown when failing to validate URL Metrics data.
Index: class-od-element.php
===================================================================
--- class-od-element.php	(revision 3228614)
+++ class-od-element.php	(working copy)
@@ -6,10 +6,11 @@
  * @since 0.7.0
  */
 
-// Exit if accessed directly.
+// @codeCoverageIgnoreStart
 if ( ! defined( 'ABSPATH' ) ) {
-	exit;
+	exit; // Exit if accessed directly.
 }
+// @codeCoverageIgnoreEnd
 
 /**
  * Data for a single element in a URL Metric.
@@ -33,6 +34,15 @@
 	protected $data;
 
 	/**
+	 * Transitional XPath.
+	 *
+	 * @since 1.0.0
+	 * @todo Remove logic related to transitional_xpath in a subsequent release once URL Metrics have been collected with the new format.
+	 * @var non-empty-string|null
+	 */
+	protected $transitional_xpath = null;
+
+	/**
 	 * URL Metric that this element belongs to.
 	 *
 	 * @since 0.7.0
@@ -51,7 +61,8 @@
 	 * @param OD_URL_Metric        $url_metric URL Metric.
 	 */
 	public function __construct( array $data, OD_URL_Metric $url_metric ) {
-		$this->data       = $data;
+		$this->data = $data;
+
 		$this->url_metric = $url_metric;
 	}
 
@@ -88,6 +99,9 @@
 	 * @return mixed|null The property value, or null if not set.
 	 */
 	public function get( string $key ) {
+		if ( 'xpath' === $key ) {
+			return $this->get_xpath();
+		}
 		return $this->data[ $key ] ?? null;
 	}
 
@@ -117,11 +131,59 @@
 	 * Gets XPath for element.
 	 *
 	 * @since 0.7.0
+	 * @since 1.0.0 Returns the transitional XPath format. To access the underlying raw XPath, access the 'xpath' key of the jsonSerialize response.
+	 * @todo Remove logic related to transitional_xpath in a subsequent release once URL Metrics have been collected with the new format.
 	 *
 	 * @return non-empty-string XPath.
 	 */
 	public function get_xpath(): string {
-		return $this->data['xpath'];
+
+		if ( ! isset( $this->transitional_xpath ) ) {
+			$replacements = array(
+
+				/*
+				 * Convert the original XPath format for elements in the BODY.
+				 *
+				 * Example:
+				 *   /*[1][self::HTML]/*[2][self::BODY]/*[1][self::DIV]/*[1][self::IMG]
+				 *   =>
+				 *   /HTML/BODY/DIV/*[1][self::IMG]
+				 */
+				'#^/\*\[1]\[self::HTML]/\*\[2]\[self::BODY]/\*\[\d+]\[self::([a-zA-Z0-9:_-]+)]#' => '/HTML/BODY/$1',
+
+				/*
+				 * Convert the original XPath format for elements in the HEAD.
+				 *
+				 * Example:
+				 *   /*[1][self::HTML]/*[1][self::HEAD]/*[1][self::META]
+				 *   =>
+				 *   /HTML/HEAD/*[1][self::META]
+				 */
+				'#^/\*\[1\]\[self::HTML\]/\*\[1\]\[self::HEAD]#' => '/HTML/HEAD',
+
+				/*
+				 * Convert the new XPath format for elements in the BODY.
+				 *
+				 * Note that the new XPath format for elements in the HEAD does not need to be converted to the
+				 * transitional format since disambiguating attributes are not used in the HEAD.
+				 *
+				 * Example:
+				 *   /HTML/BODY/DIV[@id='page']/*[1][self::IMG]
+				 *   =>
+				 *   /HTML/BODY/DIV/*[1][self::IMG]
+				 */
+				'#^(/HTML/BODY/\w+)\[@[^\]]+?]#' => '$1',
+			);
+			foreach ( $replacements as $search => $replace ) {
+				$xpath = preg_replace( $search, $replace, $this->data['xpath'], -1, $count );
+				if ( $count > 0 ) {
+					$this->transitional_xpath = $xpath;
+					break;
+				}
+			}
+		}
+
+		return $this->transitional_xpath ?? $this->data['xpath'];
 	}
 
 	/**
@@ -188,6 +250,9 @@
 	 */
 	#[ReturnTypeWillChange]
 	public function offsetGet( $offset ) {
+		if ( 'xpath' === $offset ) {
+			return $this->get_xpath();
+		}
 		return $this->data[ $offset ] ?? null;
 	}
 
Index: class-od-html-tag-processor.php
===================================================================
--- class-od-html-tag-processor.php	(revision 3228614)
+++ class-od-html-tag-processor.php	(working copy)
@@ -6,10 +6,11 @@
  * @since 0.1.1
  */
 
-// Exit if accessed directly.
+// @codeCoverageIgnoreStart
 if ( ! defined( 'ABSPATH' ) ) {
-	exit;
+	exit; // Exit if accessed directly.
 }
+// @codeCoverageIgnoreEnd
 
 /**
  * Extension to WP_HTML_Tag_Processor that supports injecting HTML and obtaining XPath for the current tag.
@@ -98,11 +99,33 @@
 	/**
 	 * Pattern for valid XPath subset for breadcrumb.
 	 *
+	 * The pattern for matching a tag /[a-zA-Z0-9:_-]+/ used here is informed by the characters found in tag names in
+	 * HTTP Archive as {@link https://docs.google.com/spreadsheets/d/1grkd2_1xSV3jvNK6ucRQ0OL1HmGTsScHuwA8GZuRLHU/edit?gid=2057119066#gid=2057119066 seen}
+	 * in Web Almanac 2022, with the only exception being the very malformed tag name `script="async"`. Note that XPaths
+	 * begin with `/HTML/BODY` followed by an index-free reference to an element which is a direct child of the BODY but
+	 * with a disambiguating attribute predicate added, for example `/HTML/BODY/DIV[@id="page"]`. Below this point, all
+	 * tags must then have indices to disambiguate the XPaths among siblings. For example:
+	 * `/HTML/BODY/DIV[@id="page"]/*[2][self::MAIN]/*[1][self::FIGURE]/*[2][self::IMG]`.
+	 *
+	 * The benefit of omitting the node index from direct children of the BODY allows for variation in the content output
+	 * at `wp_body_open()` without impacting the computed XPaths for subsequent tags. Omitting the node index at this
+	 * level, however, does introduce the risk of duplicate XPaths being computed. For example, if a theme has a
+	 * `<div id="header" role="banner">` and a `<div id="footer" role="contentinfo">` which are both direct descendants
+	 * of `BODY`, then it is possible for an XPath like `/HTML/BODY/DIV/*[1][self::IMG]` to be duplicated if both of
+	 * these `DIV` elements have an `IMG` as the first child. This is also an issue in sites using the Image block
+	 * because it outputs a `DIV.wp-lightbox-overlay.zoom` in `wp_footer`, resulting in there being a real possibility
+	 * for XPaths to not be unique in the page. This would similarly be an issue for any theme/plugin that prints a
+	 * `DIV` at the `wp_footer`, again to add a modal, for example. Therefore, en lieu of node index being added to
+	 * children of `BODY`, a disambiguating attribute predicate is added for the element's `id`, `role`, or `class`
+	 * attribute. These three attributes are the most stable across page loads, especially at the root of the document
+	 * (where there is no Post Loop using `post_class()`).
+	 *
 	 * @since 0.4.0
 	 * @see self::get_xpath()
 	 * @var string
+	 * @link https://github.com/WordPress/performance/issues/1787
 	 */
-	const XPATH_PATTERN = '^(/\*\[\d+\]\[self::.+?\])+$';
+	const XPATH_PATTERN = '^(/([a-zA-Z0-9:_-]+|\*\[\d+\]\[self::[a-zA-Z0-9:_-]+\])(\[@(id|role|class)=\'[a-zA-Z0-9_.\s:-]*\'\])?)+$';
 
 	/**
 	 * Bookmark for the end of the HEAD.
@@ -131,6 +154,17 @@
 	private $open_stack_tags = array();
 
 	/**
+	 * Stack of the attributes for open tags.
+	 *
+	 * Note that currently only the third item will currently be populated (index 2), as this corresponds to tags which
+	 * are children of the `BODY` tag. This is used in {@see self::get_xpath()}.
+	 *
+	 * @since 1.0.0
+	 * @var array<array<string, string>>
+	 */
+	private $open_stack_attributes = array();
+
+	/**
 	 * Open stack indices.
 	 *
 	 * @since 0.4.0
@@ -147,19 +181,32 @@
 	 * populated back into `$this->open_stack_tags` and `$this->open_stack_indices`.
 	 *
 	 * @since 0.4.0
-	 * @var array<string, array{tags: string[], indices: int[]}>
+	 * @var array<string, array{tags: string[], attributes: array<array<string, string>>, indices: int[]}>
 	 */
 	private $bookmarked_open_stacks = array();
 
 	/**
-	 * XPath for the current tag.
+	 * Stored XPath for the current tag.
 	 *
-	 * This is used so that repeated calls to {@see self::get_xpath()} won't needlessly reconstruct the string. This
-	 * gets cleared whenever {@see self::open_tags()} iterates to the next tag.
+	 * This is used so that repeated calls to {@see self::get_stored_xpath()} won't needlessly reconstruct the string.
+	 * This gets cleared whenever {@see self::open_tags()} iterates to the next tag.
 	 *
+	 * @todo Remove this once the XPath transitional period is over.
+	 *
 	 * @since 0.4.0
 	 * @var string|null
 	 */
+	private $current_stored_xpath = null;
+
+	/**
+	 * (Transitional) XPath for the current tag.
+	 *
+	 * This is used to store the old XPath format in a transitional period until which new URL Metrics are expected to
+	 * have been collected to purge out references to the old format.
+	 *
+	 * @since 1.0.0
+	 * @var string|null
+	 */
 	private $current_xpath = null;
 
 	/**
@@ -215,7 +262,12 @@
 		if ( null !== $query ) {
 			throw new InvalidArgumentException( esc_html__( 'Processor subclass does not support queries.', 'optimization-detective' ) );
 		}
-		return parent::next_tag( array( 'tag_closers' => 'visit' ) );
+
+		// Elements in the Admin Bar are not relevant for optimization, so this loop ensures that no tags in the Admin Bar are visited.
+		do {
+			$matched = parent::next_tag( array( 'tag_closers' => 'visit' ) );
+		} while ( $matched && $this->is_admin_bar() );
+		return $matched;
 	}
 
 	/**
@@ -267,11 +319,13 @@
 	 * @return bool Whether a token was parsed.
 	 */
 	public function next_token(): bool {
-		$this->current_xpath = null; // Clear cache.
+		$this->current_stored_xpath = null; // Clear cache.
+		$this->current_xpath        = null; // Clear cache.
 		++$this->cursor_move_count;
 		if ( ! parent::next_token() ) {
-			$this->open_stack_tags    = array();
-			$this->open_stack_indices = array();
+			$this->open_stack_tags       = array();
+			$this->open_stack_attributes = array();
+			$this->open_stack_indices    = array();
 
 			// 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;
@@ -285,6 +339,7 @@
 
 		if ( $this->previous_tag_without_closer ) {
 			array_pop( $this->open_stack_tags );
+			array_pop( $this->open_stack_attributes );
 		}
 
 		if ( ! $this->is_tag_closer() ) {
@@ -296,6 +351,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_attributes, (int) $i );
 					array_splice( $this->open_stack_indices, count( $this->open_stack_tags ) + 1 );
 				}
 			}
@@ -303,6 +359,13 @@
 			$level                   = count( $this->open_stack_tags );
 			$this->open_stack_tags[] = $tag_name;
 
+			// For children of the BODY, capture disambiguating comments. See the get_xpath() method for where this data is used.
+			$attributes = array();
+			if ( isset( $this->open_stack_tags[1] ) && 'BODY' === $this->open_stack_tags[1] && 2 === $level ) {
+				$attributes = $this->get_disambiguating_attributes();
+			}
+			$this->open_stack_attributes[] = $attributes;
+
 			if ( ! isset( $this->open_stack_indices[ $level ] ) ) {
 				$this->open_stack_indices[ $level ] = 0;
 			} else {
@@ -326,6 +389,7 @@
 			}
 
 			$popped_tag_name = array_pop( $this->open_stack_tags );
+			array_pop( $this->open_stack_attributes );
 			if ( $popped_tag_name !== $tag_name ) {
 				$this->warn(
 					__METHOD__,
@@ -443,8 +507,9 @@
 	public function seek( $bookmark_name ): bool {
 		$result = parent::seek( $bookmark_name );
 		if ( $result ) {
-			$this->open_stack_tags    = $this->bookmarked_open_stacks[ $bookmark_name ]['tags'];
-			$this->open_stack_indices = $this->bookmarked_open_stacks[ $bookmark_name ]['indices'];
+			$this->open_stack_tags       = $this->bookmarked_open_stacks[ $bookmark_name ]['tags'];
+			$this->open_stack_attributes = $this->bookmarked_open_stacks[ $bookmark_name ]['attributes'];
+			$this->open_stack_indices    = $this->bookmarked_open_stacks[ $bookmark_name ]['indices'];
 		}
 		return $result;
 	}
@@ -462,8 +527,9 @@
 		$result = parent::set_bookmark( $name );
 		if ( $result ) {
 			$this->bookmarked_open_stacks[ $name ] = array(
-				'tags'    => $this->open_stack_tags,
-				'indices' => $this->open_stack_indices,
+				'tags'       => $this->open_stack_tags,
+				'attributes' => $this->open_stack_attributes,
+				'indices'    => $this->open_stack_indices,
 			);
 		}
 		return $result;
@@ -499,15 +565,50 @@
 	 * @since 0.4.0
 	 * @since 0.9.0 Renamed from get_breadcrumbs() to get_indexed_breadcrumbs().
 	 *
-	 * @return Generator<array{string, int}> Breadcrumb.
+	 * @return Generator<array{string, int, array<string, string>}> Breadcrumb.
 	 */
 	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 ] );
+			yield array( $breadcrumb_tag_name, $this->open_stack_indices[ $i ], $this->open_stack_attributes[ $i ] );
 		}
 	}
 
 	/**
+	 * Gets disambiguating attributes.
+	 *
+	 * This returns the most stable attribute which can be used to disambiguate an XPath expression when the node index
+	 * is not appropriate. This is used specifically for children of the `BODY`. The `id` and `role` attributes are most
+	 * stable followed by the `class` attribute (cf. <https://g.co/gemini/share/032edd9063c1>), although all Block
+	 * Themes utilize the 'wp-site-blocks' class name in the root `DIV`. Only one attribute is currently returned,
+	 * although potentially more could be returned if additional disambiguation is needed in the future.
+	 *
+	 * @since 1.0.0
+	 *
+	 * @return array<string, string> Disambiguating attributes.
+	 */
+	private function get_disambiguating_attributes(): array {
+		$attributes = array();
+		foreach ( array( 'id', 'role', 'class' ) as $attribute_name ) {
+			$attribute_value = $this->get_attribute( $attribute_name );
+			if ( null === $attribute_value ) {
+				continue;
+			}
+			if ( true === $attribute_value ) {
+				// In XPath, a boolean attribute in HTML like `<video controls>` is the same as `<video controls="">`. Both are matched by `//video[@controls=""]`.
+				$attribute_value = '';
+			} elseif ( 1 !== preg_match( '/^[a-zA-Z0-9_.\s:-]*$/', $attribute_value ) ) {
+				// Skip attribute values which contain uncommon characters, especially single/double quote marks and
+				// brackets, which could cause headaches when constructing/deconstructing XPath attribute predicates.
+				continue;
+			}
+
+			$attributes[ $attribute_name ] = $attribute_value;
+			break; // Stop when we've found one.
+		}
+		return $attributes;
+	}
+
+	/**
 	 * Computes the HTML breadcrumbs for the currently-matched node, if matched.
 	 *
 	 * Breadcrumbs start at the outermost parent and descend toward the matched element.
@@ -541,18 +642,33 @@
 	/**
 	 * Gets XPath for the current open tag.
 	 *
-	 * It would be nicer if this were like `/html[1]/body[2]` but in XPath the position() here refers to the
-	 * index of the preceding node set. So it has to rather be written `/*[1][self::html]/*[2][self::body]`.
+	 * It would be nicer if this were like `.../DIV[1]/DIV[2]` but in XPath the position() here refers to the
+	 * index of the preceding node set. So it has to rather be written `.../*[1][self::DIV]/*[2][self::DIV]`.
+	 * Note that the first three levels lack any node index whereas the third level includes a disambiguating
+	 * attribute predicate (e.g. `/HTML/BODY/DIV[@id="page"]`) for the reasons explained in {@see self::XPATH_PATTERN}.
+	 * This predicate will be included once the transitional period is over.
 	 *
 	 * @since 0.4.0
+	 * @todo Replace the logic herein with what is in get_stored_xpath() once the transitional period is over.
 	 *
 	 * @return string XPath.
 	 */
 	public function get_xpath(): string {
+		/*
+		 * This transitional format is used by default for all extensions. The non-transitional format is used only in
+		 * od_optimize_template_output_buffer() when setting the data-od-xpath attribute. This is so that the new format
+		 * will replace the old format as new URL Metrics are collected. After a month of the new format being live, the
+		 * transitional format can be eliminated. See the corresponding logic in OD_Element for normalizing both the
+		 * old and new XPath formats to use the transitional format.
+		 */
 		if ( null === $this->current_xpath ) {
 			$this->current_xpath = '';
-			foreach ( $this->get_indexed_breadcrumbs() as list( $tag_name, $index ) ) {
-				$this->current_xpath .= sprintf( '/*[%d][self::%s]', $index + 1, $tag_name );
+			foreach ( $this->get_indexed_breadcrumbs() as $i => list( $tag_name, $index, $attributes ) ) {
+				if ( $i < 2 || ( 2 === $i && '/HTML/BODY' === $this->current_xpath ) ) {
+					$this->current_xpath .= "/$tag_name";
+				} else {
+					$this->current_xpath .= sprintf( '/*[%d][self::%s]', $index + 1, $tag_name );
+				}
 			}
 		}
 		return $this->current_xpath;
@@ -559,6 +675,61 @@
 	}
 
 	/**
+	 * Gets stored XPath for the current open tag.
+	 *
+	 * This method is temporary for a transition period while new URL Metrics are collected for active installs. Once
+	 * the transition period is over, the logic in this method can be moved to {@see self::get_xpath()} and this method
+	 * can simply be an alias for that one. See related logic in {@see OD_Element::get_xpath()}. This function is only
+	 * used internally by Optimization Detective in {@see od_optimize_template_output_buffer()}.
+	 *
+	 * @since 1.0.0
+	 * @todo Move the logic in this method to the get_xpath() method and let this be an alias for that method once the transitional period is over.
+	 * @access private
+	 *
+	 * @return string XPath.
+	 */
+	public function get_stored_xpath(): string {
+		if ( null === $this->current_stored_xpath ) {
+			$this->current_stored_xpath = '';
+			foreach ( $this->get_indexed_breadcrumbs() as $i => list( $tag_name, $index, $attributes ) ) {
+				if ( $i < 2 ) {
+					$this->current_stored_xpath .= "/$tag_name";
+				} elseif ( 2 === $i && '/HTML/BODY' === $this->current_stored_xpath ) {
+					$segment = "/$tag_name";
+					foreach ( $attributes as $attribute_name => $attribute_value ) {
+						$segment .= sprintf(
+							"[@%s='%s']",
+							$attribute_name,
+							$attribute_value // Note: $attribute_value has already been validated to only contain safe characters /^[a-zA-Z0-9_.\s:-]*/ which do not need escaping.
+						);
+					}
+					$this->current_stored_xpath .= $segment;
+				} else {
+					$this->current_stored_xpath .= sprintf( '/*[%d][self::%s]', $index + 1, $tag_name );
+				}
+			}
+		}
+		return $this->current_stored_xpath;
+	}
+
+	/**
+	 * Returns whether the processor is currently at or inside the admin bar.
+	 *
+	 * @since 1.0.0
+	 *
+	 * @return bool Whether at or inside the admin bar.
+	 */
+	private function is_admin_bar(): bool {
+		return (
+			isset( $this->open_stack_tags[2], $this->open_stack_attributes[2]['id'] )
+			&&
+			'DIV' === $this->open_stack_tags[2]
+			&&
+			'wpadminbar' === $this->open_stack_attributes[2]['id']
+		);
+	}
+
+	/**
 	 * Append HTML to the HEAD.
 	 *
 	 * The provided HTML must be valid! No validation is performed.
Index: class-od-link-collection.php
===================================================================
--- class-od-link-collection.php	(revision 3228614)
+++ class-od-link-collection.php	(working copy)
@@ -6,10 +6,11 @@
  * @since 0.3.0
  */
 
-// Exit if accessed directly.
+// @codeCoverageIgnoreStart
 if ( ! defined( 'ABSPATH' ) ) {
-	exit;
+	exit; // Exit if accessed directly.
 }
+// @codeCoverageIgnoreEnd
 
 /**
  * Collection for links added to the document.
@@ -113,12 +114,18 @@
 	 * @return LinkAttributes[] Prepared links with adjacent-duplicates merged together and media attributes added.
 	 */
 	private function get_prepared_links(): array {
+		$links_by_rel = array_values( $this->links_by_rel );
+		if ( count( $links_by_rel ) === 0 ) {
+			// This condition is needed for PHP 7.2 and PHP 7.3 in which array_merge() fails if passed a spread empty array: 'array_merge() expects at least 1 parameter, 0 given'.
+			return array();
+		}
+
 		return array_merge(
 			...array_map(
 				function ( array $links ): array {
 					return $this->merge_consecutive_links( $links );
 				},
-				array_values( $this->links_by_rel )
+				$links_by_rel
 			)
 		);
 	}
Index: class-od-strict-url-metric.php
===================================================================
--- class-od-strict-url-metric.php	(revision 3228614)
+++ class-od-strict-url-metric.php	(working copy)
@@ -6,10 +6,11 @@
  * @since 0.6.0
  */
 
-// Exit if accessed directly.
+// @codeCoverageIgnoreStart
 if ( ! defined( 'ABSPATH' ) ) {
-	exit;
+	exit; // Exit if accessed directly.
 }
+// @codeCoverageIgnoreEnd
 
 /**
  * Representation of the measurements taken from a single client's visit to a specific URL without additionalProperties allowed.
@@ -17,6 +18,8 @@
  * This is used exclusively in the REST API endpoint for capturing new URL Metrics to prevent invalid additional data from being
  * submitted in the request. For URL Metrics which have been stored the looser OD_URL_Metric class is used instead.
  *
+ * @phpstan-import-type JSONSchema from OD_URL_Metric
+ *
  * @since 0.6.0
  * @access private
  */
@@ -27,7 +30,7 @@
 	 *
 	 * @since 0.6.0
 	 *
-	 * @return array<string, mixed> Schema.
+	 * @return JSONSchema Schema.
 	 */
 	public static function get_json_schema(): array {
 		return self::set_additional_properties_to_false( parent::get_json_schema() );
@@ -42,26 +45,28 @@
 	 * @since 0.6.0
 	 * @see rest_default_additional_properties_to_false()
 	 *
-	 * @param mixed $schema Schema.
-	 * @return mixed Processed schema.
+	 * @phpstan-param JSONSchema $schema
+	 *
+	 * @param array<string, mixed> $schema Schema.
+	 * @return JSONSchema Processed schema.
 	 */
-	private static function set_additional_properties_to_false( $schema ) {
-		if ( ! isset( $schema['type'] ) ) {
-			return $schema;
-		}
-
+	private static function set_additional_properties_to_false( array $schema ): array {
 		$type = (array) $schema['type'];
 
 		if ( in_array( 'object', $type, true ) ) {
 			if ( isset( $schema['properties'] ) ) {
 				foreach ( $schema['properties'] as $key => $child_schema ) {
-					$schema['properties'][ $key ] = self::set_additional_properties_to_false( $child_schema );
+					if ( isset( $child_schema['type'] ) ) {
+						$schema['properties'][ $key ] = self::set_additional_properties_to_false( $child_schema );
+					}
 				}
 			}
 
 			if ( isset( $schema['patternProperties'] ) ) {
 				foreach ( $schema['patternProperties'] as $key => $child_schema ) {
-					$schema['patternProperties'][ $key ] = self::set_additional_properties_to_false( $child_schema );
+					if ( isset( $child_schema['type'] ) ) {
+						$schema['patternProperties'][ $key ] = self::set_additional_properties_to_false( $child_schema );
+					}
 				}
 			}
 
@@ -69,7 +74,7 @@
 		}
 
 		if ( in_array( 'array', $type, true ) ) {
-			if ( isset( $schema['items'] ) ) {
+			if ( isset( $schema['items'], $schema['items']['type'] ) ) {
 				$schema['items'] = self::set_additional_properties_to_false( $schema['items'] );
 			}
 		}
Index: class-od-tag-visitor-context.php
===================================================================
--- class-od-tag-visitor-context.php	(revision 3228614)
+++ class-od-tag-visitor-context.php	(working copy)
@@ -6,16 +6,16 @@
  * @since 0.4.0
  */
 
-// Exit if accessed directly.
+// @codeCoverageIgnoreStart
 if ( ! defined( 'ABSPATH' ) ) {
-	exit;
+	exit; // Exit if accessed directly.
 }
+// @codeCoverageIgnoreEnd
 
 /**
  * Context for tag visitors invoked for each tag while walking over a document.
  *
  * @since 0.4.0
- * @access private
  *
  * @property-read OD_URL_Metric_Group_Collection $url_metrics_group_collection Deprecated property accessed via magic getter. Use the url_metric_group_collection property instead.
  */
@@ -24,6 +24,7 @@
 	/**
 	 * HTML tag processor.
 	 *
+	 * @since 0.4.0
 	 * @var OD_HTML_Tag_Processor
 	 * @readonly
 	 */
@@ -32,6 +33,7 @@
 	/**
 	 * URL Metric group collection.
 	 *
+	 * @since 0.4.0
 	 * @var OD_URL_Metric_Group_Collection
 	 * @readonly
 	 */
@@ -40,6 +42,7 @@
 	/**
 	 * Link collection.
 	 *
+	 * @since 0.4.0
 	 * @var OD_Link_Collection
 	 * @readonly
 	 */
@@ -46,19 +49,42 @@
 	public $link_collection;
 
 	/**
+	 * Visited tag state.
+	 *
+	 * @since 1.0.0
+	 * @var OD_Visited_Tag_State
+	 */
+	private $visited_tag_state;
+
+	/**
 	 * Constructor.
 	 *
+	 * @since 0.4.0
+	 *
 	 * @param OD_HTML_Tag_Processor          $processor                   HTML tag processor.
 	 * @param OD_URL_Metric_Group_Collection $url_metric_group_collection URL Metric group collection.
 	 * @param OD_Link_Collection             $link_collection             Link collection.
+	 * @param OD_Visited_Tag_State           $visited_tag_state           Visited tag state.
 	 */
-	public function __construct( OD_HTML_Tag_Processor $processor, OD_URL_Metric_Group_Collection $url_metric_group_collection, OD_Link_Collection $link_collection ) {
+	public function __construct( OD_HTML_Tag_Processor $processor, OD_URL_Metric_Group_Collection $url_metric_group_collection, OD_Link_Collection $link_collection, OD_Visited_Tag_State $visited_tag_state ) {
 		$this->processor                   = $processor;
 		$this->url_metric_group_collection = $url_metric_group_collection;
 		$this->link_collection             = $link_collection;
+		$this->visited_tag_state           = $visited_tag_state;
 	}
 
 	/**
+	 * Marks the tag for being tracked in URL Metrics.
+	 *
+	 * Calling this method from a tag visitor has the same effect as a tag visitor returning `true`.
+	 *
+	 * @since 1.0.0
+	 */
+	public function track_tag(): void {
+		$this->visited_tag_state->track_tag();
+	}
+
+	/**
 	 * Gets deprecated property.
 	 *
 	 * @since 0.7.0
Index: class-od-tag-visitor-registry.php
===================================================================
--- class-od-tag-visitor-registry.php	(revision 3228614)
+++ class-od-tag-visitor-registry.php	(working copy)
@@ -6,15 +6,16 @@
  * @since 0.3.0
  */
 
-// Exit if accessed directly.
+// @codeCoverageIgnoreStart
 if ( ! defined( 'ABSPATH' ) ) {
-	exit;
+	exit; // Exit if accessed directly.
 }
+// @codeCoverageIgnoreEnd
 
 /**
  * Registry for tag visitors invoked for each tag while walking over a document.
  *
- * @phpstan-type TagVisitorCallback callable( OD_Tag_Visitor_Context ): bool
+ * @phpstan-type TagVisitorCallback callable( OD_Tag_Visitor_Context ): ( bool | void )
  *
  * @implements IteratorAggregate<string, TagVisitorCallback>
  *
Index: class-od-url-metric-group-collection.php
===================================================================
--- class-od-url-metric-group-collection.php	(revision 3228614)
+++ class-od-url-metric-group-collection.php	(working copy)
@@ -6,10 +6,11 @@
  * @since 0.1.0
  */
 
-// Exit if accessed directly.
+// @codeCoverageIgnoreStart
 if ( ! defined( 'ABSPATH' ) ) {
-	exit;
+	exit; // Exit if accessed directly.
 }
+// @codeCoverageIgnoreEnd
 
 /**
  * Collection of URL groups according to the breakpoints.
@@ -270,9 +271,12 @@
 				return;
 			}
 		}
+		// @codeCoverageIgnoreStart
+		// In practice this exception should never get thrown because create_groups() creates groups from a minimum of 0 to a maximum of PHP_INT_MAX.
 		throw new InvalidArgumentException(
 			esc_html__( 'No group available to add URL Metric to.', 'optimization-detective' )
 		);
+		// @codeCoverageIgnoreEnd
 	}
 
 	/**
@@ -295,6 +299,8 @@
 					return $group;
 				}
 			}
+			// @codeCoverageIgnoreStart
+			// In practice this exception should never get thrown because create_groups() creates groups from a minimum of 0 to a maximum of PHP_INT_MAX.
 			throw new InvalidArgumentException(
 				esc_html(
 					sprintf(
@@ -304,6 +310,7 @@
 					)
 				)
 			);
+			// @codeCoverageIgnoreEnd
 		} )();
 
 		$this->result_cache[ __FUNCTION__ ][ $viewport_width ] = $result;
Index: class-od-url-metric-group.php
===================================================================
--- class-od-url-metric-group.php	(revision 3228614)
+++ class-od-url-metric-group.php	(working copy)
@@ -6,10 +6,11 @@
  * @since 0.1.0
  */
 
-// Exit if accessed directly.
+// @codeCoverageIgnoreStart
 if ( ! defined( 'ABSPATH' ) ) {
-	exit;
+	exit; // Exit if accessed directly.
 }
+// @codeCoverageIgnoreEnd
 
 /**
  * URL Metrics grouped by viewport according to breakpoints.
Index: class-od-url-metric.php
===================================================================
--- class-od-url-metric.php	(revision 3228614)
+++ class-od-url-metric.php	(working copy)
@@ -6,10 +6,11 @@
  * @since 0.1.0
  */
 
-// Exit if accessed directly.
+// @codeCoverageIgnoreStart
 if ( ! defined( 'ABSPATH' ) ) {
-	exit;
+	exit; // Exit if accessed directly.
 }
+// @codeCoverageIgnoreEnd
 
 /**
  * Representation of the measurements taken from a single client's visit to a specific URL.
@@ -44,6 +45,19 @@
  *                                viewport: ViewportRect,
  *                                elements: ElementData[]
  *                            }
+ * @phpstan-type JSONSchema   array{
+ *                                type: string|string[],
+ *                                items?: mixed,
+ *                                properties?: array<string, mixed>,
+ *                                patternProperties?: array<string, mixed>,
+ *                                required?: bool,
+ *                                minimum?: int,
+ *                                maximum?: int,
+ *                                pattern?: non-empty-string,
+ *                                additionalProperties?: bool,
+ *                                format?: non-empty-string,
+ *                                readonly?: bool,
+ *                            }
  *
  * @since 0.1.0
  * @access private
@@ -160,7 +174,7 @@
 	 *
 	 * @todo Cache the return value?
 	 *
-	 * @return array<string, mixed> Schema.
+	 * @return JSONSchema Schema.
 	 */
 	public static function get_json_schema(): array {
 		/*
Index: detect.js
===================================================================
--- detect.js	(revision 3228614)
+++ detect.js	(working copy)
@@ -1,5 +1,6 @@
 /**
  * @typedef {import("web-vitals").LCPMetric} LCPMetric
+ * @typedef {import("web-vitals").LCPMetricWithAttribution} LCPMetricWithAttribution
  * @typedef {import("./types.ts").ElementData} ElementData
  * @typedef {import("./types.ts").OnTTFBFunction} OnTTFBFunction
  * @typedef {import("./types.ts").OnFCPFunction} OnFCPFunction
@@ -6,6 +7,11 @@
  * @typedef {import("./types.ts").OnLCPFunction} OnLCPFunction
  * @typedef {import("./types.ts").OnINPFunction} OnINPFunction
  * @typedef {import("./types.ts").OnCLSFunction} OnCLSFunction
+ * @typedef {import("./types.ts").OnTTFBWithAttributionFunction} OnTTFBWithAttributionFunction
+ * @typedef {import("./types.ts").OnFCPWithAttributionFunction} OnFCPWithAttributionFunction
+ * @typedef {import("./types.ts").OnLCPWithAttributionFunction} OnLCPWithAttributionFunction
+ * @typedef {import("./types.ts").OnINPWithAttributionFunction} OnINPWithAttributionFunction
+ * @typedef {import("./types.ts").OnCLSWithAttributionFunction} OnCLSWithAttributionFunction
  * @typedef {import("./types.ts").URLMetric} URLMetric
  * @typedef {import("./types.ts").URLMetricGroupStatus} URLMetricGroupStatus
  * @typedef {import("./types.ts").Extension} Extension
@@ -360,11 +366,11 @@
 	);
 
 	const {
-		/** @type OnTTFBFunction */ onTTFB,
-		/** @type OnFCPFunction */ onFCP,
-		/** @type OnLCPFunction */ onLCP,
-		/** @type OnINPFunction */ onINP,
-		/** @type OnCLSFunction */ onCLS,
+		/** @type {OnTTFBFunction|OnTTFBWithAttributionFunction} */ onTTFB,
+		/** @type {OnFCPFunction|OnFCPWithAttributionFunction} */ onFCP,
+		/** @type {OnLCPFunction|OnLCPWithAttributionFunction} */ onLCP,
+		/** @type {OnINPFunction|OnINPWithAttributionFunction} */ onINP,
+		/** @type {OnCLSFunction|OnCLSWithAttributionFunction} */ onCLS,
 	} = await import( webVitalsLibrarySrc );
 
 	// TODO: Does this make sense here?
@@ -490,13 +496,18 @@
 		} );
 	}
 
-	/** @type {LCPMetric[]} */
+	/** @type {(LCPMetric|LCPMetricWithAttribution)[]} */
 	const lcpMetricCandidates = [];
 
 	// Obtain at least one LCP candidate. More may be reported before the page finishes loading.
 	await new Promise( ( resolve ) => {
 		onLCP(
-			( /** @type LCPMetric */ metric ) => {
+			/**
+			 * Handles an LCP metric being reported.
+			 *
+			 * @param {LCPMetric|LCPMetricWithAttribution} metric
+			 */
+			( metric ) => {
 				lcpMetricCandidates.push( metric );
 				resolve();
 			},
Index: detection.php
===================================================================
--- detection.php	(revision 3228614)
+++ detection.php	(working copy)
@@ -6,9 +6,11 @@
  * @since 0.1.0
  */
 
+// @codeCoverageIgnoreStart
 if ( ! defined( 'ABSPATH' ) ) {
 	exit; // Exit if accessed directly.
 }
+// @codeCoverageIgnoreEnd
 
 /**
  * Obtains the ID for a post related to this response so that page caches can be told to invalidate their cache.
@@ -70,8 +72,32 @@
  * @param OD_URL_Metric_Group_Collection $group_collection URL Metric group collection.
  */
 function od_get_detection_script( string $slug, OD_URL_Metric_Group_Collection $group_collection ): string {
+
+	/**
+	 * Filters whether to use the web-vitals.js build with attribution.
+	 *
+	 * When using the attribution build of web-vitals, the metric object passed to report callbacks registered via
+	 * `onTTFB`, `onFCP`, `onLCP`, `onCLS`, and `onINP` will include an additional {@link https://github.com/GoogleChrome/web-vitals#attribution attribution property}.
+	 * For details, please refer to the {@link https://github.com/GoogleChrome/web-vitals web-vitals documentation}.
+	 *
+	 * For example, to opt in to using the attribution build:
+	 *
+	 *     add_filter( 'od_use_web_vitals_attribution_build', '__return_true' );
+	 *
+	 * Note that the attribution build is slightly larger than the standard build, so this is why it is not used by default.
+	 * The additional attribution data is made available to client-side extension script modules registered via the `od_extension_module_urls` filter.
+	 *
+	 * @since 1.0.0
+	 *
+	 * @param bool $use_attribution_build Whether to use the attribution build.
+	 */
+	$use_attribution_build = (bool) apply_filters( 'od_use_web_vitals_attribution_build', false );
+
 	$web_vitals_lib_data = require __DIR__ . '/build/web-vitals.asset.php';
-	$web_vitals_lib_src  = add_query_arg( 'ver', $web_vitals_lib_data['version'], plugin_dir_url( __FILE__ ) . 'build/web-vitals.js' );
+	$web_vitals_lib_src  = $use_attribution_build ?
+		plugins_url( 'build/web-vitals-attribution.js', __FILE__ ) :
+		plugins_url( 'build/web-vitals.js', __FILE__ );
+	$web_vitals_lib_src  = add_query_arg( 'ver', $web_vitals_lib_data['version'], $web_vitals_lib_src );
 
 	/**
 	 * Filters the list of extension script module URLs to import when performing detection.
@@ -118,7 +144,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__ ) . od_get_asset_path( 'detect.js' ) ) ),
+			wp_json_encode( plugins_url( add_query_arg( 'ver', OPTIMIZATION_DETECTIVE_VERSION, od_get_asset_path( 'detect.js' ) ), __FILE__ ) ),
 			wp_json_encode( $detect_args )
 		),
 		array( 'type' => 'module' )
Index: helper.php
===================================================================
--- helper.php	(revision 3228614)
+++ helper.php	(working copy)
@@ -6,14 +6,17 @@
  * @since 0.1.0
  */
 
+// @codeCoverageIgnoreStart
 if ( ! defined( 'ABSPATH' ) ) {
 	exit; // Exit if accessed directly.
 }
+// @codeCoverageIgnoreEnd
 
 /**
  * Initializes extensions for Optimization Detective.
  *
  * @since 0.7.0
+ * @access private
  */
 function od_initialize_extensions(): void {
 	/**
@@ -29,6 +32,9 @@
 /**
  * Generates a media query for the provided minimum and maximum viewport widths.
  *
+ * This helper function is available for extensions to leverage when manually printing STYLE rules via
+ * {@see OD_HTML_Tag_Processor::append_head_html()} or {@see OD_HTML_Tag_Processor::append_body_html()}
+ *
  * @since 0.7.0
  *
  * @param int|null $minimum_viewport_width Minimum viewport width.
@@ -59,10 +65,18 @@
  * See {@see 'wp_head'}.
  *
  * @since 0.1.0
+ * @access private
  */
 function od_render_generator_meta_tag(): void {
 	// Use the plugin slug as it is immutable.
-	echo '<meta name="generator" content="optimization-detective ' . esc_attr( OPTIMIZATION_DETECTIVE_VERSION ) . '">' . "\n";
+	$content = 'optimization-detective ' . OPTIMIZATION_DETECTIVE_VERSION;
+
+	// Indicate that the plugin will not be doing anything because the REST API is unavailable.
+	if ( od_is_rest_api_unavailable() ) {
+		$content .= '; rest_api_unavailable';
+	}
+
+	echo '<meta name="generator" content="' . esc_attr( $content ) . '">' . "\n";
 }
 
 /**
@@ -69,6 +83,7 @@
  * Gets the path to a script or stylesheet.
  *
  * @since 0.9.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.
Index: hooks.php
===================================================================
--- hooks.php	(revision 3228614)
+++ hooks.php	(working copy)
@@ -6,12 +6,21 @@
  * @since 0.1.0
  */
 
+// @codeCoverageIgnoreStart
 if ( ! defined( 'ABSPATH' ) ) {
 	exit; // Exit if accessed directly.
 }
+// @codeCoverageIgnoreEnd
 
+// The addition of the following hooks is tested in Test_OD_Hooks::test_hooks_added() and Test_OD_Storage_Post_Type::test_add_hooks().
+
+// @codeCoverageIgnoreStart
 add_action( 'init', 'od_initialize_extensions', PHP_INT_MAX );
 add_filter( 'template_include', 'od_buffer_output', PHP_INT_MAX );
 OD_URL_Metrics_Post_Type::add_hooks();
 add_action( 'wp', 'od_maybe_add_template_output_buffer_filter' );
 add_action( 'wp_head', 'od_render_generator_meta_tag' );
+add_filter( 'site_status_tests', 'od_add_rest_api_availability_test' );
+add_action( 'admin_init', 'od_maybe_run_rest_api_health_check' );
+add_action( 'after_plugin_row_meta', 'od_render_rest_api_health_check_admin_notice_in_plugin_row', 30 );
+// @codeCoverageIgnoreEnd
Index: load.php
===================================================================
--- load.php	(revision 3228614)
+++ load.php	(working copy)
@@ -5,7 +5,7 @@
  * 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.6
  * Requires PHP: 7.2
- * Version: 0.9.0
+ * Version: 1.0.0-beta1
  * Author: WordPress Performance Team
  * Author URI: https://make.wordpress.org/performance/
  * License: GPLv2 or later
@@ -15,10 +15,11 @@
  * @package optimization-detective
  */
 
-// Exit if accessed directly.
+// @codeCoverageIgnoreStart
 if ( ! defined( 'ABSPATH' ) ) {
-	exit;
+	exit; // Exit if accessed directly.
 }
+// @codeCoverageIgnoreEnd
 
 (
 	/**
@@ -70,7 +71,7 @@
 	}
 )(
 	'optimization_detective_pending_plugin',
-	'0.9.0',
+	'1.0.0-beta1',
 	static function ( string $version ): void {
 		if ( defined( 'OPTIMIZATION_DETECTIVE_VERSION' ) ) {
 			return;
@@ -122,10 +123,14 @@
 		// Optimization logic.
 		require_once __DIR__ . '/class-od-link-collection.php';
 		require_once __DIR__ . '/class-od-tag-visitor-registry.php';
+		require_once __DIR__ . '/class-od-visited-tag-state.php';
 		require_once __DIR__ . '/class-od-tag-visitor-context.php';
 		require_once __DIR__ . '/optimization.php';
 
 		// Add hooks for the above requires.
 		require_once __DIR__ . '/hooks.php';
+
+		// Load site health checks.
+		require_once __DIR__ . '/site-health.php';
 	}
 );
Index: optimization.php
===================================================================
--- optimization.php	(revision 3228614)
+++ optimization.php	(working copy)
@@ -6,9 +6,11 @@
  * @since 0.1.0
  */
 
+// @codeCoverageIgnoreStart
 if ( ! defined( 'ABSPATH' ) ) {
 	exit; // Exit if accessed directly.
 }
+// @codeCoverageIgnoreEnd
 
 /**
  * Starts output buffering at the end of the 'template_include' filter.
@@ -78,9 +80,38 @@
  * @access private
  */
 function od_maybe_add_template_output_buffer_filter(): void {
-	if ( ! od_can_optimize_response() || isset( $_GET['optimization_detective_disabled'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
+	$conditions = array(
+		array(
+			'test'   => od_can_optimize_response(),
+			'reason' => __( 'Page is not optimized because od_can_optimize_response() returned false. This can be overridden with the od_can_optimize_response filter.', 'optimization-detective' ),
+		),
+		array(
+			'test'   => ! od_is_rest_api_unavailable() || ( wp_get_environment_type() === 'local' && ! function_exists( 'tests_add_filter' ) ),
+			'reason' => __( 'Page is not optimized because the REST API for storing URL Metrics is not available.', 'optimization-detective' ),
+		),
+		array(
+			'test'   => ! isset( $_GET['optimization_detective_disabled'] ), // phpcs:ignore WordPress.Security.NonceVerification.Recommended
+			'reason' => __( 'Page is not optimized because the URL has the optimization_detective_disabled query parameter.', 'optimization-detective' ),
+		),
+	);
+	$reasons    = array();
+	foreach ( $conditions as $condition ) {
+		if ( ! $condition['test'] ) {
+			$reasons[] = $condition['reason'];
+		}
+	}
+	if ( count( $reasons ) > 0 ) {
+		if ( WP_DEBUG ) {
+			add_action(
+				'wp_print_footer_scripts',
+				static function () use ( $reasons ): void {
+					od_print_disabled_reasons( $reasons );
+				}
+			);
+		}
 		return;
 	}
+
 	$callback = 'od_optimize_template_output_buffer';
 	if (
 		function_exists( 'perflab_wrap_server_timing' )
@@ -95,6 +126,28 @@
 }
 
 /**
+ * Prints the reasons why Optimization Detective is not optimizing the current page.
+ *
+ * This is only used when WP_DEBUG is enabled.
+ *
+ * @since 1.0.0
+ * @access private
+ *
+ * @param string[] $reasons Reason messages.
+ */
+function od_print_disabled_reasons( array $reasons ): void {
+	foreach ( $reasons as $reason ) {
+		wp_print_inline_script_tag(
+			sprintf(
+				'console.info( %s );',
+				(string) wp_json_encode( '[Optimization Detective] ' . $reason )
+			),
+			array( 'type' => 'module' )
+		);
+	}
+}
+
+/**
  * Determines whether the current response can be optimized.
  *
  * @since 0.1.0
@@ -118,12 +171,6 @@
 		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 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()
@@ -219,7 +266,8 @@
 		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 );
+	$visited_tag_state    = new OD_Visited_Tag_State();
+	$tag_visitor_context  = new OD_Tag_Visitor_Context( $processor, $group_collection, $link_collection, $visited_tag_state );
 	$current_tag_bookmark = 'optimization_detective_current_tag';
 	$visitors             = iterator_to_array( $tag_visitor_registry );
 
@@ -227,12 +275,20 @@
 	$needs_detection = ! $group_collection->is_every_group_complete();
 
 	do {
+		// Never process anything inside NOSCRIPT since it will never show up in the DOM when scripting is enabled, and thus it can never be detected nor measured.
+		if ( in_array( 'NOSCRIPT', $processor->get_breadcrumbs(), true ) ) {
+			continue;
+		}
+
 		$tracked_in_url_metrics = false;
 		$processor->set_bookmark( $current_tag_bookmark ); // TODO: Should we break if this returns false?
 
 		foreach ( $visitors as $visitor ) {
-			$cursor_move_count      = $processor->get_cursor_move_count();
-			$tracked_in_url_metrics = $visitor( $tag_visitor_context ) || $tracked_in_url_metrics;
+			$cursor_move_count    = $processor->get_cursor_move_count();
+			$visitor_return_value = $visitor( $tag_visitor_context );
+			if ( true === $visitor_return_value ) {
+				$tracked_in_url_metrics = true;
+			}
 
 			// If the visitor traversed HTML tags, we need to go back to this tag so that in the next iteration any
 			// relevant tag visitors may apply, in addition to properly setting the data-od-xpath on this tag below.
@@ -242,9 +298,17 @@
 		}
 		$processor->release_bookmark( $current_tag_bookmark );
 
+		if ( $visited_tag_state->is_tag_tracked() ) {
+			$tracked_in_url_metrics = true;
+		}
+
 		if ( $tracked_in_url_metrics && $needs_detection ) {
-			$processor->set_meta_attribute( 'xpath', $processor->get_xpath() );
+			// TODO: Replace get_stored_xpath with get_xpath once the transitional period is over.
+			$xpath = $processor->get_stored_xpath();
+			$processor->set_meta_attribute( 'xpath', $xpath );
 		}
+
+		$visited_tag_state->reset();
 	} while ( $processor->next_open_tag() );
 
 	// Send any preload links in a Link response header and in a LINK tag injected at the end of the HEAD.
Index: readme.txt
===================================================================
--- readme.txt	(revision 3228614)
+++ readme.txt	(working copy)
@@ -2,7 +2,7 @@
 
 Contributors: wordpressdotorg
 Tested up to: 6.7
-Stable tag:   0.9.0
+Stable tag:   1.0.0-beta1
 License:      GPLv2 or later
 License URI:  https://www.gnu.org/licenses/gpl-2.0.html
 Tags:         performance, optimization, rum
@@ -13,291 +13,27 @@
 
 This plugin captures real user metrics about what elements are displayed on the page across a variety of device form factors (e.g. desktop, tablet, and phone) in order to apply loading optimizations which are not possible with WordPress’s current server-side heuristics.
 
-This plugin is a dependency which does not provide end-user functionality on its own. For that, please install the dependent plugin [Image Prioritizer](https://wordpress.org/plugins/image-prioritizer/) or [Embed Optimizer](https://wordpress.org/plugins/embed-optimizer/) (among [others](https://github.com/WordPress/performance/labels/%5BPlugin%5D%20Optimization%20Detective) to come from the WordPress Core Performance team).
+This plugin is a dependency which does not provide end-user functionality on its own. For that, please install the dependent plugin [Image Prioritizer](https://wordpress.org/plugins/image-prioritizer/) or [Embed Optimizer](https://wordpress.org/plugins/embed-optimizer/) (among [others](https://github.com/WordPress/performance/labels/%5BPlugin%5D%20Optimization%20Detective) to come from the WordPress Core Performance team). There are currently **no settings** and no user interface for this plugin since it is designed to work without any configuration.
 
-= Background =
+Your site must have the **REST API accessible** to frontend visitors since this is how metrics are collected about how a page should be optimized.
 
-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.
+Please refer to the [full plugin documentation](https://github.com/WordPress/performance/blob/trunk/plugins/optimization-detective/docs/README.md) for a [technical introduction](https://github.com/WordPress/performance/blob/trunk/plugins/optimization-detective/docs/introduction.md), [filter/action hooks](https://github.com/WordPress/performance/blob/trunk/plugins/optimization-detective/docs/hooks.md), and [extensions](https://github.com/WordPress/performance/blob/trunk/plugins/optimization-detective/docs/extensions.md) that show use cases and examples.
 
-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).
-
-= Technical Foundation =
-
-At the core of Optimization Detective is the “URL Metric”, information about a page according to how it was loaded by a client with a specific viewport width. This includes which elements were visible in the initial viewport and which one was the LCP element. The URL Metric data is also extensible. Each URL on a site can have an associated set of these URL Metrics (stored in a custom post type) which are gathered from the visits of real users. It gathers samples of URL Metrics which are grouped according to WordPress's default responsive breakpoints:
-
-1. Mobile: 0-480px
-2. Phablet: 481-600px
-3. Tablet: 601-782px
-4. Desktop: \>782px
-
-When no more URL Metrics are needed for a URL due to the sample size being obtained for the viewport group, it discontinues serving the JavaScript to gather the metrics (leveraging the [web-vitals.js](https://github.com/GoogleChrome/web-vitals) library). With the URL Metrics in hand, the output-buffered page is sent through the HTML Tag Processor and--when the [Image Prioritizer](https://wordpress.org/plugins/image-prioritizer/) dependent plugin is installed--the images which were the LCP element for various breakpoints will get prioritized with high-priority preload links (along with `fetchpriority=high` on the actual `img` tag when it is the common LCP element across all breakpoints). LCP elements with background images added via inline `background-image` styles are also prioritized with preload links.
-
-URL Metrics have a “freshness TTL” after which they will be stale and the JavaScript will be served again to start gathering metrics again to ensure that the right elements continue to get their loading prioritized. When a URL Metrics custom post type hasn't been touched in a while, it is automatically garbage-collected.
-
-👉 **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 dependent plugin(s)). And since administrator users are not normal visitors typically, optimizations are not applied for admins by default (but this can be overridden with the `od_can_optimize_response` filter below). URL Metrics are not collected for administrators because it is likely that additional elements will be present on the page which are not also shown to non-administrators, meaning the URL Metrics could not reliably be reused between them.
-
-There are currently **no settings** and no user interface for this plugin since it is designed to work without any configuration.
-
-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)
-
-Fires when the Optimization Detective is initializing. This action is useful for loading extension code that depends on Optimization Detective to be running. The version of the plugin is passed as the sole argument so that if the required version is not present, the callback can short circuit.
-
-**Action:** `od_register_tag_visitors` (argument: `OD_Tag_Visitor_Registry`)
-
-Fires to register tag visitors before walking over the document to perform optimizations.
-
-For example, to register a new tag visitor that targets `H1` elements:
-
-`
-<?php
-add_action(
-	'od_register_tag_visitors',
-	static function ( OD_Tag_Visitor_Registry $registry ) {
-		$registry->register(
-			'my-plugin/h1',
-			static function ( OD_Tag_Visitor_Context $context ): bool {
-				if ( $context->processor->get_tag() !== 'H1' ) {
-					return false;
-				}
-				// Now optimize based on stored URL Metrics in $context->url_metric_group_collection.
-				// ...
-
-				// Returning true causes the tag to be tracked in URL Metrics. If there is no need
-				// for this, as in there is no reference to $context->url_metric_group_collection
-				// in a tag visitor, then this can instead return false.
-				return true;
-			}
-		);
-	}
-);
-`
-
-Refer to [Image Prioritizer](https://github.com/WordPress/performance/tree/trunk/plugins/image-prioritizer) and [Embed Optimizer](https://github.com/WordPress/performance/tree/trunk/plugins/embed-optimizer) for real world examples of how tag visitors are used. Registered tag visitors need only be callables, so in addition to providing a closure you may provide a `callable-string` or even a class which has an `__invoke()` method.
-
-**Filter:** `od_breakpoint_max_widths` (default: `array(480, 600, 782)`)
-
-Filters the breakpoint max widths to group URL Metrics for various viewports. Each number represents the maximum width (inclusive) for a given breakpoint. So if there is one number, 480, then this means there will be two viewport groupings, one for 0\<=480, and another \>480. If instead there are the two breakpoints defined, 480 and 782, then this means there will be three viewport groups of URL Metrics, one for 0\<=480 (i.e. mobile), another 481\<=782 (i.e. phablet/tablet), and another \>782 (i.e. desktop).
-
-These default breakpoints are reused from Gutenberg which appear to be used the most in media queries that affect frontend styles.
-
-**Filter:** `od_can_optimize_response` (default: boolean condition, see below)
-
-Filters whether the current response can be optimized. By default, detection and optimization are only performed when:
-
-1. It’s not a search template (`is_search()`).
-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' )`), 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.
-
-To force every response to be optimized regardless of the conditions above, you can do:
-
-`
-<?php
-add_filter( 'od_can_optimize_response', '__return_true' );
-`
-
-**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. 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
-add_filter( 'od_url_metrics_breakpoint_sample_size', function (): int {
-	return 1;
-} );
-`
-
-**Filter:** `od_url_metric_storage_lock_ttl` (default: 1 minute in seconds)
-
-Filters how long a given IP is locked from submitting another metric-storage REST API request. Filtering the TTL to zero will disable any metric storage locking. This is useful, for example, to disable locking when a user is logged-in with code like the following:
-
-`
-<?php
-add_filter( 'od_metrics_storage_lock_ttl', function ( int $ttl ): int {
-    return is_user_logged_in() ? 0 : $ttl;
-} );
-`
-
-**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. 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', 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). 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.
-
-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 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', static function (): int {
-	return 5;
-} );
-`
-
-**Filter:** `od_template_output_buffer` (default: the HTML response)
-
-Filters the template output buffer prior to sending to the client. This filter is added to implement [\#43258](https://core.trac.wordpress.org/ticket/43258) in WordPress core.
-
-**Filter:** `od_url_metric_schema_element_item_additional_properties` (default: empty array)
-
-Filters additional schema properties which should be allowed for an element's item in a URL Metric.
-
-For example to add a `resizedBoundingClientRect` property:
-
-`
-<?php
-add_filter(
-	'od_url_metric_schema_element_item_additional_properties',
-	static function ( array $additional_properties ): array {
-		$additional_properties['resizedBoundingClientRect'] = array(
-			'type'       => 'object',
-			'properties' => array_fill_keys(
-				array(
-					'width',
-					'height',
-					'x',
-					'y',
-					'top',
-					'right',
-					'bottom',
-					'left',
-				),
-				array(
-					'type'     => 'number',
-					'required' => true,
-				)
-			),
-		);
-		return $additional_properties;
-	}
-);
-`
-
-See also [example usage](https://github.com/WordPress/performance/blob/6bb8405c5c446e3b66c2bfa3ae03ba61b188bca2/plugins/embed-optimizer/hooks.php#L81-L110) in Embed Optimizer.
-
-**Filter:** `od_url_metric_schema_root_additional_properties` (default: empty array)
-
-Filters additional schema properties which should be allowed at the root of a URL Metric.
-
-The usage here is the same as the previous filter, except it allows new properties to be added to the root of the URL Metric and not just to one of the object items in the `elements` property.
-
-**Filter:** `od_extension_module_urls` (default: empty array of strings)
-
-Filters the list of extension script module URLs to import when performing detection.
-
-For example:
-
-`
-<?php
-add_filter(
-	'od_extension_module_urls',
-	static function ( array $extension_module_urls ): array {
-		$extension_module_urls[] = add_query_arg( 'ver', '1.0', plugin_dir_url( __FILE__ ) . 'detect.js' );
-		return $extension_module_urls;
-	}
-);
-`
-
-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.
-
-The supplied context object includes these properties:
-
-* `$request`: The `WP_REST_Request` for storing the URL Metric.
-* `$post_id`: The post ID for the `od_url_metric` post.
-* `$url_metric`: The newly-stored URL Metric.
-* `$url_metric_group`: The viewport group that the URL Metric was added to.
-* `$url_metric_group_collection`: The `OD_URL_Metric_Group_Collection` instance to which the URL Metric was added.
-
 == Installation ==
 
-= Installation from within WordPress =
+= Installation from the directory within WordPress =
 
-1. Visit **Plugins > Add New**.
+1. Visit **Plugins > Add New** in the WordPress Admin.
 2. Search for **Optimization Detective**.
 3. Install and activate the **Optimization Detective** plugin.
 
 = Manual installation =
 
-1. Upload the entire `optimization-detective` folder to the `/wp-content/plugins/` directory.
-2. Visit **Plugins**.
-3. Activate the **Optimization Detective** plugin.
+1. Download the plugin [ZIP from WordPress.org](https://downloads.wordpress.org/plugin/optimization-detective.zip) or, after following the [Getting Started instructions](https://make.wordpress.org/performance/handbook/performance-lab/), create a ZIP build from a clone of the [GitHub repo](https://github.com/WordPress/performance) via `npm run build:plugin:optimization-detective --env zip=true`.
+2. Visit **Plugins > Add New Plugin** in the WordPress Admin.
+3. Click **Upload Plugin**
+4. Select the `optimization-detective.zip` file on your system from step 1 and click **Install Now**.
+5. Click the **Active Plugin** button.
 
 == Frequently Asked Questions ==
 
@@ -319,6 +55,28 @@
 
 == Changelog ==
 
+= 1.0.0-beta1 =
+
+**Enhancements**
+
+* Add site health check to detect blocked REST API and short-circuit optimization when unavailable. ([1762](https://github.com/WordPress/performance/pull/1762))
+* Allow extensions to opt in to using the web-vitals attribution build via the `od_use_web_vitals_attribution_build` filter. ([1759](https://github.com/WordPress/performance/pull/1759))
+* Disambiguate XPaths for children of `BODY` with `id`, `class`, or `role` attributes. ([1797](https://github.com/WordPress/performance/pull/1797))
+* Eliminate varying URL Metrics by logged-in state and discontinue disabling optimization by default for admins. ([1788](https://github.com/WordPress/performance/pull/1788))
+* Improve test coverage for Optimization Detective. ([1817](https://github.com/WordPress/performance/pull/1817))
+* Introduce `OD_Tag_Visitor_Context::track_tag()` method as alternative for returning `true` in tag visitor callback. ([1821](https://github.com/WordPress/performance/pull/1821))
+* Omit element node index in XPaths up to children of BODY. ([1790](https://github.com/WordPress/performance/pull/1790))
+* Skip visiting tags in the Admin Bar when optimizing a page. ([1816](https://github.com/WordPress/performance/pull/1816))
+
+**Bug Fixes**
+
+* Ensure optimization is performed in the wp-env local environment and log debug messages to console when disabled. ([1822](https://github.com/WordPress/performance/pull/1822))
+* Skip visiting any tags inside of `NOSCRIPT` elements. ([1783](https://github.com/WordPress/performance/pull/1783))
+
+**Documentation**
+
+* Move Optimization Detective docs into [`README.md`](https://github.com/WordPress/performance/tree/trunk/plugins/optimization-detective/docs). ([1763](https://github.com/WordPress/performance/pull/1763))
+
 = 0.9.0 =
 
 **Enhancements**
Index: storage/class-od-storage-lock.php
===================================================================
--- storage/class-od-storage-lock.php	(revision 3228614)
+++ storage/class-od-storage-lock.php	(working copy)
@@ -6,10 +6,11 @@
  * @since 0.1.0
  */
 
-// Exit if accessed directly.
+// @codeCoverageIgnoreStart
 if ( ! defined( 'ABSPATH' ) ) {
-	exit;
+	exit; // Exit if accessed directly.
 }
+// @codeCoverageIgnoreEnd
 
 /**
  * Class containing logic for locking storage for new URL Metrics.
Index: storage/class-od-url-metric-store-request-context.php
===================================================================
--- storage/class-od-url-metric-store-request-context.php	(revision 3228614)
+++ storage/class-od-url-metric-store-request-context.php	(working copy)
@@ -6,10 +6,11 @@
  * @since 0.7.0
  */
 
-// Exit if accessed directly.
+// @codeCoverageIgnoreStart
 if ( ! defined( 'ABSPATH' ) ) {
-	exit;
+	exit; // Exit if accessed directly.
 }
+// @codeCoverageIgnoreEnd
 
 /**
  * Context for when a URL Metric is successfully stored via the REST API.
Index: storage/class-od-url-metrics-post-type.php
===================================================================
--- storage/class-od-url-metrics-post-type.php	(revision 3228614)
+++ storage/class-od-url-metrics-post-type.php	(working copy)
@@ -6,9 +6,11 @@
  * @since 0.1.0
  */
 
+// @codeCoverageIgnoreStart
 if ( ! defined( 'ABSPATH' ) ) {
 	exit; // Exit if accessed directly.
 }
+// @codeCoverageIgnoreEnd
 
 /**
  * URL Metrics Post Type.
Index: storage/data.php
===================================================================
--- storage/data.php	(revision 3228614)
+++ storage/data.php	(working copy)
@@ -6,9 +6,11 @@
  * @since 0.1.0
  */
 
+// @codeCoverageIgnoreStart
 if ( ! defined( 'ABSPATH' ) ) {
 	exit; // Exit if accessed directly.
 }
+// @codeCoverageIgnoreEnd
 
 /**
  * Gets the freshness age (TTL) for a given URL Metric.
@@ -77,11 +79,6 @@
 		);
 	}
 
-	// Vary URL Metrics by whether the user is logged in since additional elements may be present.
-	if ( is_user_logged_in() ) {
-		$normalized_query_vars['user_logged_in'] = true;
-	}
-
 	return $normalized_query_vars;
 }
 
@@ -203,6 +200,7 @@
 	}
 
 	$data = array(
+		'xpath_version'    => 2, // Bump whenever a major change to the XPath format occurs so that new URL Metrics are proactively gathered.
 		'tag_visitors'     => array_keys( iterator_to_array( $tag_visitor_registry ) ),
 		'queried_object'   => $queried_object_data,
 		'queried_posts'    => array_filter(
Index: storage/rest-api.php
===================================================================
--- storage/rest-api.php	(revision 3228614)
+++ storage/rest-api.php	(working copy)
@@ -6,9 +6,11 @@
  * @since 0.1.0
  */
 
+// @codeCoverageIgnoreStart
 if ( ! defined( 'ABSPATH' ) ) {
 	exit; // Exit if accessed directly.
 }
+// @codeCoverageIgnoreEnd
 
 /**
  * Namespace for optimization-detective.
@@ -34,6 +36,8 @@
  *
  * @since 0.1.0
  * @access private
+ *
+ * @see od_compose_site_health_result()
  */
 function od_register_endpoint(): void {
 
@@ -164,7 +168,7 @@
 		);
 	} 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() );
+		return new WP_Error( 'invalid_viewport_width', $exception->getMessage() ); // @codeCoverageIgnore
 	}
 	if ( $url_metric_group->is_complete() ) {
 		return new WP_Error(
@@ -275,7 +279,7 @@
  * @since 0.8.0
  * @access private
  *
- * @param int $cache_purge_post_id Cache purge post ID.
+ * @param positive-int $cache_purge_post_id Cache purge post ID.
  */
 function od_trigger_page_cache_invalidation( int $cache_purge_post_id ): void {
 	$post = get_post( $cache_purge_post_id );
Index: types.ts
===================================================================
--- types.ts	(revision 3228614)
+++ types.ts	(working copy)
@@ -2,6 +2,13 @@
 type ExcludeProps< T > = { [ k: string ]: any } & { [ K in keyof T ]?: never };
 
 import { onTTFB, onFCP, onLCP, onINP, onCLS } from 'web-vitals';
+import {
+	onTTFB as onTTFBWithAttribution,
+	onFCP as onFCPWithAttribution,
+	onLCP as onLCPWithAttribution,
+	onINP as onINPWithAttribution,
+	onCLS as onCLSWithAttribution,
+} from 'web-vitals/attribution';
 
 export interface ElementData {
 	isLCP: boolean;
@@ -35,14 +42,19 @@
 export type OnLCPFunction = typeof onLCP;
 export type OnINPFunction = typeof onINP;
 export type OnCLSFunction = typeof onCLS;
+export type OnTTFBWithAttributionFunction = typeof onTTFBWithAttribution;
+export type OnFCPWithAttributionFunction = typeof onFCPWithAttribution;
+export type OnLCPWithAttributionFunction = typeof onLCPWithAttribution;
+export type OnINPWithAttributionFunction = typeof onINPWithAttribution;
+export type OnCLSWithAttributionFunction = typeof onCLSWithAttribution;
 
 export type InitializeArgs = {
 	readonly isDebug: boolean;
-	readonly onTTFB: OnTTFBFunction;
-	readonly onFCP: OnFCPFunction;
-	readonly onLCP: OnLCPFunction;
-	readonly onINP: OnINPFunction;
-	readonly onCLS: OnCLSFunction;
+	readonly onTTFB: OnTTFBFunction | OnTTFBWithAttributionFunction;
+	readonly onFCP: OnFCPFunction | OnFCPWithAttributionFunction;
+	readonly onLCP: OnLCPFunction | OnLCPWithAttributionFunction;
+	readonly onINP: OnINPFunction | OnINPWithAttributionFunction;
+	readonly onCLS: OnCLSFunction | OnCLSWithAttributionFunction;
 };
 
 export type InitializeCallback = ( args: InitializeArgs ) => Promise< void >;
Index: uninstall.php
===================================================================
--- uninstall.php	(revision 3228614)
+++ uninstall.php	(working copy)
@@ -8,7 +8,7 @@
 
 // If uninstall.php is not called by WordPress, bail.
 if ( ! defined( 'WP_UNINSTALL_PLUGIN' ) ) {
-	exit;
+	exit; // @codeCoverageIgnore
 }
 
 require_once __DIR__ . '/storage/class-od-url-metrics-post-type.php';
@@ -17,6 +17,10 @@
 	// Delete all URL Metrics posts for the current site.
 	OD_URL_Metrics_Post_Type::delete_all_posts();
 	wp_unschedule_hook( OD_URL_Metrics_Post_Type::GC_CRON_EVENT_NAME );
+
+	// Clear out site health check data.
+	delete_option( 'od_rest_api_unavailable' );
+	delete_transient( 'od_rest_api_health_check_response' );
 };
 
 $od_delete_site_data();

performance-lab

Important

Stable tag change: 3.7.0 → 3.8.0

svn status:

M       includes/admin/load.php
M       includes/admin/plugins.php
M       includes/admin/rest-api.php
M       includes/admin/server-timing.php
M       includes/server-timing/defaults.php
M       includes/server-timing/hooks.php
M       includes/server-timing/load.php
M       includes/server-timing/object-cache.copy.php
M       includes/site-health/audit-autoloaded-options/helper.php
M       includes/site-health/audit-autoloaded-options/hooks.php
M       includes/site-health/audit-enqueued-assets/helper.php
M       includes/site-health/audit-enqueued-assets/hooks.php
M       includes/site-health/avif-headers/helper.php
M       includes/site-health/avif-headers/hooks.php
M       includes/site-health/avif-support/helper.php
M       includes/site-health/avif-support/hooks.php
?       includes/site-health/bfcache-compatibility-headers
?       includes/site-health/effective-asset-cache-headers
M       includes/site-health/load.php
M       includes/site-health/webp-support/helper.php
M       includes/site-health/webp-support/hooks.php
M       load.php
M       readme.txt
svn diff
Index: includes/admin/load.php
===================================================================
--- includes/admin/load.php	(revision 3228614)
+++ includes/admin/load.php	(working copy)
@@ -5,9 +5,11 @@
  * @package performance-lab
  */
 
+// @codeCoverageIgnoreStart
 if ( ! defined( 'ABSPATH' ) ) {
 	exit; // Exit if accessed directly.
 }
+// @codeCoverageIgnoreEnd
 
 /**
  * Adds the features page to the Settings menu.
@@ -264,7 +266,7 @@
 	// Enqueue plugin activate AJAX script and localize script data.
 	wp_enqueue_script(
 		'perflab-plugin-activate-ajax',
-		plugin_dir_url( PERFLAB_MAIN_FILE ) . perflab_get_asset_path( 'includes/admin/plugin-activate-ajax.js' ),
+		plugins_url( perflab_get_asset_path( 'includes/admin/plugin-activate-ajax.js' ), PERFLAB_MAIN_FILE ),
 		array( 'wp-i18n', 'wp-a11y', 'wp-api-fetch' ),
 		PERFLAB_VERSION,
 		true
Index: includes/admin/plugins.php
===================================================================
--- includes/admin/plugins.php	(revision 3228614)
+++ includes/admin/plugins.php	(working copy)
@@ -6,9 +6,11 @@
  * @noinspection PhpRedundantOptionalArgumentInspection
  */
 
+// @codeCoverageIgnoreStart
 if ( ! defined( 'ABSPATH' ) ) {
 	exit; // Exit if accessed directly.
 }
+// @codeCoverageIgnoreEnd
 
 /**
  * Gets plugin info for the given plugin slug from WordPress.org.
Index: includes/admin/rest-api.php
===================================================================
--- includes/admin/rest-api.php	(revision 3228614)
+++ includes/admin/rest-api.php	(working copy)
@@ -6,9 +6,11 @@
  * @since 3.6.0
  */
 
+// @codeCoverageIgnoreStart
 if ( ! defined( 'ABSPATH' ) ) {
 	exit; // Exit if accessed directly.
 }
+// @codeCoverageIgnoreEnd
 
 /**
  * Namespace for performance-lab REST API.
Index: includes/admin/server-timing.php
===================================================================
--- includes/admin/server-timing.php	(revision 3228614)
+++ includes/admin/server-timing.php	(working copy)
@@ -5,9 +5,11 @@
  * @package performance-lab
  */
 
+// @codeCoverageIgnoreStart
 if ( ! defined( 'ABSPATH' ) ) {
 	exit; // Exit if accessed directly.
 }
+// @codeCoverageIgnoreEnd
 
 // Do not add any of the hooks if Server-Timing is disabled.
 if ( defined( 'PERFLAB_DISABLE_SERVER_TIMING' ) && PERFLAB_DISABLE_SERVER_TIMING ) {
Index: includes/server-timing/defaults.php
===================================================================
--- includes/server-timing/defaults.php	(revision 3228614)
+++ includes/server-timing/defaults.php	(working copy)
@@ -6,9 +6,11 @@
  * @since 1.8.0
  */
 
+// @codeCoverageIgnoreStart
 if ( ! defined( 'ABSPATH' ) ) {
 	exit; // Exit if accessed directly.
 }
+// @codeCoverageIgnoreEnd
 
 // Do not add any of the hooks if Server-Timing is disabled.
 if ( defined( 'PERFLAB_DISABLE_SERVER_TIMING' ) && PERFLAB_DISABLE_SERVER_TIMING ) {
Index: includes/server-timing/hooks.php
===================================================================
--- includes/server-timing/hooks.php	(revision 3228614)
+++ includes/server-timing/hooks.php	(working copy)
@@ -7,9 +7,11 @@
  * @since 3.1.0
  */
 
+// @codeCoverageIgnoreStart
 if ( ! defined( 'ABSPATH' ) ) {
 	exit; // Exit if accessed directly.
 }
+// @codeCoverageIgnoreEnd
 
 /**
  * Adds server timing to REST API response.
Index: includes/server-timing/load.php
===================================================================
--- includes/server-timing/load.php	(revision 3228614)
+++ includes/server-timing/load.php	(working copy)
@@ -6,9 +6,11 @@
  * @since 1.8.0
  */
 
+// @codeCoverageIgnoreStart
 if ( ! defined( 'ABSPATH' ) ) {
 	exit; // Exit if accessed directly.
 }
+// @codeCoverageIgnoreEnd
 
 // Do not add any of the hooks if Server-Timing is disabled.
 if ( defined( 'PERFLAB_DISABLE_SERVER_TIMING' ) && PERFLAB_DISABLE_SERVER_TIMING ) {
Index: includes/server-timing/object-cache.copy.php
===================================================================
--- includes/server-timing/object-cache.copy.php	(revision 3228614)
+++ includes/server-timing/object-cache.copy.php	(working copy)
@@ -28,9 +28,11 @@
  * @since 1.8.0
  */
 
+// @codeCoverageIgnoreStart
 if ( ! defined( 'ABSPATH' ) ) {
 	exit; // Exit if accessed directly.
 }
+// @codeCoverageIgnoreEnd
 
 // Set constant to be able to later check for whether this file was loaded.
 if ( ! defined( 'PERFLAB_OBJECT_CACHE_DROPIN_VERSION' ) ) {
Index: includes/site-health/audit-autoloaded-options/helper.php
===================================================================
--- includes/site-health/audit-autoloaded-options/helper.php	(revision 3228614)
+++ includes/site-health/audit-autoloaded-options/helper.php	(working copy)
@@ -6,9 +6,11 @@
  * @since 2.1.0
  */
 
+// @codeCoverageIgnoreStart
 if ( ! defined( 'ABSPATH' ) ) {
 	exit; // Exit if accessed directly.
 }
+// @codeCoverageIgnoreEnd
 
 /**
  * Callback for autoloaded_options test.
Index: includes/site-health/audit-autoloaded-options/hooks.php
===================================================================
--- includes/site-health/audit-autoloaded-options/hooks.php	(revision 3228614)
+++ includes/site-health/audit-autoloaded-options/hooks.php	(working copy)
@@ -6,9 +6,11 @@
  * @since 2.1.0
  */
 
+// @codeCoverageIgnoreStart
 if ( ! defined( 'ABSPATH' ) ) {
 	exit; // Exit if accessed directly.
 }
+// @codeCoverageIgnoreEnd
 
 /**
  * Adds test to site health.
Index: includes/site-health/audit-enqueued-assets/helper.php
===================================================================
--- includes/site-health/audit-enqueued-assets/helper.php	(revision 3228614)
+++ includes/site-health/audit-enqueued-assets/helper.php	(working copy)
@@ -6,9 +6,11 @@
  * @since 1.0.0
  */
 
+// @codeCoverageIgnoreStart
 if ( ! defined( 'ABSPATH' ) ) {
 	exit; // Exit if accessed directly.
 }
+// @codeCoverageIgnoreEnd
 
 /**
  * Callback for enqueued_js_assets test.
Index: includes/site-health/audit-enqueued-assets/hooks.php
===================================================================
--- includes/site-health/audit-enqueued-assets/hooks.php	(revision 3228614)
+++ includes/site-health/audit-enqueued-assets/hooks.php	(working copy)
@@ -6,9 +6,11 @@
  * @since 2.1.0
  */
 
+// @codeCoverageIgnoreStart
 if ( ! defined( 'ABSPATH' ) ) {
 	exit; // Exit if accessed directly.
 }
+// @codeCoverageIgnoreEnd
 
 /**
  * Audit enqueued and printed scripts in is_front_page(). Ignore /wp-includes scripts.
Index: includes/site-health/avif-headers/helper.php
===================================================================
--- includes/site-health/avif-headers/helper.php	(revision 3228614)
+++ includes/site-health/avif-headers/helper.php	(working copy)
@@ -6,9 +6,11 @@
  * @since 3.6.0
  */
 
+// @codeCoverageIgnoreStart
 if ( ! defined( 'ABSPATH' ) ) {
 	exit; // Exit if accessed directly.
 }
+// @codeCoverageIgnoreEnd
 
 /**
  * Callback for avif_headers test.
Index: includes/site-health/avif-headers/hooks.php
===================================================================
--- includes/site-health/avif-headers/hooks.php	(revision 3228614)
+++ includes/site-health/avif-headers/hooks.php	(working copy)
@@ -6,9 +6,11 @@
  * @since 3.6.0
  */
 
+// @codeCoverageIgnoreStart
 if ( ! defined( 'ABSPATH' ) ) {
 	exit; // Exit if accessed directly.
 }
+// @codeCoverageIgnoreEnd
 
 /**
  * Adds tests to site health.
Index: includes/site-health/avif-support/helper.php
===================================================================
--- includes/site-health/avif-support/helper.php	(revision 3228614)
+++ includes/site-health/avif-support/helper.php	(working copy)
@@ -6,9 +6,11 @@
  * @since 3.1.0
  */
 
+// @codeCoverageIgnoreStart
 if ( ! defined( 'ABSPATH' ) ) {
 	exit; // Exit if accessed directly.
 }
+// @codeCoverageIgnoreEnd
 
 /**
  * Callback for avif_enabled test.
Index: includes/site-health/avif-support/hooks.php
===================================================================
--- includes/site-health/avif-support/hooks.php	(revision 3228614)
+++ includes/site-health/avif-support/hooks.php	(working copy)
@@ -6,9 +6,11 @@
  * @since 3.1.0
  */
 
+// @codeCoverageIgnoreStart
 if ( ! defined( 'ABSPATH' ) ) {
 	exit; // Exit if accessed directly.
 }
+// @codeCoverageIgnoreEnd
 
 /**
  * Adds tests to site health.
Index: includes/site-health/load.php
===================================================================
--- includes/site-health/load.php	(revision 3228614)
+++ includes/site-health/load.php	(working copy)
@@ -6,9 +6,11 @@
  * @since 3.0.0
  */
 
+// @codeCoverageIgnoreStart
 if ( ! defined( 'ABSPATH' ) ) {
 	exit; // Exit if accessed directly.
 }
+// @codeCoverageIgnoreEnd
 
 // Audit Autoloaded Options site health check.
 require_once __DIR__ . '/audit-autoloaded-options/helper.php';
@@ -29,3 +31,11 @@
 // AVIF headers site health check.
 require_once __DIR__ . '/avif-headers/helper.php';
 require_once __DIR__ . '/avif-headers/hooks.php';
+
+// Effective Asset Cache Headers site health check.
+require_once __DIR__ . '/effective-asset-cache-headers/helper.php';
+require_once __DIR__ . '/effective-asset-cache-headers/hooks.php';
+
+// Cache-Control headers site health check.
+require_once __DIR__ . '/bfcache-compatibility-headers/helper.php';
+require_once __DIR__ . '/bfcache-compatibility-headers/hooks.php';
Index: includes/site-health/webp-support/helper.php
===================================================================
--- includes/site-health/webp-support/helper.php	(revision 3228614)
+++ includes/site-health/webp-support/helper.php	(working copy)
@@ -6,9 +6,11 @@
  * @since 2.1.0
  */
 
+// @codeCoverageIgnoreStart
 if ( ! defined( 'ABSPATH' ) ) {
 	exit; // Exit if accessed directly.
 }
+// @codeCoverageIgnoreEnd
 
 /**
  * Callback for webp_enabled test.
Index: includes/site-health/webp-support/hooks.php
===================================================================
--- includes/site-health/webp-support/hooks.php	(revision 3228614)
+++ includes/site-health/webp-support/hooks.php	(working copy)
@@ -6,9 +6,11 @@
  * @since 2.1.0
  */
 
+// @codeCoverageIgnoreStart
 if ( ! defined( 'ABSPATH' ) ) {
 	exit; // Exit if accessed directly.
 }
+// @codeCoverageIgnoreEnd
 
 /**
  * Adds tests to site health.
Index: load.php
===================================================================
--- load.php	(revision 3228614)
+++ load.php	(working copy)
@@ -5,7 +5,7 @@
  * Description: Performance plugin from the WordPress Performance Team, which is a collection of standalone performance features.
  * Requires at least: 6.6
  * Requires PHP: 7.2
- * Version: 3.7.0
+ * Version: 3.8.0
  * Author: WordPress Performance Team
  * Author URI: https://make.wordpress.org/performance/
  * License: GPLv2 or later
@@ -15,11 +15,13 @@
  * @package performance-lab
  */
 
+// @codeCoverageIgnoreStart
 if ( ! defined( 'ABSPATH' ) ) {
 	exit; // Exit if accessed directly.
 }
+// @codeCoverageIgnoreEnd
 
-define( 'PERFLAB_VERSION', '3.7.0' );
+define( 'PERFLAB_VERSION', '3.8.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 3228614)
+++ readme.txt	(working copy)
@@ -2,7 +2,7 @@
 
 Contributors: wordpressdotorg
 Tested up to: 6.7
-Stable tag:   3.7.0
+Stable tag:   3.8.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,14 @@
 
 == Changelog ==
 
+= 3.8.0 =
+
+**Enhancements**
+
+* Add Site Health check for `Cache-Control: no-store` page response header which disables bfcache. ([1807](https://github.com/WordPress/performance/pull/1807))
+* Add Site Health test to verify that static assets are served with far-future expires. ([1727](https://github.com/WordPress/performance/pull/1727))
+* Enqueue scripts using `plugins_url()` instead of `plugin_dir_url()`. ([1761](https://github.com/WordPress/performance/pull/1761))
+
 = 3.7.0 =
 
 **Enhancements**

speculation-rules

Important

Stable tag change: 1.3.1 → 1.4.0

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 3228614)
+++ class-plsr-url-pattern-prefixer.php	(working copy)
@@ -6,10 +6,11 @@
  * @since 1.0.0
  */
 
-// Exit if accessed directly.
+// @codeCoverageIgnoreStart
 if ( ! defined( 'ABSPATH' ) ) {
-	exit;
+	exit; // Exit if accessed directly.
 }
+// @codeCoverageIgnoreEnd
 
 /**
  * Class for prefixing URL patterns.
@@ -35,7 +36,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 3228614)
+++ helper.php	(working copy)
@@ -6,10 +6,11 @@
  * @since 1.0.0
  */
 
-// Exit if accessed directly.
+// @codeCoverageIgnoreStart
 if ( ! defined( 'ABSPATH' ) ) {
-	exit;
+	exit; // Exit if accessed directly.
 }
+// @codeCoverageIgnoreEnd
 
 /**
  * Returns the speculation rules.
@@ -19,30 +20,18 @@
  *
  * @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();
 
 	$base_href_exclude_paths = array(
-		$prefixer->prefix_path_pattern( '/wp-login.php', 'site' ),
+		$prefixer->prefix_path_pattern( '/wp-*.php', 'site' ),
 		$prefixer->prefix_path_pattern( '/wp-admin/*', 'site' ),
-		$prefixer->prefix_path_pattern( '/*\\?*(^|&)_wpnonce=*', 'home' ),
 		$prefixer->prefix_path_pattern( '/*', 'uploads' ),
 		$prefixer->prefix_path_pattern( '/*', 'content' ),
 		$prefixer->prefix_path_pattern( '/*', 'plugins' ),
@@ -50,11 +39,20 @@
 		$prefixer->prefix_path_pattern( '/*', 'stylesheet' ),
 	);
 
+	/*
+	 * If pretty permalinks are enabled, exclude any URLs with query parameters.
+	 * Otherwise, exclude specifically the URLs with a `_wpnonce` query parameter.
+	 */
+	if ( (bool) get_option( 'permalink_structure' ) ) {
+		$base_href_exclude_paths[] = $prefixer->prefix_path_pattern( '/*\\?(.+)', 'home' );
+	} else {
+		$base_href_exclude_paths[] = $prefixer->prefix_path_pattern( '/*\\?*(^|&)_wpnonce=*', 'home' );
+	}
+
 	/**
 	 * Filters the paths for which speculative prerendering should be disabled.
 	 *
 	 * All paths should start in a forward slash, relative to the root document. The `*` can be used as a wildcard.
-	 * By default, the array includes `/wp-login.php` and `/wp-admin/*`.
 	 *
 	 * If the WordPress site is in a subdirectory, the exclude paths will automatically be prefixed as necessary.
 	 *
Index: hooks.php
===================================================================
--- hooks.php	(revision 3228614)
+++ hooks.php	(working copy)
@@ -6,10 +6,11 @@
  * @since 1.0.0
  */
 
-// Exit if accessed directly.
+// @codeCoverageIgnoreStart
 if ( ! defined( 'ABSPATH' ) ) {
-	exit;
+	exit; // Exit if accessed directly.
 }
+// @codeCoverageIgnoreEnd
 
 /**
  * Prints the speculation rules.
@@ -19,30 +20,38 @@
  * @since 1.0.0
  */
 function plsr_print_speculation_rules(): void {
-	$rules = plsr_get_speculation_rules();
-	if ( empty( $rules ) ) {
+	// Skip speculative loading for logged-in users.
+	if ( is_user_logged_in() ) {
 		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' ) );
+	// Skip speculative loading for sites without pretty permalinks, unless explicitly enabled.
+	if ( ! (bool) get_option( 'permalink_structure' ) ) {
+		/**
+		 * Filters whether speculative loading should be enabled even though the site does not use pretty permalinks.
+		 *
+		 * Since query parameters are commonly used by plugins for dynamic behavior that can change state, ideally any
+		 * such URLs are excluded from speculative loading. If the site does not use pretty permalinks though, they are
+		 * impossible to recognize. Therefore speculative loading is disabled by default for those sites.
+		 *
+		 * For site owners of sites without pretty permalinks that are certain their site is not using such a pattern,
+		 * this filter can be used to still enable speculative loading at their own risk.
+		 *
+		 * @since 1.4.0
+		 *
+		 * @param bool $enabled Whether speculative loading is enabled even without pretty permalinks.
+		 */
+		$enabled = (bool) apply_filters( 'plsr_enabled_without_pretty_permalinks', false );
+
+		if ( ! $enabled ) {
+			return;
+		}
 	}
 
 	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 3228614)
+++ load.php	(working copy)
@@ -2,10 +2,10 @@
 /**
  * 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
+ * Version: 1.4.0
  * Author: WordPress Performance Team
  * Author URI: https://make.wordpress.org/performance/
  * License: GPLv2 or later
@@ -15,10 +15,11 @@
  * @package speculation-rules
  */
 
-// Exit if accessed directly.
+// @codeCoverageIgnoreStart
 if ( ! defined( 'ABSPATH' ) ) {
-	exit;
+	exit; // Exit if accessed directly.
 }
+// @codeCoverageIgnoreEnd
 
 (
 	/**
@@ -65,7 +66,7 @@
 	}
 )(
 	'plsr_pending_plugin_info',
-	'1.3.1',
+	'1.4.0',
 	static function ( string $version ): void {
 
 		// Define the constant.
Index: readme.txt
===================================================================
--- readme.txt	(revision 3228614)
+++ 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.4.0
+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 {
@@ -88,15 +82,26 @@
 
 As mentioned above, adding the `no-prerender` CSS class to a link will prevent it from being prerendered (but not prefetched). Additionally, links with `rel=nofollow` will neither be prefetched nor prerendered because some plugins add this to non-idempotent links (e.g. add to cart); such links ideally should rather be buttons which trigger a POST request or at least they should use `wp_nonce_url()`.
 
+= Are there any special considerations for speculative loading behavior? =
+
+For safety reasons, the entire speculative loading feature is disabled by default for logged-in users and for sites that do not use pretty permalinks. The latter is the case because plugins often use URLs with custom query parameters to let users perform actions, and such URLs should not be speculatively loaded. For sites without pretty permalinks, it is impossible or at least extremely complex to differentiate between which query parameters are Core defaults and which query parameters are custom.
+
+If you are running this plugin on a site without pretty permalinks and are confident that there are no custom query parameters in use that can cause state changes, you can opt in to enabling speculative loading via the `plsr_enabled_without_pretty_permalinks` filter:
+
+`
+<?php
+add_filter( 'plsr_enabled_without_pretty_permalinks', '__return_true' );
+`
+
 = How will this impact analytics and personalization? =
 
 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? =
 
@@ -114,6 +119,12 @@
 
 == Changelog ==
 
+= 1.4.0 =
+
+**Enhancements**
+
+* Implement speculative loading considerations for safer behavior. ([1784](https://github.com/WordPress/performance/pull/1784))
+
 = 1.3.1 =
 
 **Bug Fixes**
Index: settings.php
===================================================================
--- settings.php	(revision 3228614)
+++ settings.php	(working copy)
@@ -6,10 +6,11 @@
  * @since 1.0.0
  */
 
-// Exit if accessed directly.
+// @codeCoverageIgnoreStart
 if ( ! defined( 'ABSPATH' ) ) {
-	exit;
+	exit; // Exit if accessed directly.
 }
+// @codeCoverageIgnoreEnd
 
 /**
  * Returns the available options for the Speculative Loading mode and their labels.
@@ -16,7 +17,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 +31,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 +46,7 @@
  *
  * @since 1.0.0
  *
- * @return array<string, string> {
+ * @return array{ mode: 'prerender', eagerness: 'moderate' } {
  *     Default setting value.
  *
  *     @type string $mode      Mode.
@@ -60,12 +61,29 @@
 }
 
 /**
+ * Returns the stored setting value for Speculative Loading configuration.
+ *
+ * @since 1.4.0
+ *
+ * @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 +97,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 +128,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 +141,7 @@
 							'enum'        => array_keys( plsr_get_eagerness_labels() ),
 						),
 					),
+					'additionalProperties' => false,
 				),
 			),
 		)
@@ -188,7 +205,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 +214,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 +243,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

Warning

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

svn status:

M       helper.php
M       hooks.php
M       load.php
M       third-party/google-site-kit.php
M       third-party/seo-by-rank-math.php
M       third-party/woocommerce.php
M       third-party.php
svn diff
Index: helper.php
===================================================================
--- helper.php	(revision 3228614)
+++ helper.php	(working copy)
@@ -6,9 +6,11 @@
  * @package web-worker-offloading
  */
 
+// @codeCoverageIgnoreStart
 if ( ! defined( 'ABSPATH' ) ) {
 	exit; // Exit if accessed directly.
 }
+// @codeCoverageIgnoreEnd
 
 /**
  * Gets configuration for Web Worker Offloading.
@@ -23,7 +25,7 @@
 	$config = array(
 		// The source code in the build directory is compiled from <https://github.com/BuilderIO/partytown/tree/main/src/lib>.
 		// See webpack config in the WordPress/performance repo: <https://github.com/WordPress/performance/blob/282a068f3eb2575d37aeb9034e894e7140fcddca/webpack.config.js#L84-L130>.
-		'lib' => wp_parse_url( plugin_dir_url( __FILE__ ), PHP_URL_PATH ) . 'build/',
+		'lib' => wp_parse_url( plugins_url( 'build/', __FILE__ ), PHP_URL_PATH ),
 	);
 
 	if ( WP_DEBUG && SCRIPT_DEBUG ) {
Index: hooks.php
===================================================================
--- hooks.php	(revision 3228614)
+++ hooks.php	(working copy)
@@ -6,9 +6,11 @@
  * @package web-worker-offloading
  */
 
+// @codeCoverageIgnoreStart
 if ( ! defined( 'ABSPATH' ) ) {
 	exit; // Exit if accessed directly.
 }
+// @codeCoverageIgnoreEnd
 
 /**
  * Registers defaults scripts for Web Worker Offloading.
Index: load.php
===================================================================
--- load.php	(revision 3228614)
+++ load.php	(working copy)
@@ -15,10 +15,11 @@
  * @package web-worker-offloading
  */
 
-// Exit if accessed directly.
+// @codeCoverageIgnoreStart
 if ( ! defined( 'ABSPATH' ) ) {
-	exit;
+	exit; // Exit if accessed directly.
 }
+// @codeCoverageIgnoreEnd
 
 // Define the constant.
 if ( defined( 'WEB_WORKER_OFFLOADING_VERSION' ) ) {
Index: third-party/google-site-kit.php
===================================================================
--- third-party/google-site-kit.php	(revision 3228614)
+++ third-party/google-site-kit.php	(working copy)
@@ -6,9 +6,11 @@
  * @package web-worker-offloading
  */
 
+// @codeCoverageIgnoreStart
 if ( ! defined( 'ABSPATH' ) ) {
 	exit; // Exit if accessed directly.
 }
+// @codeCoverageIgnoreEnd
 
 /**
  * Configures WWO for Site Kit and Google Analytics.
Index: third-party/seo-by-rank-math.php
===================================================================
--- third-party/seo-by-rank-math.php	(revision 3228614)
+++ third-party/seo-by-rank-math.php	(working copy)
@@ -6,9 +6,11 @@
  * @package web-worker-offloading
  */
 
+// @codeCoverageIgnoreStart
 if ( ! defined( 'ABSPATH' ) ) {
 	exit; // Exit if accessed directly.
 }
+// @codeCoverageIgnoreEnd
 
 /**
  * Configures WWO for Rank Math SEO and Google Analytics.
Index: third-party/woocommerce.php
===================================================================
--- third-party/woocommerce.php	(revision 3228614)
+++ third-party/woocommerce.php	(working copy)
@@ -6,9 +6,11 @@
  * @package web-worker-offloading
  */
 
+// @codeCoverageIgnoreStart
 if ( ! defined( 'ABSPATH' ) ) {
 	exit; // Exit if accessed directly.
 }
+// @codeCoverageIgnoreEnd
 
 /**
  * Configures WWO for WooCommerce and Google Analytics.
Index: third-party.php
===================================================================
--- third-party.php	(revision 3228614)
+++ third-party.php	(working copy)
@@ -6,9 +6,11 @@
  * @package web-worker-offloading
  */
 
+// @codeCoverageIgnoreStart
 if ( ! defined( 'ABSPATH' ) ) {
 	exit; // Exit if accessed directly.
 }
+// @codeCoverageIgnoreEnd
 
 /**
  * Adds scripts to be offloaded to a worker.

webp-uploads

Important

Stable tag change: 2.4.0 → 2.5.0

svn status:

M       deprecated.php
M       helper.php
M       hooks.php
M       image-edit.php
M       load.php
M       readme.txt
M       rest-api.php
M       settings.php
svn diff
Index: deprecated.php
===================================================================
--- deprecated.php	(revision 3228614)
+++ deprecated.php	(working copy)
@@ -7,9 +7,11 @@
  * @since 1.1.1
  */
 
+// @codeCoverageIgnoreStart
 if ( ! defined( 'ABSPATH' ) ) {
 	exit; // Exit if accessed directly.
 }
+// @codeCoverageIgnoreEnd
 
 /**
  * Returns the attachment sources array ordered by filesize.
Index: helper.php
===================================================================
--- helper.php	(revision 3228614)
+++ helper.php	(working copy)
@@ -7,9 +7,11 @@
  * @since 1.0.0
  */
 
+// @codeCoverageIgnoreStart
 if ( ! defined( 'ABSPATH' ) ) {
 	exit; // Exit if accessed directly.
 }
+// @codeCoverageIgnoreEnd
 
 /**
  * Returns an array with the list of valid mime types that a specific mime type can be converted into it,
Index: hooks.php
===================================================================
--- hooks.php	(revision 3228614)
+++ hooks.php	(working copy)
@@ -7,9 +7,11 @@
  * @since 1.0.0
  */
 
+// @codeCoverageIgnoreStart
 if ( ! defined( 'ABSPATH' ) ) {
 	exit; // Exit if accessed directly.
 }
+// @codeCoverageIgnoreEnd
 
 /**
  * Hook called by `wp_generate_attachment_metadata` to create the `sources` property for every image
@@ -510,74 +512,24 @@
 add_action( 'delete_attachment', 'webp_uploads_remove_sources_files', 10, 1 );
 
 /**
- * Filters `the_content` to update images so that they use the preferred MIME type where possible.
+ * Filters `wp_content_img_tag` to update images so that they use the preferred MIME type where possible.
  *
- * By default, this is `image/webp`, if the current attachment contains the targeted MIME
- * type. In the near future this will be filterable.
+ * @since 2.5.0
  *
- * Note that most of this function will not be needed for an eventual core implementation as it
- * would rely on `wp_filter_content_tags()`.
- *
- * @since 1.0.0
- *
- * @see wp_filter_content_tags()
- *
- * @param string|mixed $content The content of the current post.
- * @return string The content with the updated references to the images.
+ * @param string $filtered_image Full img tag with attributes that will replace the source img tag.
+ * @param string $context        Additional context, like the current filter name or the function name from where this was called.
+ * @param int    $attachment_id  The image attachment ID. May be 0 in case the image is not an attachment.
+ * @return string The updated IMG tag with references to the new MIME type if available.
  */
-function webp_uploads_update_image_references( $content ): string {
-	if ( ! is_string( $content ) ) {
-		$content = '';
-	}
-
+function webp_uploads_filter_image_tag( string $filtered_image, string $context, int $attachment_id ): string {
 	// Bail early if request is not for the frontend.
 	if ( ! webp_uploads_in_frontend_body() ) {
-		return $content;
+		return $filtered_image;
 	}
 
-	// This content does not have any tag on it, move forward.
-	// TODO: Eventually this should use the HTML API to parse out the image tags and then update them.
-	if ( 0 === (int) preg_match_all( '/<(img)\s[^>]+>/', $content, $img_tags, PREG_SET_ORDER ) ) {
-		return $content;
-	}
+	$filtered_image = str_replace( $filtered_image, webp_uploads_img_tag_update_mime_type( $filtered_image, 'the_content', $attachment_id ), $filtered_image );
 
-	$images = array();
-	foreach ( $img_tags as list( $img ) ) {
-		$processor = new WP_HTML_Tag_Processor( $img );
-		if ( ! $processor->next_tag( array( 'tag_name' => 'IMG' ) ) ) {
-			// This condition won't ever be met since we're iterating over the IMG tags extracted with preg_match_all() above.
-			continue;
-		}
-
-		// Find the ID of each image by the class.
-		// TODO: It would be preferable to use the $processor->class_list() method but there seems to be some typing issues with PHPStan.
-		$class_name = $processor->get_attribute( 'class' );
-		if (
-			! is_string( $class_name )
-			||
-			1 !== preg_match( '/(?:^|\s)wp-image-([1-9]\d*)(?:\s|$)/i', $class_name, $matches )
-		) {
-			continue;
-		}
-
-		// Make sure we use the last item on the list of matches.
-		$images[ $img ] = (int) $matches[1];
-	}
-
-	$attachment_ids = array_unique( array_filter( array_values( $images ) ) );
-	if ( count( $attachment_ids ) > 1 ) {
-		/**
-		 * Warm the object cache with post and meta information for all found
-		 * images to avoid making individual database calls.
-		 */
-		_prime_post_caches( $attachment_ids, false, true );
-	}
-
-	foreach ( $images as $img => $attachment_id ) {
-		$content = str_replace( $img, webp_uploads_img_tag_update_mime_type( $img, 'the_content', $attachment_id ), $content );
-	}
-
-	return $content;
+	return $filtered_image;
 }
 
 /**
@@ -773,11 +725,7 @@
  * @since 2.1.0
  */
 function webp_uploads_init(): void {
-	if ( webp_uploads_is_picture_element_enabled() ) {
-		add_filter( 'wp_content_img_tag', 'webp_uploads_wrap_image_in_picture', 10, 3 );
-	} else {
-		add_filter( 'the_content', 'webp_uploads_update_image_references', 13 ); // Run after wp_filter_content_tags.
-	}
+	add_filter( 'wp_content_img_tag', webp_uploads_is_picture_element_enabled() ? 'webp_uploads_wrap_image_in_picture' : 'webp_uploads_filter_image_tag', 10, 3 );
 }
 add_action( 'init', 'webp_uploads_init' );
 
Index: image-edit.php
===================================================================
--- image-edit.php	(revision 3228614)
+++ image-edit.php	(working copy)
@@ -7,9 +7,11 @@
  * @since 1.0.0
  */
 
+// @codeCoverageIgnoreStart
 if ( ! defined( 'ABSPATH' ) ) {
 	exit; // Exit if accessed directly.
 }
+// @codeCoverageIgnoreEnd
 
 /**
  * Adds sources to metadata for an attachment.
Index: load.php
===================================================================
--- load.php	(revision 3228614)
+++ load.php	(working copy)
@@ -5,7 +5,7 @@
  * Description: Converts images to more modern formats such as WebP or AVIF during upload.
  * Requires at least: 6.6
  * Requires PHP: 7.2
- * Version: 2.4.0
+ * Version: 2.5.0
  * Author: WordPress Performance Team
  * Author URI: https://make.wordpress.org/performance/
  * License: GPLv2 or later
@@ -15,10 +15,11 @@
  * @package webp-uploads
  */
 
-// Exit if accessed directly.
+// @codeCoverageIgnoreStart
 if ( ! defined( 'ABSPATH' ) ) {
-	exit;
+	exit; // Exit if accessed directly.
 }
+// @codeCoverageIgnoreEnd
 
 // Define required constants.
 if ( defined( 'WEBP_UPLOADS_VERSION' ) ) {
@@ -25,7 +26,7 @@
 	return;
 }
 
-define( 'WEBP_UPLOADS_VERSION', '2.4.0' );
+define( 'WEBP_UPLOADS_VERSION', '2.5.0' );
 define( 'WEBP_UPLOADS_MAIN_FILE', plugin_basename( __FILE__ ) );
 
 require_once __DIR__ . '/helper.php';
Index: readme.txt
===================================================================
--- readme.txt	(revision 3228614)
+++ readme.txt	(working copy)
@@ -2,7 +2,7 @@
 
 Contributors: wordpressdotorg
 Tested up to: 6.7
-Stable tag:   2.4.0
+Stable tag:   2.5.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,12 @@
 
 == Changelog ==
 
+= 2.5.0 =
+
+**Enhancements**
+
+* Switch to `wp_content_img_tag` filter for improved image handling. ([1772](https://github.com/WordPress/performance/pull/1772))
+
 = 2.4.0 =
 
 **Enhancements**
Index: rest-api.php
===================================================================
--- rest-api.php	(revision 3228614)
+++ rest-api.php	(working copy)
@@ -7,9 +7,11 @@
  * @since 1.0.0
  */
 
+// @codeCoverageIgnoreStart
 if ( ! defined( 'ABSPATH' ) ) {
 	exit; // Exit if accessed directly.
 }
+// @codeCoverageIgnoreEnd
 
 /**
  * Updates the response for an attachment to include sources for additional mime types available the image.
Index: settings.php
===================================================================
--- settings.php	(revision 3228614)
+++ settings.php	(working copy)
@@ -7,9 +7,11 @@
  * @since 1.0.0
  */
 
+// @codeCoverageIgnoreStart
 if ( ! defined( 'ABSPATH' ) ) {
 	exit; // Exit if accessed directly.
 }
+// @codeCoverageIgnoreEnd
 
 /**
  * Registers setting for generating JPEG in addition to the selected modern format for image uploads.

@westonruter westonruter marked this pull request as ready for review January 25, 2025 23:00
Copy link

github-actions bot commented Jan 25, 2025

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: adamsilverstein <[email protected]>

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

@westonruter westonruter mentioned this pull request Jan 25, 2025
5 tasks
Copy link
Member

@adamsilverstein adamsilverstein left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good!

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.

2 participants