Skip to content

Prepare 3.9.0 release#1862

Merged
westonruter merged 6 commits intorelease/3.9.0from
publish/3.9.0
Feb 13, 2025
Merged

Prepare 3.9.0 release#1862
westonruter merged 6 commits intorelease/3.9.0from
publish/3.9.0

Conversation

@westonruter
Copy link
Copy Markdown
Member

@westonruter westonruter commented Feb 11, 2025

Fixes #1852
Previous #1827

  • Bump versions
  • Update changelogs
  • Run npm run since

The following plugins are included in this release:

  1. optimization-detective 1.0.0-beta2
  2. performance-lab 3.9.0
  3. image-prioritizer 1.0.0-beta1
  4. embed-optimizer 1.0.0-beta1

@westonruter westonruter added this to the performance-lab 3.9.0 milestone Feb 11, 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 Feb 11, 2025
@westonruter westonruter mentioned this pull request Feb 11, 2025
5 tasks
@codecov
Copy link
Copy Markdown

codecov bot commented Feb 11, 2025

Codecov Report

Attention: Patch coverage is 25.00000% with 3 lines in your changes missing coverage. Please review.

Project coverage is 66.70%. Comparing base (5b5b5f8) to head (770ca76).
Report is 7 commits behind head on release/3.9.0.

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/performance-lab/load.php 0.00% 1 Missing ⚠️
Additional details and impacted files
@@              Coverage Diff               @@
##           release/3.9.0    #1862   +/-   ##
==============================================
  Coverage          66.70%   66.70%           
==============================================
  Files                 88       88           
  Lines               7029     7029           
==============================================
  Hits                4689     4689           
  Misses              2340     2340           
Flag Coverage Δ
multisite 66.70% <25.00%> (ø)
single 37.20% <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.

@github-actions
Copy link
Copy Markdown

github-actions bot commented Feb 11, 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 <westonruter@git.wordpress.org>
Co-authored-by: adamsilverstein <adamsilverstein@git.wordpress.org>
Co-authored-by: felixarntz <flixos90@git.wordpress.org>

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

@westonruter
Copy link
Copy Markdown
Member Author

Pending release diffs:

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 3238281)
+++ 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 3238281)
+++ 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 3238281)
+++ 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.
@@ -63,16 +65,17 @@
 		$attr['data-has-transparency'] = $image_meta['has_transparency'] ? 'true' : 'false';
 
 		$class = $image_meta['has_transparency'] ? 'has-transparency' : 'not-transparent';
-		if ( empty( $attr['class'] ) ) {
+
+		if ( isset( $attr['class'] ) && is_string( $attr['class'] ) && '' !== $attr['class'] ) {
+			$attr['class'] .= ' ' . $class;
+		} else {
 			$attr['class'] = $class;
-		} else {
-			$attr['class'] .= ' ' . $class;
 		}
 	}
 
-	if ( ! empty( $image_meta['dominant_color'] ) ) {
+	if ( isset( $image_meta['dominant_color'] ) && is_string( $image_meta['dominant_color'] ) && '' !== $image_meta['dominant_color'] ) {
 		$attr['data-dominant-color'] = esc_attr( $image_meta['dominant_color'] );
-		$style_attribute             = empty( $attr['style'] ) ? '' : $attr['style'];
+		$style_attribute             = isset( $attr['style'] ) && is_string( $attr['style'] ) ? $attr['style'] : '';
 		$attr['style']               = '--dominant-color: #' . esc_attr( $image_meta['dominant_color'] ) . ';' . $style_attribute;
 	}
 
@@ -138,7 +141,7 @@
 		return $filtered_image;
 	}
 
-	if ( ! empty( $image_meta['dominant_color'] ) ) {
+	if ( isset( $image_meta['dominant_color'] ) && is_string( $image_meta['dominant_color'] ) && '' !== $image_meta['dominant_color'] ) {
 		$processor->set_attribute( 'data-dominant-color', $image_meta['dominant_color'] );
 
 		$style_attribute = '--dominant-color: #' . $image_meta['dominant_color'] . '; ';
Index: load.php
===================================================================
--- load.php	(revision 3238281)
+++ 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.1 → 1.0.0-beta1

svn status:

M       class-embed-optimizer-tag-visitor.php
M       load.php
M       readme.txt
svn diff
Index: class-embed-optimizer-tag-visitor.php
===================================================================
--- class-embed-optimizer-tag-visitor.php	(revision 3238281)
+++ class-embed-optimizer-tag-visitor.php	(working copy)
@@ -185,12 +185,21 @@
 
 			$style_rules = array();
 			foreach ( $minimums as $minimum ) {
-				$style_rules[] = sprintf(
-					'@media %s { #%s { min-height: %dpx; } }',
-					od_generate_media_query( $minimum['group']->get_minimum_viewport_width(), $minimum['group']->get_maximum_viewport_width() ),
+				$style_rule = sprintf(
+					'#%s { min-height: %dpx; }',
 					$element_id,
 					$minimum['height']
 				);
+
+				$media_feature = od_generate_media_query( $minimum['group']->get_minimum_viewport_width(), $minimum['group']->get_maximum_viewport_width() );
+				if ( null !== $media_feature ) {
+					$style_rule = sprintf(
+						'@media %s { %s }',
+						$media_feature,
+						$style_rule
+					);
+				}
+				$style_rules[] = $style_rule;
 			}
 
 			$processor->append_head_html( sprintf( "<style>\n%s\n</style>\n", join( "\n", $style_rules ) ) );
Index: load.php
===================================================================
--- load.php	(revision 3238281)
+++ 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.1
+ * Version: 1.0.0-beta1
  * Author: WordPress Performance Team
  * Author URI: https://make.wordpress.org/performance/
  * License: GPLv2 or later
@@ -71,7 +71,7 @@
 	}
 )(
 	'embed_optimizer_pending_plugin',
-	'0.4.1',
+	'1.0.0-beta1',
 	static function ( string $version ): void {
 		if ( defined( 'EMBED_OPTIMIZER_VERSION' ) ) {
 			return;
Index: readme.txt
===================================================================
--- readme.txt	(revision 3238281)
+++ readme.txt	(working copy)
@@ -2,7 +2,7 @@
 
 Contributors: wordpressdotorg
 Tested up to: 6.7
-Stable tag:   0.4.1
+Stable tag:   1.0.0-beta1
 License:      GPLv2 or later
 License URI:  https://www.gnu.org/licenses/gpl-2.0.html
 Tags:         performance, embeds
@@ -67,6 +67,13 @@
 
 == Changelog ==
 
+= 1.0.0-beta1 =
+
+**Enhancements**
+
+* Bump version to 1.0.0-beta1 to indicate graduation from being experimental. See [1846](https://github.com/WordPress/performance/pull/1846).
+* Use CSS range syntax in media queries. ([1833](https://github.com/WordPress/performance/pull/1833))
+
 = 0.4.1 =
 
 **Bug Fixes**

image-prioritizer

Important

Stable tag change: 0.3.1 → 1.0.0-beta1

svn status:

M       class-image-prioritizer-img-tag-visitor.php
M       load.php
M       readme.txt
svn diff
Index: class-image-prioritizer-img-tag-visitor.php
===================================================================
--- class-image-prioritizer-img-tag-visitor.php	(revision 3238281)
+++ class-image-prioritizer-img-tag-visitor.php	(working copy)
@@ -146,21 +146,44 @@
 			$processor->remove_attribute( 'fetchpriority' );
 		}
 
-		// Ensure that sizes=auto is set properly.
-		$sizes = $processor->get_attribute( 'sizes' );
-		if ( is_string( $sizes ) ) {
+		// Ensure that sizes is set properly when it is a responsive image (it has a srcset attribute).
+		if ( is_string( $processor->get_attribute( 'srcset' ) ) ) {
+			$sizes = $processor->get_attribute( 'sizes' );
+			if ( ! is_string( $sizes ) ) {
+				$sizes = '';
+			}
+
 			$is_lazy  = 'lazy' === $this->get_attribute_value( $processor, 'loading' );
 			$has_auto = $this->sizes_attribute_includes_valid_auto( $sizes );
 
 			if ( $is_lazy && ! $has_auto ) {
-				$processor->set_attribute( 'sizes', "auto, $sizes" );
+				$new_sizes = 'auto';
+				if ( '' !== trim( $sizes, " \t\f\r\n" ) ) {
+					$new_sizes .= ', ';
+				}
+				$sizes = $new_sizes . $sizes;
 			} elseif ( ! $is_lazy && $has_auto ) {
 				// Remove auto from the beginning of the list.
-				$processor->set_attribute(
-					'sizes',
-					(string) preg_replace( '/^[ \t\f\r\n]*auto[ \t\f\r\n]*(,[ \t\f\r\n]*)?/i', '', $sizes )
-				);
+				$sizes = (string) preg_replace( '/^[ \t\f\r\n]*auto[ \t\f\r\n]*(,[ \t\f\r\n]*)?/i', '', $sizes );
 			}
+
+			// Compute more accurate sizes when it isn't lazy-loaded and sizes=auto isn't taking care of it.
+			if ( ! $is_lazy ) {
+				$computed_sizes = $this->compute_sizes( $context );
+				if ( count( $computed_sizes ) > 0 ) {
+					$new_sizes = join( ', ', $computed_sizes );
+
+					// Preserve the original sizes as a fallback when URL Metrics are missing from one or more viewport group.
+					// Note that when all groups are populated, the media features will span all possible viewport widths from
+					// zero to infinity, so there is no need to include the original sizes since they will never match.
+					if ( '' !== $sizes && ! $context->url_metric_group_collection->is_every_group_populated() ) {
+						$new_sizes .= ", $sizes";
+					}
+					$sizes = $new_sizes;
+				}
+			}
+
+			$processor->set_attribute( 'sizes', $sizes );
 		}
 
 		$parent_tag = $this->get_parent_tag_name( $context );
@@ -385,4 +408,38 @@
 			return 'auto' === $sizes_attr || str_starts_with( $sizes_attr, 'auto,' );
 		}
 	}
+
+	/**
+	 * Computes responsive sizes for the current element based on its boundingClientRect width captured in URL Metrics.
+	 *
+	 * @since 1.0.0
+	 *
+	 * @param OD_Tag_Visitor_Context $context Context.
+	 * @return non-empty-string[] Computed sizes.
+	 */
+	private function compute_sizes( OD_Tag_Visitor_Context $context ): array {
+		$sizes = array();
+
+		$xpath = $context->processor->get_xpath();
+		foreach ( $context->url_metric_group_collection as $group ) {
+			// Obtain the maximum width that the image appears among all URL Metrics collected for this viewport group.
+			$element_max_width = 0;
+			foreach ( $group->get_xpath_elements_map()[ $xpath ] ?? array() as $element ) {
+				$element_max_width = max( $element_max_width, $element->get_bounding_client_rect()['width'] );
+			}
+
+			// Use the maximum width as the size for image in this breakpoint.
+			if ( $element_max_width > 0 ) {
+				$size          = sprintf( '%dpx', $element_max_width );
+				$media_feature = od_generate_media_query( $group->get_minimum_viewport_width(), $group->get_maximum_viewport_width() );
+				if ( null !== $media_feature ) {
+					// Note: The null case only happens when a site has filtered od_breakpoint_max_widths to be an empty array, meaning there is only one viewport group.
+					$size = "$media_feature $size";
+				}
+				$sizes[] = $size;
+			}
+		}
+
+		return $sizes;
+	}
 }
Index: load.php
===================================================================
--- load.php	(revision 3238281)
+++ load.php	(working copy)
@@ -6,7 +6,7 @@
  * Requires at least: 6.6
  * Requires PHP: 7.2
  * Requires Plugins: optimization-detective
- * Version: 0.3.1
+ * Version: 1.0.0-beta1
  * Author: WordPress Performance Team
  * Author URI: https://make.wordpress.org/performance/
  * License: GPLv2 or later
@@ -72,7 +72,7 @@
 	}
 )(
 	'image_prioritizer_pending_plugin',
-	'0.3.1',
+	'1.0.0-beta1',
 	static function ( string $version ): void {
 		if ( defined( 'IMAGE_PRIORITIZER_VERSION' ) ) {
 			return;
Index: readme.txt
===================================================================
--- readme.txt	(revision 3238281)
+++ readme.txt	(working copy)
@@ -2,12 +2,12 @@
 
 Contributors: wordpressdotorg
 Tested up to: 6.7
-Stable tag:   0.3.1
+Stable tag:   1.0.0-beta1
 License:      GPLv2 or later
 License URI:  https://www.gnu.org/licenses/gpl-2.0.html
 Tags:         performance, optimization, image, lcp, lazy-load
 
-Prioritizes the loading of images and videos based on how visible they are to actual visitors; adds fetchpriority and applies lazy loading.
+Prioritizes the loading of images and videos based on how they appear to actual visitors: adds fetchpriority, preloads, lazy-loads, and sets sizes.
 
 == Description ==
 
@@ -15,7 +15,7 @@
 
 The current optimizations include:
 
-1. Add breakpoint-specific `fetchpriority=high` preload links (`LINK[rel=preload]`) for image URLs of LCP elements:
+1. Add breakpoint-specific `fetchpriority=high` preload links (both as `LINK[rel=preload]` elements and `Link` response headers) for image URLs of LCP elements:
    1. An `IMG` element, including the `srcset`/`sizes` attributes supplied as `imagesrcset`/`imagesizes` on the `LINK`.
    2. The first `SOURCE` element with a `type` attribute in a `PICTURE` element. (Art-directed `PICTURE` elements using media queries are not supported.)
    3. An element with a CSS `background-image` inline `style` attribute.
@@ -27,7 +27,9 @@
    1. Apply lazy loading to `IMG` tags based on whether they appear in any breakpoint’s initial viewport.
    2. Implement lazy loading of CSS background images added via inline `style` attributes.
    3. Lazy-load `VIDEO` tags by setting the appropriate attributes based on whether they appear in the initial viewport. If a `VIDEO` is the LCP element, it gets `preload=auto`; if it is in an initial viewport, the `preload=metadata` default is left; if it is not in an initial viewport, it gets `preload=none`. Lazy-loaded videos also get initial `preload`, `autoplay`, and `poster` attributes restored when the `VIDEO` is going to enter the viewport.
-5. Ensure that [`sizes=auto`](https://make.wordpress.org/core/2024/10/18/auto-sizes-for-lazy-loaded-images-in-wordpress-6-7/) is added to all lazy-loaded `IMG` elements.
+5. Responsive image sizes:
+   1. Compute the `sizes` attribute using the widths of an image collected from URL Metrics for each breakpoint (when not lazy-loaded since then handled by `sizes=auto`).
+   2. Ensure [`sizes=auto`](https://make.wordpress.org/core/2024/10/18/auto-sizes-for-lazy-loaded-images-in-wordpress-6-7/) is set on `IMG` tags after setting correct lazy-loading (above).
 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).
 
 **This plugin requires the [Optimization Detective](https://wordpress.org/plugins/optimization-detective/) plugin as a dependency.** Please refer to that plugin for additional background on how this plugin works as well as additional developer options.
@@ -70,6 +72,13 @@
 
 == Changelog ==
 
+= 1.0.0-beta1 =
+
+**Enhancements**
+
+* Bump version to 1.0.0-beta1 to indicate graduation from being experimental. See [1846](https://github.com/WordPress/performance/pull/1846).
+* Compute responsive `sizes` attribute based on the `width` from the `boundingClientRect` in captured URL Metrics. ([1840](https://github.com/WordPress/performance/pull/1840))
+
 = 0.3.1 =
 
 **Bug Fixes**

optimization-detective

Important

Stable tag change: 1.0.0-beta1 → 1.0.0-beta2

svn status:

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-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
M       class-od-visited-tag-state.php
M       detect.js
M       detect.min.js
M       detection.php
M       docs/extensions.md
M       docs/hooks.md
M       helper.php
M       hooks.php
M       load.php
M       optimization.php
M       readme.txt
M       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
svn diff
Index: class-od-data-validation-exception.php
===================================================================
--- class-od-data-validation-exception.php	(revision 3238281)
+++ class-od-data-validation-exception.php	(working copy)
@@ -16,6 +16,5 @@
  * Exception thrown when failing to validate URL Metrics data.
  *
  * @since 0.1.0
- * @access private
  */
 final class OD_Data_Validation_Exception extends Exception {}
Index: class-od-element.php
===================================================================
--- class-od-element.php	(revision 3238281)
+++ class-od-element.php	(working copy)
@@ -21,7 +21,6 @@
  * @todo The above implements tag should account for additional undefined keys which can be supplied by extending the element schema. May depend on <https://github.com/phpstan/phpstan/issues/8438>.
  *
  * @since 0.7.0
- * @access private
  */
 class OD_Element implements ArrayAccess, JsonSerializable {
 
Index: class-od-html-tag-processor.php
===================================================================
--- class-od-html-tag-processor.php	(revision 3238281)
+++ class-od-html-tag-processor.php	(working copy)
@@ -16,7 +16,6 @@
  * Extension to WP_HTML_Tag_Processor that supports injecting HTML and obtaining XPath for the current tag.
  *
  * @since 0.1.1
- * @access private
  */
 final class OD_HTML_Tag_Processor extends WP_HTML_Tag_Processor {
 
@@ -149,7 +148,7 @@
 	 * Open stack tags.
 	 *
 	 * @since 0.4.0
-	 * @var string[]
+	 * @var non-empty-string[]
 	 */
 	private $open_stack_tags = array();
 
@@ -160,7 +159,7 @@
 	 * are children of the `BODY` tag. This is used in {@see self::get_xpath()}.
 	 *
 	 * @since 1.0.0
-	 * @var array<array<string, string>>
+	 * @var array<array<non-empty-string, string>>
 	 */
 	private $open_stack_attributes = array();
 
@@ -168,7 +167,7 @@
 	 * Open stack indices.
 	 *
 	 * @since 0.4.0
-	 * @var int[]
+	 * @var non-negative-int[]
 	 */
 	private $open_stack_indices = array();
 
@@ -181,7 +180,7 @@
 	 * populated back into `$this->open_stack_tags` and `$this->open_stack_indices`.
 	 *
 	 * @since 0.4.0
-	 * @var array<string, array{tags: string[], attributes: array<array<string, string>>, indices: int[]}>
+	 * @var array<string, array{tags: non-empty-string[], attributes: array<array<non-empty-string, string>>, indices: non-negative-int[]}>
 	 */
 	private $bookmarked_open_stacks = array();
 
@@ -221,7 +220,7 @@
 	 * Mapping of bookmark name to a list of HTML strings which will be inserted at the time get_updated_html() is called.
 	 *
 	 * @since 0.4.0
-	 * @var array<string, string[]>
+	 * @var array<non-empty-string, string[]>
 	 */
 	private $buffered_text_replacements = array();
 
@@ -238,7 +237,7 @@
 	 * Count for the number of times that the cursor was moved.
 	 *
 	 * @since 0.6.0
-	 * @var int
+	 * @var non-negative-int
 	 * @see self::next_token()
 	 * @see self::seek()
 	 */
@@ -292,7 +291,7 @@
 	 * @see WP_HTML_Processor::expects_closer()
 	 * @since 0.4.0
 	 *
-	 * @param string|null $tag_name Tag name, if not provided then the current tag is used. Optional.
+	 * @param non-empty-string|null $tag_name Tag name, if not provided then the current tag is used. Optional.
 	 * @return bool Whether to expect a closer for the tag.
 	 */
 	public function expects_closer( ?string $tag_name = null ): bool {
@@ -336,6 +335,11 @@
 		if ( null === $tag_name || $this->get_token_type() !== '#tag' ) {
 			return true;
 		}
+		/**
+		 * Tag name.
+		 *
+		 * @var non-empty-string $tag_name
+		 */
 
 		if ( $this->previous_tag_without_closer ) {
 			array_pop( $this->open_stack_tags );
@@ -422,7 +426,7 @@
 	 * @see self::next_token()
 	 * @see self::seek()
 	 *
-	 * @return int Count of times the cursor has moved.
+	 * @return non-negative-int Count of times the cursor has moved.
 	 */
 	public function get_cursor_move_count(): int {
 		return $this->cursor_move_count;
@@ -458,8 +462,8 @@
 	 *
 	 * @since 0.4.0
 	 *
-	 * @param string      $name  Meta attribute name.
-	 * @param string|true $value Value.
+	 * @param non-empty-string $name  Meta attribute name.
+	 * @param string|true      $value Value.
 	 * @return bool Whether an attribute was set.
 	 */
 	public function set_meta_attribute( string $name, $value ): bool {
@@ -489,7 +493,7 @@
 	 * @since 0.4.0
 	 * @see WP_HTML_Processor::get_current_depth()
 	 *
-	 * @return int Nesting-depth of current location in the document.
+	 * @return non-negative-int Nesting-depth of current location in the document.
 	 */
 	public function get_current_depth(): int {
 		return count( $this->open_stack_tags );
@@ -565,7 +569,7 @@
 	 * @since 0.4.0
 	 * @since 0.9.0 Renamed from get_breadcrumbs() to get_indexed_breadcrumbs().
 	 *
-	 * @return Generator<array{string, int, array<string, string>}> Breadcrumb.
+	 * @return Generator<array{non-empty-string, non-negative-int, array<non-empty-string, string>}> Breadcrumb.
 	 */
 	private function get_indexed_breadcrumbs(): Generator {
 		foreach ( $this->open_stack_tags as $i => $breadcrumb_tag_name ) {
@@ -584,7 +588,7 @@
 	 *
 	 * @since 1.0.0
 	 *
-	 * @return array<string, string> Disambiguating attributes.
+	 * @return array<non-empty-string, string> Disambiguating attributes.
 	 */
 	private function get_disambiguating_attributes(): array {
 		$attributes = array();
@@ -617,7 +621,7 @@
 	 * @since 0.9.0
 	 * @see WP_HTML_Processor::get_breadcrumbs()
 	 *
-	 * @return string[] Array of tag names representing path to matched node.
+	 * @return non-empty-string[] Array of tag names representing path to matched node.
 	 */
 	public function get_breadcrumbs(): array {
 		return $this->open_stack_tags;
Index: class-od-link-collection.php
===================================================================
--- class-od-link-collection.php	(revision 3238281)
+++ class-od-link-collection.php	(working copy)
@@ -18,7 +18,7 @@
  * @phpstan-type Link array{
  *                   attributes: LinkAttributes,
  *                   minimum_viewport_width: int<0, max>|null,
- *                   maximum_viewport_width: positive-int|null
+ *                   maximum_viewport_width: int<1, max>|null
  *               }
  *
  * @phpstan-type LinkAttributes array{
@@ -37,7 +37,6 @@
  *
  * @since 0.3.0
  * @since 0.4.0 Renamed from OD_Preload_Link_Collection.
- * @access private
  */
 final class OD_Link_Collection implements Countable {
 
@@ -44,6 +43,8 @@
 	/**
 	 * Links grouped by rel type.
 	 *
+	 * @since 0.4.0
+	 *
 	 * @var array<string, Link[]>
 	 */
 	private $links_by_rel = array();
@@ -51,11 +52,13 @@
 	/**
 	 * Adds link.
 	 *
+	 * @since 0.3.0
+	 *
 	 * @phpstan-param LinkAttributes $attributes
 	 *
-	 * @param array             $attributes             Attributes.
-	 * @param int<0, max>|null  $minimum_viewport_width Minimum width or null if not bounded or relevant.
-	 * @param positive-int|null $maximum_viewport_width Maximum width or null if not bounded (i.e. infinity) or relevant.
+	 * @param array            $attributes             Attributes.
+	 * @param int<0, max>|null $minimum_viewport_width Minimum width (exclusive) or null if not bounded or relevant.
+	 * @param int<1, max>|null $maximum_viewport_width Maximum width (inclusive) or null if not bounded (i.e. infinity) or relevant.
 	 *
 	 * @throws InvalidArgumentException When invalid arguments are provided.
 	 */
@@ -111,6 +114,8 @@
 	 * When two links are identical except for their minimum/maximum widths which are also consecutive, then merge them
 	 * together. Also, add media attributes to the links.
 	 *
+	 * @since 0.4.0
+	 *
 	 * @return LinkAttributes[] Prepared links with adjacent-duplicates merged together and media attributes added.
 	 */
 	private function get_prepared_links(): array {
@@ -133,6 +138,8 @@
 	/**
 	 * Merges consecutive links.
 	 *
+	 * @since 0.4.0
+	 *
 	 * @param Link[] $links Links.
 	 * @return LinkAttributes[] Merged consecutive links.
 	 */
@@ -194,9 +201,9 @@
 					&&
 					is_int( $last_link['maximum_viewport_width'] )
 					&&
-					$last_link['maximum_viewport_width'] + 1 === $link['minimum_viewport_width']
+					$last_link['maximum_viewport_width'] === $link['minimum_viewport_width']
 				) {
-					$last_link['maximum_viewport_width'] = max( $last_link['maximum_viewport_width'], $link['maximum_viewport_width'] );
+					$last_link['maximum_viewport_width'] = null === $link['maximum_viewport_width'] ? null : max( $last_link['maximum_viewport_width'], $link['maximum_viewport_width'] );
 
 					// Update the last link with the new maximum viewport width.
 					$carry[ count( $carry ) - 1 ] = $last_link;
@@ -228,6 +235,8 @@
 	/**
 	 * Gets the HTML for the link tags.
 	 *
+	 * @since 0.3.0
+	 *
 	 * @return string Link tags HTML.
 	 */
 	public function get_html(): string {
@@ -249,15 +258,32 @@
 	/**
 	 * Constructs the Link HTTP response header.
 	 *
-	 * @return string|null Link HTTP response header, or null if there are none.
+	 * @since 0.4.0
+	 *
+	 * @return non-empty-string|null Link HTTP response header, or null if there are none.
 	 */
 	public function get_response_header(): ?string {
 		$link_headers = array();
 
 		foreach ( $this->get_prepared_links() as $link ) {
-			// The about:blank is present since a Link without a reference-uri is invalid so any imagesrcset would otherwise not get downloaded.
-			$link['href'] = isset( $link['href'] ) ? esc_url_raw( $link['href'] ) : 'about:blank';
-			$link_header  = '<' . $link['href'] . '>';
+			if ( isset( $link['href'] ) ) {
+				$decoded_url = urldecode( $link['href'] );
+
+				// Encode characters not allowed in a URL per RFC 3986 (anything that is not among the reserved and unreserved characters).
+				$encoded_url  = preg_replace_callback(
+					'/[^A-Za-z0-9\-._~:\/?#\[\]@!$&\'()*+,;=]/',
+					static function ( $matches ) {
+						return rawurlencode( $matches[0] );
+					},
+					$decoded_url
+				);
+				$link['href'] = esc_url_raw( $encoded_url ?? '' );
+			} else {
+				// The about:blank is present since a Link without a reference-uri is invalid so any imagesrcset would otherwise not get downloaded.
+				$link['href'] = 'about:blank';
+			}
+
+			$link_header = '<' . $link['href'] . '>';
 			unset( $link['href'] );
 			foreach ( $link as $name => $value ) {
 				/*
@@ -287,6 +313,8 @@
 	/**
 	 * Counts the links.
 	 *
+	 * @since 0.3.0
+	 *
 	 * @return non-negative-int Link count.
 	 */
 	public function count(): int {
Index: class-od-tag-visitor-context.php
===================================================================
--- class-od-tag-visitor-context.php	(revision 3238281)
+++ class-od-tag-visitor-context.php	(working copy)
@@ -17,7 +17,11 @@
  *
  * @since 0.4.0
  *
- * @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.
+ * @property-read OD_HTML_Tag_Processor          $processor                    HTML tag processor.
+ * @property-read OD_URL_Metric_Group_Collection $url_metric_group_collection  URL Metric group collection.
+ * @property-read OD_Link_Collection             $link_collection              Link collection.
+ * @property-read positive-int|null              $url_metrics_id               ID for the od_url_metrics post which provided the URL Metrics in the collection.
+ * @property-read OD_URL_Metric_Group_Collection $url_metrics_group_collection Deprecated alias for the $url_metric_group_collection property.
  */
 final class OD_Tag_Visitor_Context {
 
@@ -26,9 +30,8 @@
 	 *
 	 * @since 0.4.0
 	 * @var OD_HTML_Tag_Processor
-	 * @readonly
 	 */
-	public $processor;
+	private $processor;
 
 	/**
 	 * URL Metric group collection.
@@ -35,9 +38,8 @@
 	 *
 	 * @since 0.4.0
 	 * @var OD_URL_Metric_Group_Collection
-	 * @readonly
 	 */
-	public $url_metric_group_collection;
+	private $url_metric_group_collection;
 
 	/**
 	 * Link collection.
@@ -44,13 +46,24 @@
 	 *
 	 * @since 0.4.0
 	 * @var OD_Link_Collection
-	 * @readonly
 	 */
-	public $link_collection;
+	private $link_collection;
 
 	/**
+	 * ID for the od_url_metrics post which provided the URL Metrics in the collection.
+	 *
+	 * May be null if no post has been created yet.
+	 *
+	 * @since 1.0.0
+	 * @var positive-int|null
+	 */
+	private $url_metrics_id;
+
+	/**
 	 * Visited tag state.
 	 *
+	 * Important: This object is not exposed directly by the getter. It is only exposed via {@see self::track_tag()}.
+	 *
 	 * @since 1.0.0
 	 * @var OD_Visited_Tag_State
 	 */
@@ -65,12 +78,14 @@
 	 * @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.
+	 * @param positive-int|null              $url_metrics_id              ID for the od_url_metrics post which provided the URL Metrics in the collection. May be null if no post has been created yet.
 	 */
-	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 ) {
+	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, ?int $url_metrics_id ) {
 		$this->processor                   = $processor;
 		$this->url_metric_group_collection = $url_metric_group_collection;
 		$this->link_collection             = $link_collection;
 		$this->visited_tag_state           = $visited_tag_state;
+		$this->url_metrics_id              = $url_metrics_id;
 	}
 
 	/**
@@ -85,39 +100,50 @@
 	}
 
 	/**
-	 * Gets deprecated property.
+	 * Gets a property.
 	 *
 	 * @since 0.7.0
-	 * @todo Remove this when no plugins are possibly referring to the url_metrics_group_collection property anymore.
 	 *
 	 * @param string $name Property name.
-	 * @return OD_URL_Metric_Group_Collection URL Metric group collection.
+	 * @return mixed Property value.
 	 *
 	 * @throws Error When property is unknown.
 	 */
-	public function __get( string $name ): OD_URL_Metric_Group_Collection {
-		if ( 'url_metrics_group_collection' === $name ) {
-			_doing_it_wrong(
-				__CLASS__ . '::$url_metrics_group_collection',
-				esc_html(
-					sprintf(
-						/* translators: %s is class member variable name */
-						__( 'Use %s instead.', 'optimization-detective' ),
-						__CLASS__ . '::$url_metric_group_collection'
+	public function __get( string $name ) {
+		// Note that there is intentionally not a case for 'visited_tag_state'.
+		switch ( $name ) {
+			case 'processor':
+				return $this->processor;
+			case 'link_collection':
+				return $this->link_collection;
+			case 'url_metrics_id':
+				return $this->url_metrics_id;
+			case 'url_metric_group_collection':
+				return $this->url_metric_group_collection;
+			case 'url_metrics_group_collection':
+				// TODO: Remove this when no plugins are possibly referring to the url_metrics_group_collection property anymore.
+				_doing_it_wrong(
+					esc_html( __CLASS__ . '::$' . $name ),
+					esc_html(
+						sprintf(
+							/* translators: %s is class member variable name */
+							__( 'Use %s instead.', 'optimization-detective' ),
+							__CLASS__ . '::$url_metric_group_collection'
+						)
+					),
+					'optimization-detective 0.7.0'
+				);
+				return $this->url_metric_group_collection;
+			default:
+				throw new Error(
+					esc_html(
+						sprintf(
+							/* translators: %s is class member variable name */
+							__( 'Unknown property %s.', 'optimization-detective' ),
+							__CLASS__ . '::$' . $name
+						)
 					)
-				),
-				'optimization-detective 0.7.0'
-			);
-			return $this->url_metric_group_collection;
+				);
 		}
-		throw new Error(
-			esc_html(
-				sprintf(
-					/* translators: %s is class member variable name */
-					__( 'Unknown property %s.', 'optimization-detective' ),
-					__CLASS__ . '::$' . $name
-				)
-			)
-		);
 	}
 }
Index: class-od-tag-visitor-registry.php
===================================================================
--- class-od-tag-visitor-registry.php	(revision 3238281)
+++ class-od-tag-visitor-registry.php	(working copy)
@@ -20,7 +20,6 @@
  * @implements IteratorAggregate<string, TagVisitorCallback>
  *
  * @since 0.3.0
- * @access private
  */
 final class OD_Tag_Visitor_Registry implements Countable, IteratorAggregate {
 
@@ -27,7 +26,9 @@
 	/**
 	 * Visitors.
 	 *
-	 * @var array<string, TagVisitorCallback>
+	 * @since 0.3.0
+	 *
+	 * @var array<non-empty-string, TagVisitorCallback>
 	 */
 	private $visitors = array();
 
@@ -34,10 +35,12 @@
 	/**
 	 * Registers a tag visitor.
 	 *
+	 * @since 0.3.0
+	 *
 	 * @phpstan-param TagVisitorCallback $tag_visitor_callback
 	 *
-	 * @param string   $id                   Identifier for the tag visitor.
-	 * @param callable $tag_visitor_callback Tag visitor callback.
+	 * @param non-empty-string $id                   Identifier for the tag visitor.
+	 * @param callable         $tag_visitor_callback Tag visitor callback.
 	 */
 	public function register( string $id, callable $tag_visitor_callback ): void {
 		$this->visitors[ $id ] = $tag_visitor_callback;
@@ -46,7 +49,9 @@
 	/**
 	 * Determines if a visitor has been registered.
 	 *
-	 * @param string $id Identifier for the tag visitor.
+	 * @since 0.3.0
+	 *
+	 * @param non-empty-string $id Identifier for the tag visitor.
 	 * @return bool Whether registered.
 	 */
 	public function is_registered( string $id ): bool {
@@ -56,7 +61,9 @@
 	/**
 	 * Gets a registered visitor.
 	 *
-	 * @param string $id Identifier for the tag visitor.
+	 * @since 0.3.0
+	 *
+	 * @param non-empty-string $id Identifier for the tag visitor.
 	 * @return TagVisitorCallback|null Whether registered.
 	 */
 	public function get_registered( string $id ): ?callable {
@@ -69,7 +76,9 @@
 	/**
 	 * Unregisters a tag visitor.
 	 *
-	 * @param string $id Identifier for the tag visitor.
+	 * @since 0.3.0
+	 *
+	 * @param non-empty-string $id Identifier for the tag visitor.
 	 * @return bool Whether a tag visitor was unregistered.
 	 */
 	public function unregister( string $id ): bool {
@@ -83,6 +92,8 @@
 	/**
 	 * Returns an iterator for the URL Metrics in the group.
 	 *
+	 * @since 0.3.0
+	 *
 	 * @return ArrayIterator<string, TagVisitorCallback> ArrayIterator for tag visitors.
 	 */
 	public function getIterator(): ArrayIterator {
@@ -92,6 +103,8 @@
 	/**
 	 * Counts the URL Metrics in the group.
 	 *
+	 * @since 0.3.0
+	 *
 	 * @return int<0, max> URL Metric count.
 	 */
 	public function count(): int {
Index: class-od-url-metric-group-collection.php
===================================================================
--- class-od-url-metric-group-collection.php	(revision 3238281)
+++ class-od-url-metric-group-collection.php	(working copy)
@@ -18,7 +18,6 @@
  * @implements IteratorAggregate<int, OD_URL_Metric_Group>
  *
  * @since 0.1.0
- * @access private
  */
 final class OD_URL_Metric_Group_Collection implements Countable, IteratorAggregate, JsonSerializable {
 
@@ -31,6 +30,7 @@
 	 * even to when there are zero breakpoints: there will still be one group
 	 * in this case, in which every single URL Metric is added.
 	 *
+	 * @since 0.1.0
 	 * @var OD_URL_Metric_Group[]
 	 * @phpstan-var non-empty-array<OD_URL_Metric_Group>
 	 */
@@ -47,18 +47,15 @@
 	/**
 	 * Breakpoints in max widths.
 	 *
-	 * Valid values are from 1 to PHP_INT_MAX - 1. This is because:
+	 * A breakpoint must be greater than zero because a viewport group's maximum viewport width has a minimum (inclusive)
+	 * value of 1, and the breakpoints are used as the maximum viewport widths for the viewport groups, with the addition of
+	 * a final viewport group which has a maximum viewport width of infinity.
 	 *
-	 * 1. It doesn't make sense for there to be a viewport width of zero, so the first breakpoint (max width) must be at least 1.
-	 * 2. After the last breakpoint, the final breakpoint group is set to be spanning one plus the last breakpoint max width up
-	 *    until PHP_INT_MAX. So a breakpoint cannot be PHP_INT_MAX because then the minimum viewport width for the final group
-	 *    would end up being larger than PHP_INT_MAX.
-	 *
 	 * This array may be empty in which case there are no responsive breakpoints and all URL Metrics are collected in a
 	 * single group.
 	 *
-	 * @var int[]
-	 * @phpstan-var positive-int[]
+	 * @since 0.1.0
+	 * @var positive-int[]
 	 */
 	private $breakpoints;
 
@@ -65,8 +62,8 @@
 	/**
 	 * Sample size for URL Metrics for a given breakpoint.
 	 *
-	 * @var int
-	 * @phpstan-var positive-int
+	 * @since 0.1.0
+	 * @var int<1, max>
 	 */
 	private $sample_size;
 
@@ -75,8 +72,8 @@
 	 *
 	 * A freshness age of zero means a URL Metric will always be considered stale.
 	 *
-	 * @var int
-	 * @phpstan-var 0|positive-int
+	 * @since 0.1.0
+	 * @var int<0, max>
 	 */
 	private $freshness_ttl;
 
@@ -83,6 +80,7 @@
 	/**
 	 * Result cache.
 	 *
+	 * @since 0.3.0
 	 * @var array{
 	 *          get_group_for_viewport_width?: array<int, OD_URL_Metric_Group>,
 	 *          is_every_group_populated?: bool,
@@ -91,7 +89,7 @@
 	 *          get_groups_by_lcp_element?: array<string, OD_URL_Metric_Group[]>,
 	 *          get_common_lcp_element?: OD_Element|null,
 	 *          get_all_element_max_intersection_ratios?: array<string, float>,
-	 *          get_xpath_elements_map?: array<string, non-empty-array<int, OD_Element>>,
+	 *          get_xpath_elements_map?: array<string, non-empty-array<non-negative-int, OD_Element>>,
 	 *          get_all_elements_positioned_in_any_initial_viewport?: array<string, bool>,
 	 *      }
 	 */
@@ -100,8 +98,14 @@
 	/**
 	 * Constructor.
 	 *
+	 * @since 0.1.0
+	 *
 	 * @throws InvalidArgumentException When an invalid argument is supplied.
 	 *
+	 * @phpstan-param positive-int[] $breakpoints
+	 * @phpstan-param int<1, max>    $sample_size
+	 * @phpstan-param int<0, max>    $freshness_ttl
+	 *
 	 * @param OD_URL_Metric[]  $url_metrics   URL Metrics.
 	 * @param non-empty-string $current_etag  The current ETag.
 	 * @param int[]            $breakpoints   Breakpoints in max widths.
@@ -127,13 +131,13 @@
 		sort( $breakpoints );
 		$breakpoints = array_values( array_unique( $breakpoints, SORT_NUMERIC ) );
 		foreach ( $breakpoints as $breakpoint ) {
-			if ( ! is_int( $breakpoint ) || $breakpoint < 1 || PHP_INT_MAX === $breakpoint ) {
+			if ( ! is_int( $breakpoint ) || $breakpoint < 1 ) {
 				throw new InvalidArgumentException(
 					esc_html(
 						sprintf(
 							/* translators: %d is the invalid breakpoint */
 							__(
-								'Each of the breakpoints must be greater than zero and less than PHP_INT_MAX, but encountered: %d',
+								'Each of the breakpoints must be greater than zero, but encountered: %d',
 								'optimization-detective'
 							),
 							$breakpoint
@@ -196,6 +200,39 @@
 	}
 
 	/**
+	 * Gets the breakpoints in max widths.
+	 *
+	 * @since 1.0.0
+	 *
+	 * @return positive-int[] Breakpoints in max widths.
+	 */
+	public function get_breakpoints(): array {
+		return $this->breakpoints;
+	}
+
+	/**
+	 * Gets the sample size for URL Metrics for a given breakpoint.
+	 *
+	 * @since 1.0.0
+	 *
+	 * @return int<1, max> Sample size for URL Metrics for a given breakpoint.
+	 */
+	public function get_sample_size(): int {
+		return $this->sample_size;
+	}
+
+	/**
+	 * Gets the freshness age (TTL) for a given URL Metric..
+	 *
+	 * @since 1.0.0
+	 *
+	 * @return int<0, max> Freshness age (TTL) for a given URL Metric.
+	 */
+	public function get_freshness_ttl(): int {
+		return $this->freshness_ttl;
+	}
+
+	/**
 	 * Gets the first URL Metric group.
 	 *
 	 * This group normally represents viewports for mobile devices. This group always has a minimum viewport width of 0
@@ -215,7 +252,7 @@
 	 *
 	 * This group normally represents viewports for desktop devices.  This group always has a minimum viewport width
 	 * defined as one greater than the largest breakpoint returned by {@see od_get_breakpoint_max_widths()}.
-	 * The maximum viewport is always `PHP_INT_MAX`, or in other words it is unbounded.
+	 * The maximum viewport width of this group is always `null`, or in other words it is unbounded.
 	 *
 	 * @since 0.7.0
 	 *
@@ -244,13 +281,13 @@
 	 * @return OD_URL_Metric_Group[] Groups.
 	 */
 	private function create_groups(): array {
-		$groups    = array();
-		$min_width = 0;
-		foreach ( $this->breakpoints as $max_width ) {
-			$groups[]  = new OD_URL_Metric_Group( array(), $min_width, $max_width, $this->sample_size, $this->freshness_ttl, $this );
-			$min_width = $max_width + 1;
+		$groups              = array();
+		$min_width_exclusive = 0;
+		foreach ( $this->breakpoints as $max_width_inclusive ) {
+			$groups[]            = new OD_URL_Metric_Group( array(), $min_width_exclusive, $max_width_inclusive, $this->sample_size, $this->freshness_ttl, $this );
+			$min_width_exclusive = $max_width_inclusive;
 		}
-		$groups[] = new OD_URL_Metric_Group( array(), $min_width, PHP_INT_MAX, $this->sample_size, $this->freshness_ttl, $this );
+		$groups[] = new OD_URL_Metric_Group( array(), $min_width_exclusive, null, $this->sample_size, $this->freshness_ttl, $this );
 		return $groups;
 	}
 
@@ -272,7 +309,7 @@
 			}
 		}
 		// @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.
+		// In practice this exception should never get thrown because create_groups() creates groups from a minimum of 0 to an unbounded maximum.
 		throw new InvalidArgumentException(
 			esc_html__( 'No group available to add URL Metric to.', 'optimization-detective' )
 		);
@@ -285,7 +322,7 @@
 	 * @since 0.1.0
 	 * @throws InvalidArgumentException When there is no group for the provided viewport width. This would only happen if a negative width is provided.
 	 *
-	 * @param int $viewport_width Viewport width.
+	 * @param positive-int $viewport_width Viewport width.
 	 * @return OD_URL_Metric_Group URL Metric group for the viewport width.
 	 */
 	public function get_group_for_viewport_width( int $viewport_width ): OD_URL_Metric_Group {
@@ -300,7 +337,7 @@
 				}
 			}
 			// @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.
+			// In practice this exception should never get thrown because create_groups() creates groups from a minimum of 0 to an unbounded maximum.
 			throw new InvalidArgumentException(
 				esc_html(
 					sprintf(
@@ -495,7 +532,7 @@
 	 *
 	 * @since 0.7.0
 	 *
-	 * @return array<string, non-empty-array<int, OD_Element>> Keys are XPaths and values are the element instances.
+	 * @return array<string, non-empty-array<non-negative-int, OD_Element>> Keys are XPaths and values are the element instances.
 	 */
 	public function get_xpath_elements_map(): array {
 		if ( array_key_exists( __FUNCTION__, $this->result_cache ) ) {
@@ -667,8 +704,8 @@
 	 *             every_group_populated: bool,
 	 *             groups: array<int, array{
 	 *                 lcp_element: ?OD_Element,
-	 *                 minimum_viewport_width: 0|positive-int,
-	 *                 maximum_viewport_width: positive-int,
+	 *                 minimum_viewport_width: int<0, max>,
+	 *                 maximum_viewport_width: int<1, max>|null,
 	 *                 complete: bool,
 	 *                 url_metrics: OD_URL_Metric[]
 	 *             }>
Index: class-od-url-metric-group.php
===================================================================
--- class-od-url-metric-group.php	(revision 3238281)
+++ class-od-url-metric-group.php	(working copy)
@@ -18,7 +18,6 @@
  * @implements IteratorAggregate<int, OD_URL_Metric>
  *
  * @since 0.1.0
- * @access private
  */
 final class OD_URL_Metric_Group implements IteratorAggregate, Countable, JsonSerializable {
 
@@ -32,22 +31,20 @@
 	private $url_metrics;
 
 	/**
-	 * Minimum possible viewport width for the group (inclusive).
+	 * Minimum possible viewport width for the group (exclusive).
 	 *
 	 * @since 0.1.0
 	 *
-	 * @var int
-	 * @phpstan-var 0|positive-int
+	 * @var int<0, max>
 	 */
 	private $minimum_viewport_width;
 
 	/**
-	 * Maximum possible viewport width for the group (inclusive).
+	 * Maximum possible viewport width for the group (inclusive), where null means it is unbounded.
 	 *
 	 * @since 0.1.0
 	 *
-	 * @var int
-	 * @phpstan-var positive-int
+	 * @var int<1, max>|null
 	 */
 	private $maximum_viewport_width;
 
@@ -56,8 +53,7 @@
 	 *
 	 * @since 0.1.0
 	 *
-	 * @var int
-	 * @phpstan-var positive-int
+	 * @var int<1, max>
 	 */
 	private $sample_size;
 
@@ -66,8 +62,7 @@
 	 *
 	 * @since 0.1.0
 	 *
-	 * @var int
-	 * @phpstan-var 0|positive-int
+	 * @var int<0, max>
 	 */
 	private $freshness_ttl;
 
@@ -99,32 +94,41 @@
 	 *
 	 * This class should never be directly constructed. It should only be constructed by the {@see OD_URL_Metric_Group_Collection::create_groups()}.
 	 *
+	 * @since 0.1.0
+	 *
 	 * @access private
 	 * @throws InvalidArgumentException If arguments are invalid.
 	 *
+	 * @phpstan-param int<0, max>      $minimum_viewport_width
+	 * @phpstan-param int<1, max>|null $maximum_viewport_width
+	 * @phpstan-param int<1, max>      $sample_size
+	 * @phpstan-param int<0, max>      $freshness_ttl
+	 *
 	 * @param OD_URL_Metric[]                $url_metrics            URL Metrics to add to the group.
-	 * @param int                            $minimum_viewport_width Minimum possible viewport width for the group. Must be zero or greater.
-	 * @param int                            $maximum_viewport_width Maximum possible viewport width for the group. Must be greater than zero and the minimum viewport width.
+	 * @param int                            $minimum_viewport_width Minimum possible viewport width (exclusive) for the group. Must be zero or greater.
+	 * @param int|null                       $maximum_viewport_width Maximum possible viewport width (inclusive) for the group. Must be greater than zero and the minimum viewport width. Null means unbounded.
 	 * @param int                            $sample_size            Sample size for the maximum number of viewports in a group between breakpoints.
 	 * @param int                            $freshness_ttl          Freshness age (TTL) for a given URL Metric.
 	 * @param OD_URL_Metric_Group_Collection $collection             Collection that this instance belongs to.
 	 */
-	public function __construct( array $url_metrics, int $minimum_viewport_width, int $maximum_viewport_width, int $sample_size, int $freshness_ttl, OD_URL_Metric_Group_Collection $collection ) {
+	public function __construct( array $url_metrics, int $minimum_viewport_width, ?int $maximum_viewport_width, int $sample_size, int $freshness_ttl, OD_URL_Metric_Group_Collection $collection ) {
 		if ( $minimum_viewport_width < 0 ) {
 			throw new InvalidArgumentException(
 				esc_html__( 'The minimum viewport width must be at least zero.', 'optimization-detective' )
 			);
 		}
-		if ( $maximum_viewport_width < 1 ) {
-			throw new InvalidArgumentException(
-				esc_html__( 'The maximum viewport width must be greater than zero.', 'optimization-detective' )
-			);
+		if ( isset( $maximum_viewport_width ) ) {
+			if ( $maximum_viewport_width < 1 ) {
+				throw new InvalidArgumentException(
+					esc_html__( 'The maximum viewport width must be greater than zero.', 'optimization-detective' )
+				);
+			}
+			if ( $minimum_viewport_width >= $maximum_viewport_width ) {
+				throw new InvalidArgumentException(
+					esc_html__( 'The minimum viewport width must be smaller than the maximum viewport width.', 'optimization-detective' )
+				);
+			}
 		}
-		if ( $minimum_viewport_width >= $maximum_viewport_width ) {
-			throw new InvalidArgumentException(
-				esc_html__( 'The minimum viewport width must be smaller than the maximum viewport width.', 'optimization-detective' )
-			);
-		}
 		$this->minimum_viewport_width = $minimum_viewport_width;
 		$this->maximum_viewport_width = $maximum_viewport_width;
 
@@ -158,12 +162,12 @@
 	}
 
 	/**
-	 * Gets the minimum possible viewport width (inclusive).
+	 * Gets the minimum possible viewport width (exclusive).
 	 *
 	 * @since 0.1.0
 	 *
 	 * @todo Eliminate in favor of readonly public property.
-	 * @return int<0, max> Minimum viewport width.
+	 * @return int<0, max> Minimum viewport width (exclusive).
 	 */
 	public function get_minimum_viewport_width(): int {
 		return $this->minimum_viewport_width;
@@ -175,9 +179,9 @@
 	 * @since 0.1.0
 	 *
 	 * @todo Eliminate in favor of readonly public property.
-	 * @return int<1, max> Minimum viewport width.
+	 * @return int<1, max>|null Minimum viewport width (inclusive). Null means unbounded.
 	 */
-	public function get_maximum_viewport_width(): int {
+	public function get_maximum_viewport_width(): ?int {
 		return $this->maximum_viewport_width;
 	}
 
@@ -187,8 +191,7 @@
 	 * @since 0.9.0
 	 *
 	 * @todo Eliminate in favor of readonly public property.
-	 * @phpstan-return positive-int
-	 * @return int Sample size.
+	 * @return int<1, max> Sample size.
 	 */
 	public function get_sample_size(): int {
 		return $this->sample_size;
@@ -200,8 +203,7 @@
 	 * @since 0.9.0
 	 *
 	 * @todo Eliminate in favor of readonly public property.
-	 * @phpstan-return 0|positive-int
-	 * @return int Freshness age.
+	 * @return int<0, max> Freshness age.
 	 */
 	public function get_freshness_ttl(): int {
 		return $this->freshness_ttl;
@@ -208,17 +210,31 @@
 	}
 
 	/**
-	 * Checks whether the provided viewport width is within the minimum/maximum range for.
+	 * Gets the collection that this group is a part of.
 	 *
+	 * @since 1.0.0
+	 *
+	 * @todo Eliminate in favor of readonly public property.
+	 * @return OD_URL_Metric_Group_Collection Collection.
+	 */
+	public function get_collection(): OD_URL_Metric_Group_Collection {
+		return $this->collection;
+	}
+
+	/**
+	 * Checks whether the provided viewport width is between the minimum (exclusive) and maximum (inclusive).
+	 *
 	 * @since 0.1.0
 	 *
+	 * @phpstan-param int<1, max> $viewport_width
+	 *
 	 * @param int $viewport_width Viewport width.
 	 * @return bool Whether the viewport width is in range.
 	 */
 	public function is_viewport_width_in_range( int $viewport_width ): bool {
 		return (
-			$viewport_width >= $this->minimum_viewport_width &&
-			$viewport_width <= $this->maximum_viewport_width
+			$viewport_width > $this->minimum_viewport_width &&
+			( null === $this->maximum_viewport_width || $viewport_width <= $this->maximum_viewport_width )
 		);
 	}
 
@@ -286,11 +302,6 @@
 					return false;
 				}
 
-				// The ETag is not populated yet, so this is stale. Eventually this will be required.
-				if ( $url_metric->get_etag() === null ) {
-					return false;
-				}
-
 				// The ETag of the URL Metric does not match the current ETag for the collection, so it is stale.
 				if ( ! hash_equals( $url_metric->get_etag(), $this->collection->get_current_etag() ) ) {
 					return false;
@@ -329,7 +340,7 @@
 			/**
 			 * Seen breadcrumbs counts.
 			 *
-			 * @var array<int, string> $seen_breadcrumbs
+			 * @var array<int, non-empty-string> $seen_breadcrumbs
 			 */
 			$seen_breadcrumbs = array();
 
@@ -336,7 +347,7 @@
 			/**
 			 * Breadcrumb counts.
 			 *
-			 * @var array<int, int> $breadcrumb_counts
+			 * @var array<int, non-negative-int> $breadcrumb_counts
 			 */
 			$breadcrumb_counts = array();
 
@@ -489,8 +500,8 @@
 	 * @return array{
 	 *             freshness_ttl: 0|positive-int,
 	 *             sample_size: positive-int,
-	 *             minimum_viewport_width: 0|positive-int,
-	 *             maximum_viewport_width: positive-int,
+	 *             minimum_viewport_width: int<0, max>,
+	 *             maximum_viewport_width: int<1, max>|null,
 	 *             lcp_element: ?OD_Element,
 	 *             complete: bool,
 	 *             url_metrics: OD_URL_Metric[]
Index: class-od-url-metric.php
===================================================================
--- class-od-url-metric.php	(revision 3238281)
+++ class-od-url-metric.php	(working copy)
@@ -16,8 +16,8 @@
  * Representation of the measurements taken from a single client's visit to a specific URL.
  *
  * @phpstan-type ViewportRect array{
- *                                width: int,
- *                                height: int
+ *                                width: positive-int,
+ *                                height: positive-int
  *                            }
  * @phpstan-type DOMRect      array{
  *                                width: float,
@@ -39,7 +39,7 @@
  *                            }
  * @phpstan-type Data         array{
  *                                uuid: non-empty-string,
- *                                etag?: non-empty-string,
+ *                                etag: non-empty-string,
  *                                url: non-empty-string,
  *                                timestamp: float,
  *                                viewport: ViewportRect,
@@ -60,7 +60,6 @@
  *                            }
  *
  * @since 0.1.0
- * @access private
  */
 class OD_URL_Metric implements JsonSerializable {
 
@@ -67,6 +66,7 @@
 	/**
 	 * Data.
 	 *
+	 * @since 0.1.0
 	 * @var Data
 	 */
 	protected $data;
@@ -74,6 +74,7 @@
 	/**
 	 * Elements.
 	 *
+	 * @since 0.7.0
 	 * @var OD_Element[]
 	 */
 	protected $elements;
@@ -89,6 +90,8 @@
 	/**
 	 * Constructor.
 	 *
+	 * @since 0.1.0
+	 *
 	 * @phpstan-param Data|array<string, mixed> $data Valid data or invalid data (in which case an exception is thrown).
 	 *
 	 * @throws OD_Data_Validation_Exception When the input is invalid.
@@ -105,6 +108,8 @@
 	/**
 	 * Prepares data with validation and sanitization.
 	 *
+	 * @since 0.6.0
+	 *
 	 * @throws OD_Data_Validation_Exception When the input is invalid.
 	 *
 	 * @param array<string, mixed> $data Data to validate.
@@ -171,6 +176,7 @@
 	 *
 	 * @since 0.1.0
 	 * @since 0.9.0 Added the 'etag' property to the schema.
+	 * @since 1.0.0 The 'etag' property is now required.
 	 *
 	 * @todo Cache the return value?
 	 *
@@ -230,7 +236,7 @@
 					'pattern'     => '^[0-9a-f]{32}\z',
 					'minLength'   => 32,
 					'maxLength'   => 32,
-					'required'    => false, // To be made required in a future release.
+					'required'    => true,
 					'readonly'    => true, // Omit from REST API.
 				),
 				'url'       => array(
@@ -248,12 +254,12 @@
 						'width'  => array(
 							'type'     => 'integer',
 							'required' => true,
-							'minimum'  => 0,
+							'minimum'  => 1,
 						),
 						'height' => array(
 							'type'     => 'integer',
 							'required' => true,
-							'minimum'  => 0,
+							'minimum'  => 1,
 						),
 					),
 					'additionalProperties' => false,
@@ -349,7 +355,6 @@
 	 * @param array<string, mixed> $properties_schema     Properties schema to extend.
 	 * @param array<string, mixed> $additional_properties Additional properties.
 	 * @param string               $filter_name           Filter name used to extend.
-	 *
 	 * @return array<string, mixed> Extended schema.
 	 */
 	protected static function extend_schema_with_optional_properties( array $properties_schema, array $additional_properties, string $filter_name ): array {
@@ -436,7 +441,7 @@
 	 *
 	 * @since 0.6.0
 	 *
-	 * @return string UUID.
+	 * @return non-empty-string UUID.
 	 */
 	public function get_uuid(): string {
 		return $this->data['uuid'];
@@ -446,12 +451,12 @@
 	 * Gets ETag.
 	 *
 	 * @since 0.9.0
+	 * @since 1.0.0 No longer returns null as 'etag' is now required.
 	 *
-	 * @return non-empty-string|null ETag.
+	 * @return non-empty-string ETag.
 	 */
-	public function get_etag(): ?string {
-		// Since the ETag is optional for now, return null for old URL Metrics that do not have one.
-		return $this->data['etag'] ?? null;
+	public function get_etag(): string {
+		return $this->data['etag'];
 	}
 
 	/**
@@ -459,7 +464,7 @@
 	 *
 	 * @since 0.1.0
 	 *
-	 * @return string URL.
+	 * @return non-empty-string URL.
 	 */
 	public function get_url(): string {
 		return $this->data['url'];
@@ -481,7 +486,7 @@
 	 *
 	 * @since 0.1.0
 	 *
-	 * @return int Viewport width.
+	 * @return positive-int Viewport width.
 	 */
 	public function get_viewport_width(): int {
 		return $this->data['viewport']['width'];
Index: class-od-visited-tag-state.php
===================================================================
--- class-od-visited-tag-state.php	(revision 3238281)
+++ class-od-visited-tag-state.php	(working copy)
@@ -30,6 +30,8 @@
 
 	/**
 	 * Constructor.
+	 *
+	 * @since 1.0.0
 	 */
 	public function __construct() {
 		$this->reset();
Index: detect.js
===================================================================
--- detect.js	(revision 3238281)
+++ detect.js	(working copy)
@@ -96,25 +96,68 @@
 }
 
 /**
- * Checks whether the URL Metric(s) for the provided viewport width is needed.
+ * Gets the status for the URL Metric group for the provided viewport width.
  *
+ * The comparison logic here corresponds with the PHP logic in `OD_URL_Metric_Group::is_viewport_width_in_range()`.
+ * This function is also similar to the PHP logic in `\OD_URL_Metric_Group_Collection::get_group_for_viewport_width()`.
+ *
  * @param {number}                 viewportWidth          - Current viewport width.
  * @param {URLMetricGroupStatus[]} urlMetricGroupStatuses - Viewport group statuses.
- * @return {boolean} Whether URL Metrics are needed.
+ * @return {URLMetricGroupStatus} The URL metric group for the viewport width.
  */
-function isViewportNeeded( viewportWidth, urlMetricGroupStatuses ) {
-	let lastWasLacking = false;
-	for ( const { minimumViewportWidth, complete } of urlMetricGroupStatuses ) {
-		if ( viewportWidth >= minimumViewportWidth ) {
-			lastWasLacking = ! complete;
-		} else {
-			break;
+function getGroupForViewportWidth( viewportWidth, urlMetricGroupStatuses ) {
+	for ( const urlMetricGroupStatus of urlMetricGroupStatuses ) {
+		if (
+			viewportWidth > urlMetricGroupStatus.minimumViewportWidth &&
+			( null === urlMetricGroupStatus.maximumViewportWidth ||
+				viewportWidth <= urlMetricGroupStatus.maximumViewportWidth )
+		) {
+			return urlMetricGroupStatus;
 		}
 	}
-	return lastWasLacking;
+	throw new Error(
+		`${ consoleLogPrefix } Unexpectedly unable to locate group for the current viewport width.`
+	);
 }
 
 /**
+ * Gets the sessionStorage key for keeping track of whether the current client session already submitted a URL Metric.
+ *
+ * @param {string}               currentETag          - Current ETag.
+ * @param {string}               currentUrl           - Current URL.
+ * @param {URLMetricGroupStatus} urlMetricGroupStatus - URL Metric group status.
+ * @return {Promise<string>} Session storage key.
+ */
+async function getAlreadySubmittedSessionStorageKey(
+	currentETag,
+	currentUrl,
+	urlMetricGroupStatus
+) {
+	const message = [
+		currentETag,
+		currentUrl,
+		urlMetricGroupStatus.minimumViewportWidth,
+		urlMetricGroupStatus.maximumViewportWidth || '',
+	].join( '-' );
+
+	/*
+	 * Note that the components are hashed for a couple of reasons:
+	 *
+	 * 1. It results in a consistent length string devoid of any special characters that could cause problems.
+	 * 2. Since the key includes the URL, hashing it avoids potential privacy concerns where the sessionStorage is
+	 *    examined to see which URLs the client went to.
+	 *
+	 * The SHA-1 algorithm is chosen since it is the fastest and there is no need for cryptographic security.
+	 */
+	const msgBuffer = new TextEncoder().encode( message );
+	const hashBuffer = await crypto.subtle.digest( 'SHA-1', msgBuffer );
+	const hashHex = Array.from( new Uint8Array( hashBuffer ) )
+		.map( ( b ) => b.toString( 16 ).padStart( 2, '0' ) )
+		.join( '' );
+	return `odSubmitted-${ hashHex }`;
+}
+
+/**
  * Gets the current time in milliseconds.
  *
  * @return {number} Current time in milliseconds.
@@ -253,6 +296,7 @@
  * @param {number}                 args.maxViewportAspectRatio     Maximum aspect ratio allowed for the viewport.
  * @param {boolean}                args.isDebug                    Whether to show debug messages.
  * @param {string}                 args.restApiEndpoint            URL for where to send the detection data.
+ * @param {string}                 [args.restApiNonce]             Nonce for the REST API when the user is logged-in.
  * @param {string}                 args.currentETag                Current ETag.
  * @param {string}                 args.currentUrl                 Current URL.
  * @param {string}                 args.urlMetricSlug              Slug for URL Metric.
@@ -260,6 +304,7 @@
  * @param {string}                 args.urlMetricHMAC              HMAC for URL Metric storage.
  * @param {URLMetricGroupStatus[]} args.urlMetricGroupStatuses     URL Metric group statuses.
  * @param {number}                 args.storageLockTTL             The TTL (in seconds) for the URL Metric storage lock.
+ * @param {number}                 args.freshnessTTL               The freshness age (TTL) for a given URL Metric.
  * @param {string}                 args.webVitalsLibrarySrc        The URL for the web-vitals library.
  * @param {CollectionDebugData}    [args.urlMetricGroupCollection] URL Metric group collection, when in debug mode.
  */
@@ -269,6 +314,7 @@
 	isDebug,
 	extensionModuleUrls,
 	restApiEndpoint,
+	restApiNonce,
 	currentETag,
 	currentUrl,
 	urlMetricSlug,
@@ -276,6 +322,7 @@
 	urlMetricHMAC,
 	urlMetricGroupStatuses,
 	storageLockTTL,
+	freshnessTTL,
 	webVitalsLibrarySrc,
 	urlMetricGroupCollection,
 } ) {
@@ -297,8 +344,21 @@
 		);
 	}
 
+	if ( win.innerWidth === 0 || win.innerHeight === 0 ) {
+		if ( isDebug ) {
+			log(
+				'Window must have non-zero dimensions for URL Metric collection.'
+			);
+		}
+		return;
+	}
+
 	// Abort if the current viewport is not among those which need URL Metrics.
-	if ( ! isViewportNeeded( win.innerWidth, urlMetricGroupStatuses ) ) {
+	const urlMetricGroupStatus = getGroupForViewportWidth(
+		win.innerWidth,
+		urlMetricGroupStatuses
+	);
+	if ( urlMetricGroupStatus.complete ) {
 		if ( isDebug ) {
 			log( 'No need for URL Metrics from the current viewport.' );
 		}
@@ -305,6 +365,31 @@
 		return;
 	}
 
+	// Abort if the client already submitted a URL Metric for this URL and viewport group.
+	const alreadySubmittedSessionStorageKey =
+		await getAlreadySubmittedSessionStorageKey(
+			currentETag,
+			currentUrl,
+			urlMetricGroupStatus
+		);
+	if ( alreadySubmittedSessionStorageKey in sessionStorage ) {
+		const previousVisitTime = parseInt(
+			sessionStorage.getItem( alreadySubmittedSessionStorageKey ),
+			10
+		);
+		if (
+			! isNaN( previousVisitTime ) &&
+			( getCurrentTime() - previousVisitTime ) / 1000 < freshnessTTL
+		) {
+			if ( isDebug ) {
+				log(
+					'The current client session already submitted a fresh URL Metric for this URL so a new one will not be collected now.'
+				);
+				return;
+			}
+		}
+	}
+
 	// Abort if the viewport aspect ratio is not in a common range.
 	const aspectRatio = win.innerWidth / win.innerHeight;
 	if (
@@ -603,6 +688,7 @@
 		return;
 	}
 
+	// Finalize extensions.
 	if ( extensions.size > 0 ) {
 		/** @type {Promise[]} */
 		const extensionFinalizePromises = [];
@@ -655,15 +741,63 @@
 		}
 	}
 
+	/*
+	 * Now prepare the URL Metric to be sent as JSON request body.
+	 */
+
+	const maxBodyLengthKiB = 64;
+	const maxBodyLengthBytes = maxBodyLengthKiB * 1024;
+
+	// TODO: Consider adding replacer to reduce precision on numbers in DOMRect to reduce payload size.
+	const jsonBody = JSON.stringify( urlMetric );
+	const percentOfBudget =
+		( jsonBody.length / ( maxBodyLengthKiB * 1000 ) ) * 100;
+
+	/*
+	 * According to the fetch() spec:
+	 * "If the sum of contentLength and inflightKeepaliveBytes is greater than 64 kibibytes, then return a network error."
+	 * This is what browsers also implement for navigator.sendBeacon(). Therefore, if the size of the JSON is greater
+	 * than the maximum, we should avoid even trying to send it.
+	 */
+	if ( jsonBody.length > maxBodyLengthBytes ) {
+		if ( isDebug ) {
+			error(
+				`Unable to send URL Metric because it is ${ jsonBody.length.toLocaleString() } bytes, ${ Math.round(
+					percentOfBudget
+				) }% of ${ maxBodyLengthKiB } KiB limit:`,
+				urlMetric
+			);
+		}
+		return;
+	}
+
 	// Even though the server may reject the REST API request, we still have to set the storage lock
 	// because we can't look at the response when sending a beacon.
 	setStorageLock( getCurrentTime() );
 
+	// Remember that the URL Metric was submitted for this URL to avoid having multiple entries submitted by the same client.
+	sessionStorage.setItem(
+		alreadySubmittedSessionStorageKey,
+		String( getCurrentTime() )
+	);
+
 	if ( isDebug ) {
-		log( 'Sending URL Metric:', urlMetric );
+		const message = `Sending URL Metric (${ jsonBody.length.toLocaleString() } bytes, ${ Math.round(
+			percentOfBudget
+		) }% of ${ maxBodyLengthKiB } KiB limit):`;
+
+		// The threshold of 50% is used because the limit for all beacons combined is 64 KiB, not just the data for one beacon.
+		if ( percentOfBudget < 50 ) {
+			log( message, urlMetric );
+		} else {
+			warn( message, urlMetric );
+		}
 	}
 
 	const url = new URL( restApiEndpoint );
+	if ( typeof restApiNonce === 'string' ) {
+		url.searchParams.set( '_wpnonce', restApiNonce );
+	}
 	url.searchParams.set( 'slug', urlMetricSlug );
 	url.searchParams.set( 'current_etag', currentETag );
 	if ( typeof cachePurgePostId === 'number' ) {
@@ -675,7 +809,7 @@
 	url.searchParams.set( 'hmac', urlMetricHMAC );
 	navigator.sendBeacon(
 		url,
-		new Blob( [ JSON.stringify( urlMetric ) ], {
+		new Blob( [ jsonBody ], {
 			type: 'application/json',
 		} )
 	);
Index: detect.min.js
===================================================================
--- detect.min.js	(revision 3238281)
+++ detect.min.js	(working copy)
@@ -1 +1 @@
(Large diff suppressed)

Index: detection.php
===================================================================
--- detection.php	(revision 3238281)
+++ detection.php	(working copy)
@@ -38,11 +38,11 @@
  *
  * @global WP_Query $wp_query WordPress Query object.
  *
- * @return int|null Post ID or null if none found.
+ * @return positive-int|null Post ID or null if none found.
  */
 function od_get_cache_purge_post_id(): ?int {
 	$queried_object = get_queried_object();
-	if ( $queried_object instanceof WP_Post ) {
+	if ( $queried_object instanceof WP_Post && $queried_object->ID > 0 ) {
 		return $queried_object->ID;
 	}
 
@@ -55,6 +55,8 @@
 		isset( $wp_query->posts[0] )
 		&&
 		$wp_query->posts[0] instanceof WP_Post
+		&&
+		$wp_query->posts[0]->ID > 0
 	) {
 		return $wp_query->posts[0]->ID;
 	}
@@ -68,7 +70,7 @@
  * @since 0.1.0
  * @access private
  *
- * @param string                         $slug             URL Metrics slug.
+ * @param non-empty-string               $slug             URL Metrics slug.
  * @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 {
@@ -128,7 +130,8 @@
 		'urlMetricGroupStatuses' => array_map(
 			static function ( OD_URL_Metric_Group $group ): array {
 				return array(
-					'minimumViewportWidth' => $group->get_minimum_viewport_width(),
+					'minimumViewportWidth' => $group->get_minimum_viewport_width(), // Exclusive.
+					'maximumViewportWidth' => $group->get_maximum_viewport_width(), // Inclusive.
 					'complete'             => $group->is_complete(),
 				);
 			},
@@ -135,8 +138,12 @@
 			iterator_to_array( $group_collection )
 		),
 		'storageLockTTL'         => OD_Storage_Lock::get_ttl(),
+		'freshnessTTL'           => od_get_url_metric_freshness_ttl(),
 		'webVitalsLibrarySrc'    => $web_vitals_lib_src,
 	);
+	if ( is_user_logged_in() ) {
+		$detect_args['restApiNonce'] = wp_create_nonce( 'wp_rest' );
+	}
 	if ( WP_DEBUG ) {
 		$detect_args['urlMetricGroupCollection'] = $group_collection;
 	}
Index: helper.php
===================================================================
--- helper.php	(revision 3238281)
+++ helper.php	(working copy)
@@ -22,6 +22,22 @@
 	/**
 	 * Fires when extensions to Optimization Detective can be loaded and initialized.
 	 *
+	 * 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.
+	 *
+	 * Example:
+	 *
+	 *     add_action( 'od_init', function ( string $version ) {
+	 *         if ( version_compare( $version, '1.0', '<' ) ) {
+	 *             add_action( 'admin_notices', 'my_plugin_warn_optimization_plugin_outdated' );
+	 *             return;
+	 *         }
+	 *
+	 *         // Bootstrap the Optimization Detective extension.
+	 *         require_once __DIR__ . '/functions.php';
+	 *         // ...
+	 *     } );
+	 *
 	 * @since 0.7.0
 	 *
 	 * @param string $version Optimization Detective version.
@@ -37,26 +53,26 @@
  *
  * @since 0.7.0
  *
- * @param int|null $minimum_viewport_width Minimum viewport width.
- * @param int|null $maximum_viewport_width Maximum viewport width.
+ * @param int<0, max>|null $minimum_viewport_width Minimum viewport width (exclusive).
+ * @param int<1, max>|null $maximum_viewport_width Maximum viewport width (inclusive).
  * @return non-empty-string|null Media query, or null if the min/max were both unspecified or invalid.
  */
 function od_generate_media_query( ?int $minimum_viewport_width, ?int $maximum_viewport_width ): ?string {
-	if ( is_int( $minimum_viewport_width ) && is_int( $maximum_viewport_width ) && $minimum_viewport_width > $maximum_viewport_width ) {
-		_doing_it_wrong( __FUNCTION__, esc_html__( 'The minimum width cannot be greater than the maximum width.', 'optimization-detective' ), 'Optimization Detective 0.7.0' );
+	if ( is_int( $minimum_viewport_width ) && is_int( $maximum_viewport_width ) && $minimum_viewport_width >= $maximum_viewport_width ) {
+		_doing_it_wrong( __FUNCTION__, esc_html__( 'The minimum width cannot be greater than or equal to the maximum width.', 'optimization-detective' ), 'Optimization Detective 0.7.0' );
 		return null;
 	}
-	$media_attributes = array();
-	if ( null !== $minimum_viewport_width && $minimum_viewport_width > 0 ) {
-		$media_attributes[] = sprintf( '(min-width: %dpx)', $minimum_viewport_width );
-	}
-	if ( null !== $maximum_viewport_width && PHP_INT_MAX !== $maximum_viewport_width ) {
-		$media_attributes[] = sprintf( '(max-width: %dpx)', $maximum_viewport_width );
-	}
-	if ( count( $media_attributes ) === 0 ) {
+	$has_min_width = ( null !== $minimum_viewport_width && $minimum_viewport_width > 0 );
+	$has_max_width = ( null !== $maximum_viewport_width && PHP_INT_MAX !== $maximum_viewport_width ); // Note: The use of PHP_INT_MAX is obsolete.
+	if ( $has_min_width && $has_max_width ) {
+		return sprintf( '(%dpx < width <= %dpx)', $minimum_viewport_width, $maximum_viewport_width );
+	} elseif ( $has_min_width ) {
+		return sprintf( '(%dpx < width)', $minimum_viewport_width );
+	} elseif ( $has_max_width ) {
+		return sprintf( '(width <= %dpx)', $maximum_viewport_width );
+	} else {
 		return null;
 	}
-	return join( ' and ', $media_attributes );
 }
 
 /**
Index: hooks.php
===================================================================
--- hooks.php	(revision 3238281)
+++ hooks.php	(working copy)
@@ -18,6 +18,7 @@
 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();
+OD_Storage_Lock::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' );
Index: load.php
===================================================================
--- load.php	(revision 3238281)
+++ load.php	(working copy)
@@ -2,10 +2,10 @@
 /**
  * Plugin Name: Optimization Detective
  * Plugin URI: https://github.com/WordPress/performance/tree/trunk/plugins/optimization-detective
- * Description: Provides an API for leveraging real user metrics to detect optimizations to apply on the frontend to improve page performance.
+ * Description: Provides a framework for leveraging real user metrics to detect optimizations for improving page performance.
  * Requires at least: 6.6
  * Requires PHP: 7.2
- * Version: 1.0.0-beta1
+ * Version: 1.0.0-beta2
  * Author: WordPress Performance Team
  * Author URI: https://make.wordpress.org/performance/
  * License: GPLv2 or later
@@ -71,7 +71,7 @@
 	}
 )(
 	'optimization_detective_pending_plugin',
-	'1.0.0-beta1',
+	'1.0.0-beta2',
 	static function ( string $version ): void {
 		if ( defined( 'OPTIMIZATION_DETECTIVE_VERSION' ) ) {
 			return;
Index: optimization.php
===================================================================
--- optimization.php	(revision 3238281)
+++ optimization.php	(working copy)
@@ -167,6 +167,8 @@
 		// > Access to script at '.../detect.js?ver=0.4.1' from origin 'null' has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present on the requested resource.
 		// So it's better to just avoid attempting to optimize Post Embed responses (which don't need optimization anyway).
 		is_embed() ||
+		// Skip posts that aren't published yet.
+		is_preview() ||
 		// Since injection of inline-editing controls interfere with breadcrumbs, while also just not necessary in this context.
 		is_customize_preview() ||
 		// Since the images detected in the response body of a POST request cannot, by definition, be cached.
@@ -251,6 +253,77 @@
 	/**
 	 * Fires to register tag visitors before walking over the document to perform optimizations.
 	 *
+	 * Once a page has finished rendering and the output buffer is processed, the page contents are loaded into
+	 * an HTML Tag Processor instance. It then iterates over each tag in the document, and at each open tag it will
+	 * invoke all registered tag visitors. A tag visitor is simply a callable (such as a regular function, closure,
+	 * or even a class with an `__invoke` method defined). The tag visitor callback is invoked by passing an instance
+	 * of the `OD_Tag_Visitor_Context` object which includes the following read-only properties:
+	 *
+	 * - `$processor` (`OD_HTML_Tag_Processor`): The processor with the cursor at the current open tag.
+	 * - `$url_metric_group_collection` (`OD_URL_Metric_Group_Collection`): The URL Metrics which may include information about the current tag to inform what optimizations the callback performs.
+	 * - `$link_collection` (`OD_Link_Collection`): Collection of links which will be added to the `HEAD` when the page is served. This allows you to add preload links and preconnect links as needed.
+	 * - `$url_metrics_id` (`positive-int|null`): The post ID for the `od_url_metrics` post from which the URL Metrics were loaded (if any). For advanced usage.
+	 *
+	 * Note that you are free to call `$processor->next_tag()` in the callback (such as to walk over any child elements)
+	 * since the tag processor's cursor will be reset to the tag after the callback finishes.
+	 *
+	 * When a tag visitor sees it is at a relevant open tag (e.g. by checking `$processor->get_tag()`), it can call the
+	 * `$context->track_tag()` method to indicate that the tag should be measured during detection. This will cause the
+	 * tag to be included among the `elements` in the stored URL Metrics. The element data includes properties such
+	 * as `intersectionRatio`, `intersectionRect`, and `boundingClientRect` (provided by an `IntersectionObserver`) as
+	 * well as whether the tag is the LCP element (`isLCP`) or LCP element candidate (`isLCPCandidate`). This method
+	 * should not be called if the current tag is not relevant for the tag visitor or if the tag visitor callback does
+	 * not need to query the provided `OD_URL_Metric_Group_Collection` instance to apply the desired optimizations. (In
+	 * addition to calling the `$context->track_tag()`, a callback may also return `true` to indicate the tag should be
+	 * tracked.)
+	 *
+	 * Here's an example tag visitor that depends on URL Metrics data:
+	 *
+	 *     $tag_visitor_registry->register(
+	 *         'lcp-img-fetchpriority-high',
+	 *         static function ( OD_Tag_Visitor_Context $context ): void {
+	 *             if ( $context->processor->get_tag() !== 'IMG' ) {
+	 *                 return; // Tag is not relevant for this tag visitor.
+	 *             }
+	 *
+	 *             // Mark the tag for measurement during detection so it is included among the elements stored in URL Metrics.
+	 *             $context->track_tag();
+	 *
+	 *             // Make sure fetchpriority=high is added to LCP IMG elements based on the captured URL Metrics.
+	 *             $common_lcp_element = $context->url_metric_group_collection->get_common_lcp_element();
+	 *             if (
+	 *                 null !== $common_lcp_element
+	 *                 &&
+	 *                 $common_lcp_element->get_xpath() === $context->processor->get_xpath()
+	 *             ) {
+	 *                 $context->processor->set_attribute( 'fetchpriority', 'high' );
+	 *             }
+	 *         }
+	 *     );
+	 *
+	 * Please note this implementation of setting `fetchpriority=high` on the LCP `IMG` element is simplified. Please
+	 * see the Image Prioritizer extension for a more robust implementation.
+	 *
+	 * Here's an example tag visitor that does not depend on any URL Metrics data:
+	 *
+	 *     $tag_visitor_registry->register(
+	 *         'img-decoding-async',
+	 *         static function ( OD_Tag_Visitor_Context $context ): bool {
+	 *             if ( $context->processor->get_tag() !== 'IMG' ) {
+	 *                 return; // Tag is not relevant for this tag visitor.
+	 *             }
+	 *
+	 *             // Set the decoding attribute if it is absent.
+	 *             if ( null === $context->processor->get_attribute( 'decoding' ) ) {
+	 *                 $context->processor->set_attribute( 'decoding', 'async' );
+	 *             }
+	 *         }
+	 *     );
+	 *
+	 * 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 additional
+	 * examples of how tag visitors are used.
+	 *
 	 * @since 0.3.0
 	 *
 	 * @param OD_Tag_Visitor_Registry $tag_visitor_registry Tag visitor registry.
@@ -267,7 +340,13 @@
 	);
 	$link_collection      = new OD_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 );
+	$tag_visitor_context  = new OD_Tag_Visitor_Context(
+		$processor,
+		$group_collection,
+		$link_collection,
+		$visited_tag_state,
+		$post instanceof WP_Post && $post->ID > 0 ? $post->ID : null
+	);
 	$current_tag_bookmark = 'optimization_detective_current_tag';
 	$visitors             = iterator_to_array( $tag_visitor_registry );
 
Index: readme.txt
===================================================================
--- readme.txt	(revision 3238281)
+++ readme.txt	(working copy)
@@ -2,12 +2,12 @@
 
 Contributors: wordpressdotorg
 Tested up to: 6.7
-Stable tag:   1.0.0-beta1
+Stable tag:   1.0.0-beta2
 License:      GPLv2 or later
 License URI:  https://www.gnu.org/licenses/gpl-2.0.html
 Tags:         performance, optimization, rum
 
-Provides an API for leveraging real user metrics to detect optimizations to apply on the frontend to improve page performance.
+Provides a framework for leveraging real user metrics to detect optimizations for improving page performance.
 
 == Description ==
 
@@ -55,6 +55,28 @@
 
 == Changelog ==
 
+= 1.0.0-beta2 =
+
+**Enhancements**
+
+* Account for 64 KiB limit for sending beacon data. ([1851](https://github.com/WordPress/performance/pull/1851))
+* Add post ID for the `od_url_metrics` post to the tag visitor context. ([1847](https://github.com/WordPress/performance/pull/1847))
+* Change minimum viewport width to be exclusive whereas the maximum width remains inclusive. ([1839](https://github.com/WordPress/performance/pull/1839))
+* Disable URL Metric storage locking by default for administrators. ([1835](https://github.com/WordPress/performance/pull/1835))
+* Include active plugins in ETag data and increase default freshness TTL from 1 day to 1 week. ([1854](https://github.com/WordPress/performance/pull/1854))
+* Make ETag a required property of the URL Metric. ([1824](https://github.com/WordPress/performance/pull/1824))
+* Use CSS range syntax in media queries. ([1833](https://github.com/WordPress/performance/pull/1833))
+* Use `IFRAME` to display HTML responses for REST API storage request failures in Site Health test. ([1849](https://github.com/WordPress/performance/pull/1849))
+
+**Bug Fixes**
+
+* Prevent URL in `Link` header from including invalid characters. ([1802](https://github.com/WordPress/performance/pull/1802))
+* Prevent optimizing post previews by default. ([1848](https://github.com/WordPress/performance/pull/1848))
+
+**Documentation**
+
+* Improve Optimization Detective documentation. ([1782](https://github.com/WordPress/performance/pull/1782))
+
 = 1.0.0-beta1 =
 
 **Enhancements**
Index: site-health.php
===================================================================
--- site-health.php	(revision 3238281)
+++ site-health.php	(working copy)
@@ -127,6 +127,10 @@
 		$message = wp_remote_retrieve_response_message( $response );
 		$body    = wp_remote_retrieve_body( $response );
 		$data    = json_decode( $body, true );
+		$header  = wp_remote_retrieve_header( $response, 'content-type' );
+		if ( is_array( $header ) ) {
+			$header = array_pop( $header );
+		}
 
 		$is_expected = (
 			400 === $code &&
@@ -156,7 +160,18 @@
 				$result['description'] .= '<blockquote>' . esc_html( $data['message'] ) . '</blockquote>';
 			}
 
-			$result['description'] .= '<details><summary>' . esc_html__( 'Raw response:', 'optimization-detective' ) . '</summary><pre style="white-space: pre-wrap">' . esc_html( $body ) . '</pre></details>';
+			if ( '' !== $body ) {
+				$result['description'] .= '<details>';
+				$result['description'] .= '<summary>' . esc_html__( 'Raw response:', 'optimization-detective' ) . '</summary>';
+
+				if ( is_string( $header ) && str_contains( $header, 'html' ) ) {
+					$escaped_content        = htmlspecialchars( $body, ENT_QUOTES, 'UTF-8' );
+					$result['description'] .= '<iframe srcdoc="' . $escaped_content . '" sandbox width="100%" height="300"></iframe>';
+				} else {
+					$result['description'] .= '<pre style="white-space: pre-wrap">' . esc_html( $body ) . '</pre>';
+				}
+				$result['description'] .= '</details>';
+			}
 		}
 	}
 	return $result;
@@ -238,7 +253,7 @@
 		$message = "<details>$message</details>";
 	}
 
-	wp_admin_notice(
+	$notice = wp_get_admin_notice(
 		$message,
 		array(
 			'type'               => 'warning',
@@ -246,6 +261,16 @@
 			'paragraph_wrap'     => false,
 		)
 	);
+
+	echo wp_kses(
+		$notice,
+		array_merge(
+			wp_kses_allowed_html( 'post' ),
+			array(
+				'iframe' => array_fill_keys( array( 'srcdoc', 'sandbox', 'width', 'height' ), true ),
+			)
+		)
+	);
 }
 
 /**
@@ -254,7 +279,7 @@
  * @since 1.0.0
  * @access private
  *
- * @param string $plugin_file Plugin file.
+ * @param non-empty-string $plugin_file Plugin file.
  */
 function od_render_rest_api_health_check_admin_notice_in_plugin_row( string $plugin_file ): void {
 	if ( 'optimization-detective/load.php' !== $plugin_file ) { // TODO: What if a different plugin slug is used?
Index: storage/class-od-storage-lock.php
===================================================================
--- storage/class-od-storage-lock.php	(revision 3238281)
+++ storage/class-od-storage-lock.php	(working copy)
@@ -21,19 +21,54 @@
 final class OD_Storage_Lock {
 
 	/**
+	 * Capability for being able to store a URL Metric now.
+	 *
+	 * @since 1.0.0
+	 * @var string
+	 */
+	const STORE_URL_METRIC_NOW_CAPABILITY = 'od_store_url_metric_now';
+
+	/**
+	 * Adds hooks.
+	 *
+	 * @since 1.0.0
+	 */
+	public static function add_hooks(): void {
+		add_filter( 'user_has_cap', array( __CLASS__, 'filter_user_has_cap' ) );
+	}
+
+	/**
+	 * Filters `user_has_cap` to grant the `od_store_url_metric_now` capability to users who can `manage_options` by default.
+	 *
+	 * @since 1.0.0
+	 *
+	 * @param array<string, bool>|mixed $allcaps Capability names mapped to boolean values for whether the user has that capability.
+	 * @return array<string, bool> Capability names mapped to boolean values for whether the user has that capability.
+	 */
+	public static function filter_user_has_cap( $allcaps ): array {
+		if ( ! is_array( $allcaps ) ) {
+			$allcaps = array();
+		}
+		if ( isset( $allcaps['manage_options'] ) ) {
+			$allcaps['od_store_url_metric_now'] = $allcaps['manage_options'];
+		}
+		return $allcaps;
+	}
+
+	/**
 	 * Gets the TTL (in seconds) for the URL Metric storage lock.
 	 *
 	 * @since 0.1.0
-	 * @access private
 	 *
-	 * @return int TTL in seconds, greater than or equal to zero. A value of zero means that the storage lock should be disabled and thus that transients must not be used.
+	 * @return int<0, max> TTL in seconds, greater than or equal to zero. A value of zero means that the storage lock should be disabled and thus that transients must not be used.
 	 */
 	public static function get_ttl(): int {
+		$ttl = current_user_can( self::STORE_URL_METRIC_NOW_CAPABILITY ) ? 0 : MINUTE_IN_SECONDS;
 
 		/**
-		 * Filters how long a given IP is locked from submitting another metric-storage REST API request.
+		 * Filters how long the current IP is locked from submitting another URL metric storage REST API request.
 		 *
-		 * Filtering the TTL to zero will disable any metric storage locking. This is useful, for example, to disable
+		 * Filtering the TTL to zero will disable any URL Metric storage locking. This is useful, for example, to disable
 		 * locking when a user is logged-in with code like the following:
 		 *
 		 *     add_filter( 'od_metrics_storage_lock_ttl', static function ( int $ttl ): int {
@@ -40,11 +75,16 @@
 		 *         return is_user_logged_in() ? 0 : $ttl;
 		 *     } );
 		 *
+		 * By default, the TTL is zero (0) for authorized users and sixty (60) for everyone else. Whether the current
+		 * user is authorized is determined by whether the user has the `od_store_url_metric_now` capability. This
+		 * custom capability by default maps to the `manage_options` primitive capability via the `user_has_cap` filter.
+		 *
 		 * @since 0.1.0
+		 * @since 1.0.0 This now defaults to zero (0) for authorized users.
 		 *
-		 * @param int $ttl TTL.
+		 * @param int $ttl TTL. Defaults to 60, except zero (0) for authorized users.
 		 */
-		$ttl = (int) apply_filters( 'od_url_metric_storage_lock_ttl', MINUTE_IN_SECONDS );
+		$ttl = (int) apply_filters( 'od_url_metric_storage_lock_ttl', $ttl );
 		return max( 0, $ttl );
 	}
 
@@ -51,8 +91,9 @@
 	/**
 	 * Gets transient key for locking URL Metric storage (for the current IP).
 	 *
-	 * @todo Should the URL be included in the key? Or should a user only be allowed to store one metric?
-	 * @return string Transient key.
+	 * @since 0.1.0
+	 *
+	 * @return non-empty-string Transient key.
 	 */
 	public static function get_transient_key(): string {
 		$ip_address = $_SERVER['HTTP_X_FORWARDED_FOR'] ?? $_SERVER['REMOTE_ADDR'];
@@ -66,7 +107,6 @@
 	 * seconds. Otherwise, if the current TTL is zero, then any transient is deleted.
 	 *
 	 * @since 0.1.0
-	 * @access private
 	 */
 	public static function set_lock(): void {
 		$ttl = self::get_ttl();
@@ -82,7 +122,6 @@
 	 * Checks whether URL Metric storage is locked (for the current IP).
 	 *
 	 * @since 0.1.0
-	 * @access private
 	 *
 	 * @return bool Whether locked.
 	 */
Index: storage/class-od-url-metric-store-request-context.php
===================================================================
--- storage/class-od-url-metric-store-request-context.php	(revision 3238281)
+++ storage/class-od-url-metric-store-request-context.php	(working copy)
@@ -16,7 +16,13 @@
  * Context for when a URL Metric is successfully stored via the REST API.
  *
  * @since 0.7.0
- * @access private
+ *
+ * @property-read WP_REST_Request<array<string, mixed>> $request                     Request.
+ * @property-read positive-int                          $url_metrics_id              ID for the od_url_metrics post.
+ * @property-read OD_URL_Metric_Group_Collection        $url_metric_group_collection URL Metric group collection.
+ * @property-read OD_URL_Metric_Group                   $url_metric_group            URL Metric group.
+ * @property-read OD_URL_Metric                         $url_metric                  URL Metric.
+ * @property-read positive-int                          $post_id                     Deprecated alias for the $url_metrics_id property.
  */
 final class OD_URL_Metric_Store_Request_Context {
 
@@ -23,59 +29,111 @@
 	/**
 	 * Request.
 	 *
+	 * @since 0.7.0
 	 * @var WP_REST_Request<array<string, mixed>>
-	 * @readonly
 	 */
-	public $request;
+	private $request;
 
 	/**
-	 * ID for the URL Metric post.
+	 * ID for the od_url_metrics post.
 	 *
-	 * @var int
-	 * @readonly
+	 * This was originally $post_id which was introduced in 0.7.0.
+	 *
+	 * @since 1.0.0
+	 * @var positive-int
 	 */
-	public $post_id;
+	private $url_metrics_id;
 
 	/**
 	 * URL Metric group collection.
 	 *
+	 * @since 0.7.0
 	 * @var OD_URL_Metric_Group_Collection
-	 * @readonly
 	 */
-	public $url_metric_group_collection;
+	private $url_metric_group_collection;
 
 	/**
 	 * URL Metric group.
 	 *
+	 * @since 0.7.0
 	 * @var OD_URL_Metric_Group
-	 * @readonly
 	 */
-	public $url_metric_group;
+	private $url_metric_group;
 
 	/**
 	 * URL Metric.
 	 *
+	 * @since 0.7.0
 	 * @var OD_URL_Metric
-	 * @readonly
 	 */
-	public $url_metric;
+	private $url_metric;
 
 	/**
 	 * Constructor.
 	 *
+	 * @since 0.7.0
+	 *
 	 * @phpstan-param WP_REST_Request<array<string, mixed>> $request
 	 *
 	 * @param WP_REST_Request                $request                     REST API request.
-	 * @param int                            $post_id                     ID for the URL Metric post.
+	 * @param positive-int                   $url_metrics_id              ID for the URL Metric post.
 	 * @param OD_URL_Metric_Group_Collection $url_metric_group_collection URL Metric group collection.
 	 * @param OD_URL_Metric_Group            $url_metric_group            URL Metric group.
 	 * @param OD_URL_Metric                  $url_metric                  URL Metric.
 	 */
-	public function __construct( WP_REST_Request $request, int $post_id, OD_URL_Metric_Group_Collection $url_metric_group_collection, OD_URL_Metric_Group $url_metric_group, OD_URL_Metric $url_metric ) {
+	public function __construct( WP_REST_Request $request, int $url_metrics_id, OD_URL_Metric_Group_Collection $url_metric_group_collection, OD_URL_Metric_Group $url_metric_group, OD_URL_Metric $url_metric ) {
 		$this->request                     = $request;
-		$this->post_id                     = $post_id;
+		$this->url_metrics_id              = $url_metrics_id;
 		$this->url_metric_group_collection = $url_metric_group_collection;
 		$this->url_metric_group            = $url_metric_group;
 		$this->url_metric                  = $url_metric;
 	}
+
+	/**
+	 * Gets a property.
+	 *
+	 * @since 1.0.0
+	 *
+	 * @param string $name Property name.
+	 * @return mixed Property value.
+	 *
+	 * @throws Error When property is unknown.
+	 */
+	public function __get( string $name ) {
+		switch ( $name ) {
+			case 'request':
+				return $this->request;
+			case 'url_metrics_id':
+				return $this->url_metrics_id;
+			case 'url_metric_group_collection':
+				return $this->url_metric_group_collection;
+			case 'url_metric_group':
+				return $this->url_metric_group;
+			case 'url_metric':
+				return $this->url_metric;
+			case 'post_id':
+				_doing_it_wrong(
+					esc_html( __CLASS__ . '::$' . $name ),
+					esc_html(
+						sprintf(
+							/* translators: %s is class member variable name */
+							__( 'Use %s instead.', 'optimization-detective' ),
+							__CLASS__ . '::$url_metrics_id'
+						)
+					),
+					'optimization-detective 1.0.0'
+				);
+				return $this->url_metrics_id;
+			default:
+				throw new Error(
+					esc_html(
+						sprintf(
+							/* translators: %s is class member variable name */
+							__( 'Unknown property %s.', 'optimization-detective' ),
+							__CLASS__ . '::$' . $name
+						)
+					)
+				);
+		}
+	}
 }
Index: storage/class-od-url-metrics-post-type.php
===================================================================
--- storage/class-od-url-metrics-post-type.php	(revision 3238281)
+++ storage/class-od-url-metrics-post-type.php	(working copy)
@@ -23,6 +23,7 @@
 	/**
 	 * Post type slug.
 	 *
+	 * @since 0.1.0
 	 * @var string
 	 */
 	const SLUG = 'od_url_metrics';
@@ -30,6 +31,7 @@
 	/**
 	 * Event name (hook) for garbage collection of stale URL Metrics posts.
 	 *
+	 * @since 0.1.0
 	 * @var string
 	 */
 	const GC_CRON_EVENT_NAME = 'od_url_metrics_gc';
@@ -37,6 +39,7 @@
 	/**
 	 * Recurrence for garbage collection of stale URL Metrics posts.
 	 *
+	 * @since 0.1.0
 	 * @var string
 	 */
 	const GC_CRON_RECURRENCE = 'daily';
@@ -84,7 +87,7 @@
 	 *
 	 * @since 0.1.0
 	 *
-	 * @param string $slug URL Metrics slug.
+	 * @param non-empty-string $slug URL Metrics slug.
 	 * @return WP_Post|null Post object if exists.
 	 */
 	public static function get_post( string $slug ): ?WP_Post {
@@ -202,9 +205,9 @@
 	 * @since 0.1.0
 	 * @todo There is duplicate logic here with od_handle_rest_request().
 	 *
-	 * @param string        $slug           Slug (hash of normalized query vars).
-	 * @param OD_URL_Metric $new_url_metric New URL Metric.
-	 * @return int|WP_Error Post ID or WP_Error otherwise.
+	 * @param non-empty-string $slug Slug (hash of normalized query vars).
+	 * @param OD_URL_Metric    $new_url_metric New URL Metric.
+	 * @return positive-int|WP_Error Post ID or WP_Error otherwise.
 	 */
 	public static function store_url_metric( string $slug, OD_URL_Metric $new_url_metric ) {
 		$post_data = array(
@@ -226,13 +229,6 @@
 		}
 
 		$etag = $new_url_metric->get_etag();
-		if ( null === $etag ) {
-			// This case actually will never occur in practice because the store_url_metric function is only called
-			// in the REST API endpoint where the ETag parameter is required. It is here exclusively for the sake of
-			// PHPStan's static analysis. This entire condition can be removed in a future release when the 'etag'
-			// property becomes required.
-			return new WP_Error( 'missing_etag' );
-		}
 
 		$group_collection = new OD_URL_Metric_Group_Collection(
 			$url_metrics,
@@ -250,13 +246,8 @@
 		}
 
 		$post_data['post_content'] = wp_json_encode(
-			array_map(
-				static function ( OD_URL_Metric $url_metric ): array {
-					return $url_metric->jsonSerialize();
-				},
-				$group_collection->get_flattened_url_metrics()
-			),
-			JSON_UNESCAPED_SLASHES // No need for escaped slashes since not printed to frontend.
+			$group_collection->get_flattened_url_metrics(),
+			JSON_UNESCAPED_SLASHES // No need for escaping slashes since this JSON is not embedded in HTML.
 		);
 		if ( ! is_string( $post_data['post_content'] ) ) {
 			return new WP_Error( 'json_encode_error', json_last_error_msg() );
Index: storage/data.php
===================================================================
--- storage/data.php	(revision 3238281)
+++ storage/data.php	(working copy)
@@ -20,7 +20,7 @@
  * @since 0.1.0
  * @access private
  *
- * @return int Expiration TTL in seconds.
+ * @return int<0, max> Expiration TTL in seconds.
  */
 function od_get_url_metric_freshness_ttl(): int {
 	/**
@@ -31,9 +31,9 @@
 	 *
 	 * @since 0.1.0
 	 *
-	 * @param int $ttl Expiration TTL in seconds. Defaults to 1 day.
+	 * @param int $ttl Expiration TTL in seconds. Defaults to 1 week.
 	 */
-	$freshness_ttl = (int) apply_filters( 'od_url_metric_freshness_ttl', DAY_IN_SECONDS );
+	$freshness_ttl = (int) apply_filters( 'od_url_metric_freshness_ttl', WEEK_IN_SECONDS );
 
 	if ( $freshness_ttl < 0 ) {
 		_doing_it_wrong(
@@ -58,8 +58,6 @@
  *
  * This is used as a cache key for stored URL Metrics.
  *
- * TODO: For non-singular requests, consider adding the post IDs from The Loop to ensure publishing a new post will invalidate the cache.
- *
  * @since 0.1.0
  * @access private
  *
@@ -117,6 +115,8 @@
 		// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
 		$current_url .= ltrim( wp_unslash( $_SERVER['REQUEST_URI'] ), '/' );
 	}
+
+	// TODO: We should be able to assert that this returns an non-empty-string.
 	return esc_url_raw( $current_url );
 }
 
@@ -131,7 +131,7 @@
  * @see od_get_normalized_query_vars()
  *
  * @param array<string, mixed> $query_vars Normalized query vars.
- * @return string Slug.
+ * @return non-empty-string Slug.
  */
 function od_get_url_metrics_slug( array $query_vars ): string {
 	return md5( (string) wp_json_encode( $query_vars ) );
@@ -199,6 +199,17 @@
 		$queried_object_data['type'] = $queried_object->name;
 	}
 
+	$active_plugins = (array) get_option( 'active_plugins', array() );
+	if ( is_multisite() ) {
+		$active_plugins = array_unique(
+			array_merge(
+				$active_plugins,
+				array_keys( (array) get_site_option( 'active_sitewide_plugins', array() ) )
+			)
+		);
+	}
+	sort( $active_plugins );
+
 	$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 ) ),
@@ -230,6 +241,7 @@
 				'version' => wp_get_theme()->get( 'Version' ),
 			),
 		),
+		'active_plugins'   => $active_plugins,
 		'current_template' => $current_template instanceof WP_Block_Template ? get_object_vars( $current_template ) : $current_template,
 	);
 
@@ -257,15 +269,22 @@
  * @see od_verify_url_metrics_storage_hmac()
  * @see od_get_url_metrics_slug()
  *
- * @param string           $slug                Slug (hash of normalized query vars).
- * @param non-empty-string $current_etag        Current ETag.
- * @param string           $url                 URL.
- * @param int|null         $cache_purge_post_id Cache purge post ID.
- * @return string HMAC.
+ * @param non-empty-string  $slug                Slug (hash of normalized query vars).
+ * @param non-empty-string  $current_etag        Current ETag.
+ * @param string            $url                 URL.
+ * @param positive-int|null $cache_purge_post_id Cache purge post ID.
+ * @return non-empty-string HMAC.
  */
 function od_get_url_metrics_storage_hmac( string $slug, string $current_etag, string $url, ?int $cache_purge_post_id = null ): string {
 	$action = "store_url_metric:$slug:$current_etag:$url:$cache_purge_post_id";
-	return wp_hash( $action, 'nonce' );
+
+	/**
+	 * HMAC.
+	 *
+	 * @var non-empty-string $hmac
+	 */
+	$hmac = wp_hash( $action, 'nonce' );
+	return $hmac;
 }
 
 /**
@@ -278,11 +297,11 @@
  * @see od_get_url_metrics_storage_hmac()
  * @see od_get_url_metrics_slug()
  *
- * @param string           $hmac                HMAC.
- * @param string           $slug                Slug (hash of normalized query vars).
- * @param non-empty-string $current_etag        Current ETag.
- * @param string           $url                 URL.
- * @param int|null         $cache_purge_post_id Cache purge post ID.
+ * @param non-empty-string  $hmac                HMAC.
+ * @param non-empty-string  $slug                Slug (hash of normalized query vars).
+ * @param non-empty-string  $current_etag        Current ETag.
+ * @param string            $url                 URL.
+ * @param positive-int|null $cache_purge_post_id Cache purge post ID.
  * @return bool Whether the HMAC is valid.
  */
 function od_verify_url_metrics_storage_hmac( string $hmac, string $slug, string $current_etag, string $url, ?int $cache_purge_post_id = null ): bool {
@@ -360,7 +379,7 @@
  * @access private
  * @link https://github.com/WordPress/gutenberg/blob/093d52cbfd3e2c140843d3fb91ad3d03330320a5/packages/base-styles/_breakpoints.scss#L11-L13
  *
- * @return int[] Breakpoint max widths, sorted in ascending order.
+ * @return positive-int[] Breakpoint max widths, sorted in ascending order.
  */
 function od_get_breakpoint_max_widths(): array {
 	$function_name = __FUNCTION__;
@@ -368,20 +387,7 @@
 	$breakpoint_max_widths = array_map(
 		static function ( $original_breakpoint ) use ( $function_name ): int {
 			$breakpoint = $original_breakpoint;
-			if ( PHP_INT_MAX === $breakpoint ) {
-				$breakpoint = PHP_INT_MAX - 1;
-				_doing_it_wrong(
-					esc_html( $function_name ),
-					esc_html(
-						sprintf(
-							/* translators: %s is the actual breakpoint max width */
-							__( 'Breakpoint must be less than PHP_INT_MAX, but saw "%s".', 'optimization-detective' ),
-							$original_breakpoint
-						)
-					),
-					''
-				);
-			} elseif ( $breakpoint <= 0 ) {
+			if ( $breakpoint <= 0 ) {
 				$breakpoint = 1;
 				_doing_it_wrong(
 					esc_html( $function_name ),
@@ -400,12 +406,12 @@
 		/**
 		 * Filters the breakpoint max widths to group URL Metrics for various viewports.
 		 *
-		 * A breakpoint must be greater than zero and less than PHP_INT_MAX. This array may be empty in which case there
+		 * A breakpoint must be greater than zero. This array may be empty in which case there
 		 * are no responsive breakpoints and all URL Metrics are collected in a single group.
 		 *
 		 * @since 0.1.0
 		 *
-		 * @param int[] $breakpoint_max_widths Max widths for viewport breakpoints. Defaults to [480, 600, 782].
+		 * @param positive-int[] $breakpoint_max_widths Max widths for viewport breakpoints. Defaults to [480, 600, 782].
 		 */
 		array_map( 'intval', (array) apply_filters( 'od_breakpoint_max_widths', array( 480, 600, 782 ) ) )
 	);
@@ -425,7 +431,7 @@
  * @since 0.1.0
  * @access private
  *
- * @return int Sample size.
+ * @return int<1, max> Sample size.
  */
 function od_get_url_metrics_breakpoint_sample_size(): int {
 	/**
Index: storage/rest-api.php
===================================================================
--- storage/rest-api.php	(revision 3238281)
+++ storage/rest-api.php	(working copy)
@@ -15,6 +15,8 @@
 /**
  * Namespace for optimization-detective.
  *
+ * @since 0.1.0
+ * @access private
  * @var string
  */
 const OD_REST_API_NAMESPACE = 'optimization-detective/v1';
@@ -26,6 +28,8 @@
  * that does not strictly follow the standard usage. Namely, submitting a POST request to this endpoint will either
  * create a new `od_url_metrics` post, or it will update an existing post if one already exists for the provided slug.
  *
+ * @since 0.1.0
+ * @access private
  * @link https://google.aip.dev/136
  * @var string
  */
@@ -72,7 +76,7 @@
 			'required'          => true,
 			'pattern'           => '^[0-9a-f]+\z',
 			'validate_callback' => static function ( string $hmac, WP_REST_Request $request ) {
-				if ( ! od_verify_url_metrics_storage_hmac( $hmac, $request['slug'], $request['current_etag'], $request['url'], $request['cache_purge_post_id'] ?? null ) ) {
+				if ( '' === $hmac || ! od_verify_url_metrics_storage_hmac( $hmac, $request['slug'], $request['current_etag'], $request['url'], $request['cache_purge_post_id'] ?? null ) ) {
 					return new WP_Error( 'invalid_hmac', __( 'URL Metrics HMAC verification failure.', 'optimization-detective' ) );
 				}
 				return true;
@@ -214,6 +218,27 @@
 		);
 	}
 
+	/*
+	 * The limit for data sent via navigator.sendBeacon() is 64 KiB. This limit is checked in detect.js so that the
+	 * request will not even be attempted if the payload is too large. This server-side restriction is added as a
+	 * safeguard against clients sending possibly malicious payloads much larger than 64 KiB which should never be
+	 * getting sent.
+	 */
+	$max_size       = 64 * 1024;
+	$content_length = strlen( (string) wp_json_encode( $url_metric ) );
+	if ( $content_length > $max_size ) {
+		return new WP_Error(
+			'rest_content_too_large',
+			sprintf(
+				/* translators: 1: the size of the payload, 2: the maximum allowed payload size */
+				__( 'JSON payload size is %1$s bytes which is larger than the maximum allowed size of %2$s bytes.', 'optimization-detective' ),
+				number_format_i18n( $content_length ),
+				number_format_i18n( $max_size )
+			),
+			array( 'status' => 413 )
+		);
+	}
+
 	// TODO: This should be changed from store_url_metric($slug, $url_metric) instead be update_post( $slug, $group_collection ). As it stands, store_url_metric() is duplicating logic here.
 	$result = OD_URL_Metrics_Post_Type::store_url_metric(
 		$request->get_param( 'slug' ),
Index: types.ts
===================================================================
--- types.ts	(revision 3238281)
+++ types.ts	(working copy)
@@ -34,6 +34,7 @@
 
 export interface URLMetricGroupStatus {
 	minimumViewportWidth: number;
+	maximumViewportWidth: number | null;
 	complete: boolean;
 }
 

performance-lab

Important

Stable tag change: 3.8.0 → 3.9.0

svn status:

M       includes/server-timing/class-perflab-server-timing.php
M       includes/site-health/audit-autoloaded-options/hooks.php
M       includes/site-health/audit-enqueued-assets/hooks.php
M       load.php
M       readme.txt
svn diff
Index: includes/server-timing/class-perflab-server-timing.php
===================================================================
--- includes/server-timing/class-perflab-server-timing.php	(revision 3238281)
+++ includes/server-timing/class-perflab-server-timing.php	(working copy)
@@ -209,7 +209,7 @@
 	 */
 	public function use_output_buffer(): bool {
 		$options = (array) get_option( PERFLAB_SERVER_TIMING_SETTING, array() );
-		$enabled = ! empty( $options['output_buffering'] );
+		$enabled = isset( $options['output_buffering'] ) && (bool) $options['output_buffering'];
 
 		/**
 		 * Filters whether an output buffer should be used to be able to gather additional Server-Timing metrics.
@@ -220,7 +220,7 @@
 		 *
 		 * @since 1.8.0
 		 *
-		 * @param bool $use_output_buffer Whether to use an output buffer.
+		 * @param bool $enabled Whether to use an output buffer.
 		 */
 		return (bool) apply_filters( 'perflab_server_timing_use_output_buffer', $enabled );
 	}
Index: includes/site-health/audit-autoloaded-options/hooks.php
===================================================================
--- includes/site-health/audit-autoloaded-options/hooks.php	(revision 3238281)
+++ includes/site-health/audit-autoloaded-options/hooks.php	(working copy)
@@ -63,7 +63,7 @@
 		wp_die( esc_html__( 'Permission denied.', 'performance-lab' ) );
 	}
 
-	if ( empty( $option_name ) ) {
+	if ( '' === $option_name ) {
 		wp_die( esc_html__( 'Invalid option name.', 'performance-lab' ) );
 	}
 
Index: includes/site-health/audit-enqueued-assets/hooks.php
===================================================================
--- includes/site-health/audit-enqueued-assets/hooks.php	(revision 3238281)
+++ includes/site-health/audit-enqueued-assets/hooks.php	(working copy)
@@ -35,7 +35,10 @@
 
 			// Add any extra data (inlined) that was passed with the script.
 			$inline_size = 0;
-			if ( ! empty( $script->extra ) && ! empty( $script->extra['after'] ) ) {
+			if (
+				isset( $script->extra['after'] ) &&
+				is_array( $script->extra['after'] )
+			) {
 				foreach ( $script->extra['after'] as $extra ) {
 					$inline_size += ( is_string( $extra ) ) ? mb_strlen( $extra, '8bit' ) : 0;
 				}
@@ -78,7 +81,11 @@
 			}
 
 			// Check if we already have the style's path ( part of a refactor for block styles from 5.9 ).
-			if ( ! empty( $style->extra ) && ! empty( $style->extra['path'] ) ) {
+			if (
+				isset( $style->extra['path'] ) &&
+				is_string( $style->extra['path'] ) &&
+				'' !== $style->extra['path']
+			) {
 				$path = $style->extra['path'];
 			} else { // Fallback to getting the path from the style's src.
 				$path = perflab_aea_get_path_from_resource_url( $style->src );
@@ -89,7 +96,10 @@
 
 			// Add any extra data (inlined) that was passed with the style.
 			$inline_size = 0;
-			if ( ! empty( $style->extra ) && ! empty( $style->extra['after'] ) ) {
+			if (
+				isset( $style->extra['after'] ) &&
+				is_array( $style->extra['after'] )
+			) {
 				foreach ( $style->extra['after'] as $extra ) {
 					$inline_size += ( is_string( $extra ) ) ? mb_strlen( $extra, '8bit' ) : 0;
 				}
Index: load.php
===================================================================
--- load.php	(revision 3238281)
+++ 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.8.0
+ * Version: 3.9.0
  * Author: WordPress Performance Team
  * Author URI: https://make.wordpress.org/performance/
  * License: GPLv2 or later
@@ -21,7 +21,7 @@
 }
 // @codeCoverageIgnoreEnd
 
-define( 'PERFLAB_VERSION', '3.8.0' );
+define( 'PERFLAB_VERSION', '3.9.0' );
 define( 'PERFLAB_MAIN_FILE', __FILE__ );
 define( 'PERFLAB_PLUGIN_DIR_PATH', plugin_dir_path( PERFLAB_MAIN_FILE ) );
 define( 'PERFLAB_SCREEN', 'performance-lab' );
@@ -104,11 +104,11 @@
 		),
 		'embed-optimizer'         => array(
 			'constant'     => 'EMBED_OPTIMIZER_VERSION',
-			'experimental' => true,
+			'experimental' => false,
 		),
 		'image-prioritizer'       => array(
 			'constant'     => 'IMAGE_PRIORITIZER_VERSION',
-			'experimental' => true,
+			'experimental' => false,
 		),
 		'performant-translations' => array(
 			'constant' => 'PERFORMANT_TRANSLATIONS_VERSION',
Index: readme.txt
===================================================================
--- readme.txt	(revision 3238281)
+++ readme.txt	(working copy)
@@ -2,7 +2,7 @@
 
 Contributors: wordpressdotorg
 Tested up to: 6.7
-Stable tag:   3.8.0
+Stable tag:   3.9.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,12 @@
 
 == Changelog ==
 
+= 3.9.0 =
+
+**Enhancements**
+
+* Remove experimental flags from Embed Optimizer and Image Prioritizer. ([1846](https://github.com/WordPress/performance/pull/1846))
+
 = 3.8.0 =
 
 **Enhancements**

speculation-rules

Note

No changes.

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 3238281)
+++ 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 3238281)
+++ 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 3238281)
+++ 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 3238281)
+++ 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 3238281)
+++ 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 3238281)
+++ 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 3238281)
+++ 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

Warning

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

svn status:

M       deprecated.php
M       helper.php
M       hooks.php
M       image-edit.php
M       rest-api.php
svn diff
Index: deprecated.php
===================================================================
--- deprecated.php	(revision 3238281)
+++ deprecated.php	(working copy)
@@ -30,12 +30,17 @@
 	$metadata = wp_get_attachment_metadata( $attachment_id );
 
 	// Return full image size sources.
-	if ( 'full' === $size && ! empty( $metadata['sources'] ) ) {
+	if (
+		'full' === $size &&
+		isset( $metadata['sources'] ) &&
+		is_array( $metadata['sources'] ) &&
+		count( $metadata['sources'] ) > 0
+	) {
 		return $metadata['sources'];
 	}
 
 	// Return the resized image sources.
-	if ( ! empty( $metadata['sizes'][ $size ]['sources'] ) ) {
+	if ( isset( $metadata['sizes'][ $size ]['sources'] ) && is_array( $metadata['sizes'][ $size ]['sources'] ) ) {
 		return $metadata['sizes'][ $size ]['sources'];
 	}
 
Index: helper.php
===================================================================
--- helper.php	(revision 3238281)
+++ helper.php	(working copy)
@@ -66,7 +66,7 @@
 	// Ensure that all mime types have correct transforms. If a mime type has invalid transforms array,
 	// then fallback to the original mime type to make sure that the correct subsizes are created.
 	foreach ( $transforms as $mime_type => $transform_types ) {
-		if ( ! is_array( $transform_types ) || empty( $transform_types ) ) {
+		if ( ! is_array( $transform_types ) || 0 === count( $transform_types ) ) {
 			$transforms[ $mime_type ] = array( $mime_type );
 		}
 	}
@@ -163,7 +163,7 @@
 
 	$image_meta = wp_get_attachment_metadata( $attachment_id );
 	// If stored EXIF data exists, rotate the source image before creating sub-sizes.
-	if ( ! empty( $image_meta['image_meta'] ) ) {
+	if ( isset( $image_meta['image_meta'] ) && is_array( $image_meta['image_meta'] ) && count( $image_meta['image_meta'] ) > 0 ) {
 		$editor->maybe_exif_rotate();
 	}
 
@@ -185,7 +185,7 @@
 		return $image;
 	}
 
-	if ( empty( $image['file'] ) ) {
+	if ( ! isset( $image['file'] ) || ! is_string( $image['file'] ) || '' === $image['file'] ) {
 		return new WP_Error( 'image_file_not_present', __( 'The file key is not present on the image data', 'webp-uploads' ) );
 	}
 
Index: hooks.php
===================================================================
--- hooks.php	(revision 3238281)
+++ hooks.php	(working copy)
@@ -77,7 +77,7 @@
 		$metadata['sources'] = array();
 	}
 
-	if ( empty( $metadata['sources'][ $mime_type ] ) ) {
+	if ( ! isset( $metadata['sources'][ $mime_type ]['file'] ) ) {
 		$metadata['sources'][ $mime_type ] = array(
 			'file'     => wp_basename( $file ),
 			'filesize' => wp_filesize( $file ),
@@ -99,12 +99,12 @@
 	// Create the sources for the full sized image.
 	foreach ( $valid_mime_transforms[ $mime_type ] as $targeted_mime ) {
 		// If this property exists no need to create the image again.
-		if ( ! empty( $metadata['sources'][ $targeted_mime ] ) ) {
+		if ( isset( $metadata['sources'][ $targeted_mime ]['file'] ) ) {
 			continue;
 		}
 
 		// The targeted mime is not allowed in the current installation.
-		if ( empty( $allowed_mimes[ $targeted_mime ] ) ) {
+		if ( ! isset( $allowed_mimes[ $targeted_mime ] ) ) {
 			continue;
 		}
 
@@ -150,7 +150,7 @@
 			$original_image = wp_get_original_image_path( $attachment_id );
 
 			// If WordPress already modified the original itself, keep the original and discard WordPress's generated version.
-			if ( ! empty( $metadata['original_image'] ) ) {
+			if ( isset( $metadata['original_image'] ) && is_string( $metadata['original_image'] ) && '' !== $metadata['original_image'] ) {
 				$uploadpath    = wp_get_upload_dir();
 				$attached_file = get_attached_file( $attachment_id );
 				if ( false !== $attached_file ) {
@@ -171,7 +171,11 @@
 	}
 
 	// Make sure we have some sizes to work with, otherwise avoid any work.
-	if ( empty( $metadata['sizes'] ) || ! is_array( $metadata['sizes'] ) ) {
+	if (
+		! isset( $metadata['sizes'] ) ||
+		! is_array( $metadata['sizes'] ) ||
+		0 === count( $metadata['sizes'] )
+	) {
 		return $metadata;
 	}
 
@@ -179,7 +183,11 @@
 
 	foreach ( $metadata['sizes'] as $size_name => $properties ) {
 		// Do nothing if this image size is not an array or is not allowed to have additional mime types.
-		if ( ! is_array( $properties ) || empty( $sizes_with_mime_type_support[ $size_name ] ) ) {
+		if (
+			! is_array( $properties ) ||
+			! isset( $sizes_with_mime_type_support[ $size_name ] ) ||
+			false === $sizes_with_mime_type_support[ $size_name ]
+		) {
 			continue;
 		}
 
@@ -192,16 +200,16 @@
 		}
 
 		// The mime type can't be determined.
-		if ( empty( $current_mime ) ) {
+		if ( ! is_string( $current_mime ) || '' === $current_mime ) {
 			continue;
 		}
 
 		// Ensure a `sources` property exists on the existing size.
-		if ( empty( $properties['sources'] ) || ! is_array( $properties['sources'] ) ) {
+		if ( ! isset( $properties['sources'] ) || ! is_array( $properties['sources'] ) ) {
 			$properties['sources'] = array();
 		}
 
-		if ( empty( $properties['sources'][ $current_mime ] ) && isset( $properties['file'] ) ) {
+		if ( ! isset( $properties['sources'][ $current_mime ]['file'] ) && isset( $properties['file'] ) ) {
 			$properties['sources'][ $current_mime ] = array(
 				'file'     => $properties['file'],
 				'filesize' => 0,
@@ -217,7 +225,7 @@
 
 		foreach ( $valid_mime_transforms[ $mime_type ] as $mime ) {
 			// If this property exists no need to create the image again.
-			if ( ! empty( $properties['sources'][ $mime ] ) ) {
+			if ( isset( $properties['sources'][ $mime ]['file'] ) ) {
 				continue;
 			}
 
@@ -275,7 +283,7 @@
 	}
 
 	// Only setup the trace array if we no longer have more sizes.
-	if ( ! empty( $missing_sizes ) ) {
+	if ( count( $missing_sizes ) > 0 ) {
 		return $missing_sizes;
 	}
 
@@ -362,7 +370,7 @@
 function webp_uploads_remove_sources_files( int $attachment_id ): void {
 	$file = get_attached_file( $attachment_id );
 
-	if ( empty( $file ) ) {
+	if ( false === (bool) $file ) {
 		return;
 	}
 
@@ -371,7 +379,11 @@
 	$sizes = ! isset( $metadata['sizes'] ) || ! is_array( $metadata['sizes'] ) ? array() : $metadata['sizes'];
 
 	$upload_path = wp_get_upload_dir();
-	if ( empty( $upload_path['basedir'] ) ) {
+	if (
+		! isset( $upload_path['basedir'] ) ||
+		! is_string( $upload_path['basedir'] ) ||
+		'' === $upload_path['basedir']
+	) {
 		return;
 	}
 
@@ -383,7 +395,7 @@
 			continue;
 		}
 
-		$original_size_mime = empty( $size['mime-type'] ) ? '' : $size['mime-type'];
+		$original_size_mime = isset( $size['mime-type'] ) && is_string( $size['mime-type'] ) ? $size['mime-type'] : '';
 
 		foreach ( $size['sources'] as $mime => $properties ) {
 			/**
@@ -397,7 +409,11 @@
 				continue;
 			}
 
-			if ( ! is_array( $properties ) || empty( $properties['file'] ) ) {
+			if (
+				! isset( $properties['file'] ) ||
+				! is_string( $properties['file'] ) ||
+				'' === $properties['file']
+			) {
 				continue;
 			}
 
@@ -429,7 +445,11 @@
 			continue;
 		}
 
-		if ( ! is_array( $properties ) || empty( $properties['file'] ) ) {
+		if (
+			! isset( $properties['file'] ) ||
+			! is_string( $properties['file'] ) ||
+			'' === $properties['file']
+		) {
 			continue;
 		}
 
@@ -453,7 +473,7 @@
 			continue;
 		}
 
-		$original_backup_size_mime = empty( $backup_size['mime-type'] ) ? '' : $backup_size['mime-type'];
+		$original_backup_size_mime = isset( $backup_size['mime-type'] ) && is_string( $backup_size['mime-type'] ) ? $backup_size['mime-type'] : '';
 
 		foreach ( $backup_size['sources'] as $backup_mime => $backup_properties ) {
 			/**
@@ -467,12 +487,16 @@
 				continue;
 			}
 
-			if ( ! is_array( $backup_properties ) || empty( $backup_properties['file'] ) ) {
+			if (
+				! isset( $backup_properties['file'] ) ||
+				! is_string( $backup_properties['file'] ) ||
+				'' === $backup_properties['file']
+			) {
 				continue;
 			}
 
 			$backup_intermediate_file = str_replace( $basename, $backup_properties['file'], $file );
-			if ( empty( $backup_intermediate_file ) ) {
+			if ( '' === $backup_intermediate_file ) {
 				continue;
 			}
 
@@ -492,12 +516,16 @@
 	foreach ( $backup_sources as $backup_mimes ) {
 
 		foreach ( $backup_mimes as $backup_mime_properties ) {
-			if ( ! is_array( $backup_mime_properties ) || empty( $backup_mime_properties['file'] ) ) {
+			if (
+				! isset( $backup_mime_properties['file'] ) ||
+				! is_string( $backup_mime_properties['file'] ) ||
+				'' === $backup_mime_properties['file']
+			) {
 				continue;
 			}
 
 			$full_size = str_replace( $basename, $backup_mime_properties['file'], $file );
-			if ( empty( $full_size ) ) {
+			if ( '' === $full_size ) {
 				continue;
 			}
 
@@ -547,7 +575,7 @@
 	$image    = $original_image;
 	$metadata = wp_get_attachment_metadata( $attachment_id );
 
-	if ( empty( $metadata['file'] ) ) {
+	if ( ! isset( $metadata['file'] ) || ! is_string( $metadata['file'] ) || '' === $metadata['file'] ) {
 		return $image;
 	}
 
@@ -596,7 +624,7 @@
 		// Replace sub sizes for the image if present.
 		foreach ( $metadata['sizes'] as $size => $size_data ) {
 
-			if ( empty( $size_data['file'] ) ) {
+			if ( ! isset( $size_data['file'] ) || ! is_string( $size_data['file'] ) || '' === $size_data['file'] ) {
 				continue;
 			}
 
@@ -671,7 +699,7 @@
 	);
 
 	foreach ( $additional_sizes as $size => $size_details ) {
-		$allowed_sizes[ $size ] = ! empty( $size_details['provide_additional_mime_types'] );
+		$allowed_sizes[ $size ] = isset( $size_details['provide_additional_mime_types'] ) && true === (bool) $size_details['provide_additional_mime_types'];
 	}
 
 	/**
Index: image-edit.php
===================================================================
--- image-edit.php	(revision 3238281)
+++ image-edit.php	(working copy)
@@ -86,7 +86,11 @@
 		}
 
 		foreach ( $metadata['sizes'] as $size_name => $size_details ) {
-			if ( empty( $subsized_images[ $targeted_mime ][ $size_name ]['file'] ) ) {
+			if (
+				! isset( $subsized_images[ $targeted_mime ][ $size_name ]['file'] ) ||
+				! is_string( $subsized_images[ $targeted_mime ][ $size_name ]['file'] ) ||
+				'' === $subsized_images[ $targeted_mime ][ $size_name ]['file']
+			) {
 				continue;
 			}
 
@@ -124,7 +128,7 @@
 	}
 
 	$transforms = webp_uploads_get_upload_image_mime_transforms();
-	if ( empty( $transforms[ $mime_type ] ) ) {
+	if ( ! isset( $transforms[ $mime_type ] ) || ! is_array( $transforms[ $mime_type ] ) || 0 === count( $transforms[ $mime_type ] ) ) {
 		return null;
 	}
 
@@ -144,7 +148,7 @@
 			}
 			$callback_executed = true;
 			// No sizes to be created.
-			if ( empty( $metadata['sizes'] ) ) {
+			if ( ! isset( $metadata['sizes'] ) || ! is_array( $metadata['sizes'] ) || 0 === count( $metadata['sizes'] ) ) {
 				return $metadata;
 			}
 
@@ -163,7 +167,7 @@
 					}
 
 					if (
-						isset( $metadata['sizes'][ $size_name ] ) && ! empty( $metadata['sizes'][ $size_name ] ) &&
+						isset( $metadata['sizes'][ $size_name ]['file'] ) &&
 						$metadata['sizes'][ $size_name ]['file'] !== $old_metadata['sizes'][ $size_name ]['file']
 					) {
 						$resize_sizes[ $size_name ] = $metadata['sizes'][ $size_name ];
@@ -405,7 +409,7 @@
  * @param array<string, array{ file: string, filesize: int }> $sources       An array with the full sources to be stored on the next available key.
  */
 function webp_uploads_backup_full_image_sources( int $attachment_id, array $sources ): void {
-	if ( empty( $sources ) ) {
+	if ( 0 === count( $sources ) ) {
 		return;
 	}
 
@@ -435,7 +439,7 @@
 	$backup_sizes = get_post_meta( $attachment_id, '_wp_attachment_backup_sizes', true );
 	$backup_sizes = is_array( $backup_sizes ) ? $backup_sizes : array();
 
-	if ( empty( $backup_sizes ) ) {
+	if ( 0 === count( $backup_sizes ) ) {
 		return null;
 	}
 
Index: rest-api.php
===================================================================
--- rest-api.php	(revision 3238281)
+++ rest-api.php	(working copy)
@@ -30,7 +30,7 @@
 
 	foreach ( $data['media_details']['sizes'] as $size => &$details ) {
 
-		if ( empty( $details['sources'] ) || ! is_array( $details['sources'] ) ) {
+		if ( ! isset( $details['sources'] ) || ! is_array( $details['sources'] ) ) {
 			continue;
 		}
 
@@ -41,7 +41,13 @@
 	}
 
 	$full_src = wp_get_attachment_image_src( $post->ID, 'full' );
-	if ( ! empty( $full_src ) && ! empty( $data['media_details']['sources'] ) && ! empty( $data['media_details']['sizes']['full'] ) ) {
+	if (
+		isset( $full_src[0] ) &&
+		isset( $data['media_details']['sources'] ) &&
+		is_array( $data['media_details']['sources'] ) &&
+		isset( $data['media_details']['sizes']['full'] ) &&
+		is_array( $data['media_details']['sizes']['full'] )
+	) {
 		$full_url_basename = wp_basename( $full_src[0] );
 		foreach ( $data['media_details']['sources'] as $mime => &$mime_details ) {
 			$mime_details['source_url'] = str_replace( $full_url_basename, $mime_details['file'], $full_src[0] );

@westonruter
Copy link
Copy Markdown
Member Author

westonruter commented Feb 11, 2025

Comment on lines +18 to +20
* [Embed Optimizer](https://wordpress.org/plugins/embed-optimizer/)
* [Image Placeholders](https://wordpress.org/plugins/dominant-color-images/)
* [Image Prioritizer](https://wordpress.org/plugins/image-prioritizer/)
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

I missed removing the _(experimental)_ flag in #1846

* [Image Placeholders](https://wordpress.org/plugins/dominant-color-images/)
* [Image Prioritizer](https://wordpress.org/plugins/image-prioritizer/)
* [Modern Image Formats](https://wordpress.org/plugins/webp-uploads/)
* [Optimization Detective](https://wordpress.org/plugins/optimization-detective/) (dependency for Embed Optimizer and Image Prioritizer)
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

I thought it would be good to include Optimization Detective in the list here in the readme for visibility.

Copy link
Copy Markdown
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.

Great!

@westonruter westonruter merged commit 91bc26d into release/3.9.0 Feb 13, 2025
16 checks passed
@westonruter westonruter deleted the publish/3.9.0 branch February 13, 2025 19:10
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Infrastructure Issues for the overall performance plugin infrastructure skip changelog PRs that should not be mentioned in changelogs [Type] Documentation Documentation to be added or enhanced

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants