Skip to content

Conversation

@westonruter
Copy link
Member

@westonruter westonruter commented Mar 17, 2025

  • Update readme with notes about URL Metric collection
  • Add link to WordCamp talk
  • Add docs for new actions
  • Bump plugin versions
  • Run npm run since (but strip off -beta3)
  • Update readme changelogs

Plugins included in this release:

  1. embed-optimizer 1.0.0-beta2
  2. image-prioritizer 1.0.0-beta2
  3. optimization-detective 1.0.0-beta3
  4. speculation-rules 1.5.0
  5. webp-uploads 2.5.1

@westonruter westonruter added [Type] Documentation Documentation to be added or enhanced Infrastructure Issues for the overall performance plugin infrastructure no milestone PRs that do not have a defined milestone for release skip changelog PRs that should not be mentioned in changelogs labels Mar 17, 2025
@codecov
Copy link

codecov bot commented Mar 17, 2025

Codecov Report

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

Project coverage is 71.20%. Comparing base (aef2e8d) to head (9062bb0).
Report is 11 commits behind head on release/2025-03-17.

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/webp-uploads/load.php 0.00% 1 Missing ⚠️
Additional details and impacted files
@@                 Coverage Diff                 @@
##           release/2025-03-17    #1932   +/-   ##
===================================================
  Coverage               71.20%   71.20%           
===================================================
  Files                      86       86           
  Lines                    6998     6998           
===================================================
  Hits                     4983     4983           
  Misses                   2015     2015           
Flag Coverage Δ
multisite 71.20% <0.00%> (ø)
single 40.69% <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.

🚀 New features to boost your workflow:
  • Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@westonruter
Copy link
Member Author

Build zips pending merge of #1929

@westonruter
Copy link
Member Author

@westonruter
Copy link
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 3257264)
+++ 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 3257264)
+++ 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       helper.php
M       hooks.php
M       load.php
svn diff
Index: helper.php
===================================================================
--- helper.php	(revision 3257264)
+++ helper.php	(working copy)
@@ -17,10 +17,10 @@
  */
 function dominant_color_set_image_editors( array $editors ): array {
 	if ( ! class_exists( 'Dominant_Color_Image_Editor_GD' ) ) {
-		require_once __DIR__ . '/class-dominant-color-image-editor-gd.php';
+		require_once __DIR__ . '/class-dominant-color-image-editor-gd.php';// @codeCoverageIgnore
 	}
 	if ( ! class_exists( 'Dominant_Color_Image_Editor_Imagick' ) ) {
-		require_once __DIR__ . '/class-dominant-color-image-editor-imagick.php';
+		require_once __DIR__ . '/class-dominant-color-image-editor-imagick.php';// @codeCoverageIgnore
 	}
 
 	$replaces = array(
Index: hooks.php
===================================================================
--- hooks.php	(revision 3257264)
+++ 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 3257264)
+++ load.php	(working copy)
@@ -15,11 +15,12 @@
  * @package dominant-color-images
  */
 
-// Exit if accessed directly.
+// @codeCoverageIgnoreStart
 if ( ! defined( 'ABSPATH' ) ) {
-	exit;
+	exit; // Exit if accessed directly.
 }
 
+
 // Define required constants.
 if ( defined( 'DOMINANT_COLOR_IMAGES_VERSION' ) ) {
 	return;
@@ -29,3 +30,4 @@
 
 require_once __DIR__ . '/helper.php';
 require_once __DIR__ . '/hooks.php';
+// @codeCoverageIgnoreEnd

embed-optimizer

Important

Stable tag change: 1.0.0-beta1 → 1.0.0-beta2

svn status:

M       detect.js
M       detect.min.js
M       hooks.php
M       load.php
M       readme.txt
svn diff
Index: detect.js
===================================================================
--- detect.js	(revision 3257264)
+++ detect.js	(working copy)
@@ -6,7 +6,7 @@
  * when it is submitted for storage.
  */
 
-const consoleLogPrefix = '[Embed Optimizer]';
+export const name = 'Embed Optimizer';
 
 /**
  * @typedef {import("../optimization-detective/types.ts").URLMetric} URLMetric
@@ -16,29 +16,10 @@
  * @typedef {import("../optimization-detective/types.ts").FinalizeArgs} FinalizeArgs
  * @typedef {import("../optimization-detective/types.ts").FinalizeCallback} FinalizeCallback
  * @typedef {import("../optimization-detective/types.ts").ExtendedElementData} ExtendedElementData
+ * @typedef {import("../optimization-detective/types.ts").LogFunction} LogFunction
  */
 
 /**
- * Logs a message.
- *
- * @param {...*} message
- */
-function log( ...message ) {
-	// eslint-disable-next-line no-console
-	console.log( consoleLogPrefix, ...message );
-}
-
-/**
- * Logs an error.
- *
- * @param {...*} message
- */
-function error( ...message ) {
-	// eslint-disable-next-line no-console
-	console.error( consoleLogPrefix, ...message );
-}
-
-/**
  * Embed element heights.
  *
  * @type {Map<string, DOMRectReadOnly>}
@@ -51,7 +32,10 @@
  * @type {InitializeCallback}
  * @param {InitializeArgs} args Args.
  */
-export async function initialize( { isDebug } ) {
+export async function initialize( { log: _log } ) {
+	// eslint-disable-next-line no-console
+	const log = _log || console.log; // TODO: Remove once Optimization Detective likely updated, or when strict version requirement added in od_init action.
+
 	/** @type NodeListOf<HTMLDivElement> */
 	const embedWrappers = document.querySelectorAll(
 		'.wp-block-embed > .wp-block-embed__wrapper[data-od-xpath]'
@@ -58,12 +42,10 @@
 	);
 
 	for ( /** @type {HTMLElement} */ const embedWrapper of embedWrappers ) {
-		monitorEmbedWrapperForResizes( embedWrapper, isDebug );
+		monitorEmbedWrapperForResizes( embedWrapper, log );
 	}
 
-	if ( isDebug ) {
-		log( 'Loaded embed content rects:', loadedElementContentRects );
-	}
+	log( 'Loaded embed content rects:', loadedElementContentRects );
 }
 
 /**
@@ -73,24 +55,29 @@
  * @param {FinalizeArgs} args Args.
  */
 export async function finalize( {
-	isDebug,
+	log: _log,
+	error: _error,
 	getElementData,
 	extendElementData,
 } ) {
+	/* eslint-disable no-console */
+	// TODO: Remove once Optimization Detective likely updated, or when strict version requirement added in od_init action.
+	const log = _log || console.log;
+	const error = _error || console.error;
+	/* eslint-enable no-console */
+
 	for ( const [ xpath, domRect ] of loadedElementContentRects.entries() ) {
 		try {
 			extendElementData( xpath, {
 				resizedBoundingClientRect: domRect,
 			} );
-			if ( isDebug ) {
-				const elementData = getElementData( xpath );
-				log(
-					`boundingClientRect for ${ xpath } resized:`,
-					elementData.boundingClientRect,
-					'=>',
-					domRect
-				);
-			}
+			const elementData = getElementData( xpath );
+			log(
+				`boundingClientRect for ${ xpath } resized:`,
+				elementData.boundingClientRect,
+				'=>',
+				domRect
+			);
 		} catch ( err ) {
 			error(
 				`Failed to extend element data for ${ xpath } with resizedBoundingClientRect:`,
@@ -105,9 +92,9 @@
  * Monitors embed wrapper for resizes.
  *
  * @param {HTMLDivElement} embedWrapper Embed wrapper DIV.
- * @param {boolean}        isDebug      Whether debug.
+ * @param {LogFunction}    log          The function to call with log messages.
  */
-function monitorEmbedWrapperForResizes( embedWrapper, isDebug ) {
+function monitorEmbedWrapperForResizes( embedWrapper, log ) {
 	if ( ! ( 'odXpath' in embedWrapper.dataset ) ) {
 		throw new Error( 'Embed wrapper missing data-od-xpath attribute.' );
 	}
@@ -115,9 +102,7 @@
 	const observer = new ResizeObserver( ( entries ) => {
 		const [ entry ] = entries;
 		loadedElementContentRects.set( xpath, entry.contentRect );
-		if ( isDebug ) {
-			log( `Resized element ${ xpath }:`, entry.contentRect );
-		}
+		log( `Resized element ${ xpath }:`, entry.contentRect );
 	} );
 	observer.observe( embedWrapper, { box: 'content-box' } );
 }
Index: hooks.php
===================================================================
--- hooks.php	(revision 3257264)
+++ hooks.php	(working copy)
@@ -187,6 +187,11 @@
 	$trigger_error  = static function ( string $message ) use ( $function_name ): void {
 		wp_trigger_error( $function_name, esc_html( $message ) );
 	};
+
+	// As of 1.0.0-beta3, next_tag() allows $query and is beginning to migrate to skip tag closers by default.
+	// In versions prior to this, the method always visited closers and passing a $query actually threw an exception.
+	$tag_query = ! defined( 'OPTIMIZATION_DETECTIVE_VERSION' ) || version_compare( OPTIMIZATION_DETECTIVE_VERSION, '1.0.0-beta3', '>=' )
+		? array( 'tag_closers' => 'visit' ) : null;
 	try {
 		/*
 		 * Determine how to lazy load the embed.
@@ -253,7 +258,7 @@
 					}
 				}
 			}
-		} while ( $html_processor->next_tag() );
+		} while ( $html_processor->next_tag( $tag_query ) );
 		// If there was only one non-inline script, make it lazy.
 		if ( 1 === $script_count && ! $has_inline_script && $html_processor->has_bookmark( $bookmark_names['script'] ) ) {
 			$needs_lazy_script = true;
Index: load.php
===================================================================
--- load.php	(revision 3257264)
+++ 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: 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 @@
 	}
 )(
 	'embed_optimizer_pending_plugin',
-	'1.0.0-beta1',
+	'1.0.0-beta2',
 	static function ( string $version ): void {
 		if ( defined( 'EMBED_OPTIMIZER_VERSION' ) ) {
 			return;
Index: readme.txt
===================================================================
--- readme.txt	(revision 3257264)
+++ readme.txt	(working copy)
@@ -2,7 +2,7 @@
 
 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, embeds
@@ -67,6 +67,13 @@
 
 == Changelog ==
 
+= 1.0.0-beta2 =
+
+**Enhancements**
+
+* Update `OD_HTML_Tag_Processor::next_tag()` to allow `$query` arg and prepare to skip visiting tag closers by default. ([1872](https://github.com/WordPress/performance/pull/1872))
+* Expose the logging functions to client-side extensions and automatically account for the value of `isDebug`. ([1895](https://github.com/WordPress/performance/pull/1895))
+
 = 1.0.0-beta1 =
 
 **Enhancements**

image-prioritizer

Important

Stable tag change: 1.0.0-beta1 → 1.0.0-beta2

svn status:

M       class-image-prioritizer-background-image-styled-tag-visitor.php
M       class-image-prioritizer-img-tag-visitor.php
M       detect.js
M       detect.min.js
M       helper.php
M       load.php
M       readme.txt
svn diff
Index: class-image-prioritizer-background-image-styled-tag-visitor.php
===================================================================
--- class-image-prioritizer-background-image-styled-tag-visitor.php	(revision 3257264)
+++ class-image-prioritizer-background-image-styled-tag-visitor.php	(working copy)
@@ -143,6 +143,7 @@
 	private function maybe_preload_external_lcp_background_image( OD_Tag_Visitor_Context $context ): void {
 		// Gather the tuples of URL Metric group and the common LCP element external background image.
 		// Note the groups of URL Metrics do not change across invocations, we just need to compute this once for all.
+		// TODO: Instead of populating this here, it could be done once per invocation during the od_start_template_optimization action since the page's OD_URL_Metric_Group_Collection is available there.
 		if ( ! is_array( $this->group_common_lcp_element_external_background_images ) ) {
 			$this->group_common_lcp_element_external_background_images = array();
 			foreach ( $context->url_metric_group_collection as $group ) {
Index: class-image-prioritizer-img-tag-visitor.php
===================================================================
--- class-image-prioritizer-img-tag-visitor.php	(revision 3257264)
+++ class-image-prioritizer-img-tag-visitor.php	(working copy)
@@ -226,7 +226,11 @@
 		$crossorigin    = null;
 
 		// Loop through child tags until we reach the closing PICTURE tag.
-		while ( $processor->next_tag() ) {
+		// As of 1.0.0-beta3, next_tag() allows $query and is beginning to migrate to skip tag closers by default.
+		// In versions prior to this, the method always visited closers and passing a $query actually threw an exception.
+		$tag_query = version_compare( OPTIMIZATION_DETECTIVE_VERSION, '1.0.0-beta3', '>=' )
+			? array( 'tag_closers' => 'visit' ) : null;
+		while ( $processor->next_tag( $tag_query ) ) {
 			$tag = $processor->get_tag();
 
 			// If we reached the closing PICTURE tag, break.
Index: detect.js
===================================================================
--- detect.js	(revision 3257264)
+++ detect.js	(working copy)
@@ -9,7 +9,7 @@
  * document has a tag with the same name, ID, and class.
  */
 
-const consoleLogPrefix = '[Image Prioritizer]';
+export const name = 'Image Prioritizer';
 
 /**
  * Detected LCP external background image candidates.
@@ -29,21 +29,10 @@
  * @typedef {import("../optimization-detective/types.ts").InitializeArgs} InitializeArgs
  * @typedef {import("../optimization-detective/types.ts").FinalizeArgs} FinalizeArgs
  * @typedef {import("../optimization-detective/types.ts").FinalizeCallback} FinalizeCallback
+ * @typedef {import("../optimization-detective/types.ts").LogFunction} LogFunction
  */
 
 /**
- * Logs a message.
- *
- * @since 0.3.0
- *
- * @param {...*} message
- */
-function log( ...message ) {
-	// eslint-disable-next-line no-console
-	console.log( consoleLogPrefix, ...message );
-}
-
-/**
  * Initializes extension.
  *
  * @since 0.3.0
@@ -51,10 +40,13 @@
  * @type {InitializeCallback}
  * @param {InitializeArgs} args Args.
  */
-export async function initialize( { isDebug, onLCP } ) {
+export async function initialize( { log: _log, onLCP } ) {
+	// eslint-disable-next-line no-console
+	const log = _log || console.log; // TODO: Remove once Optimization Detective likely updated, or when strict version requirement added in od_init action.
+
 	onLCP(
 		( metric ) => {
-			handleLCPMetric( metric, isDebug );
+			handleLCPMetric( metric, log );
 		},
 		{
 			// This avoids needing to click to finalize LCP candidate. While this is helpful for testing, it also
@@ -70,10 +62,10 @@
  *
  * @since 0.3.0
  *
- * @param {LCPMetric} metric  - LCP Metric.
- * @param {boolean}   isDebug - Whether in debug mode.
+ * @param {LCPMetric}   metric - LCP Metric.
+ * @param {LogFunction} log    - The function to call with log messages.
  */
-function handleLCPMetric( metric, isDebug ) {
+function handleLCPMetric( metric, log ) {
 	for ( const entry of metric.entries ) {
 		// Look only for LCP entries that have a URL and a corresponding element which is not an IMG or VIDEO.
 		if (
@@ -98,19 +90,13 @@
 
 		// Skip URLs that are excessively long. This is the maxLength defined in image_prioritizer_add_element_item_schema_properties().
 		if ( entry.url.length > 500 ) {
-			if ( isDebug ) {
-				log( `Skipping very long URL: ${ entry.url }` );
-			}
+			log( `Skipping very long URL: ${ entry.url }` );
 			return;
 		}
 
 		// Also skip Custom Elements which have excessively long tag names. This is the maxLength defined in image_prioritizer_add_element_item_schema_properties().
 		if ( entry.element.tagName.length > 100 ) {
-			if ( isDebug ) {
-				log(
-					`Skipping very long tag name: ${ entry.element.tagName }`
-				);
-			}
+			log( `Skipping very long tag name: ${ entry.element.tagName }` );
 			return;
 		}
 
@@ -118,16 +104,12 @@
 		// The maxLengths are defined in image_prioritizer_add_element_item_schema_properties().
 		const id = entry.element.getAttribute( 'id' );
 		if ( typeof id === 'string' && id.length > 100 ) {
-			if ( isDebug ) {
-				log( `Skipping very long ID: ${ id }` );
-			}
+			log( `Skipping very long ID: ${ id }` );
 			return;
 		}
 		const className = entry.element.getAttribute( 'class' );
 		if ( typeof className === 'string' && className.length > 500 ) {
-			if ( isDebug ) {
-				log( `Skipping very long className: ${ className }` );
-			}
+			log( `Skipping very long className: ${ className }` );
 			return;
 		}
 
@@ -141,12 +123,10 @@
 			class: className,
 		};
 
-		if ( isDebug ) {
-			log(
-				'Detected external LCP background image:',
-				externalBackgroundImage
-			);
-		}
+		log(
+			'Detected external LCP background image:',
+			externalBackgroundImage
+		);
 
 		externalBackgroundImages.push( externalBackgroundImage );
 	}
@@ -160,7 +140,10 @@
  * @type {FinalizeCallback}
  * @param {FinalizeArgs} args Args.
  */
-export async function finalize( { extendRootData, isDebug } ) {
+export async function finalize( { extendRootData, log: _log } ) {
+	// eslint-disable-next-line no-console
+	const log = _log || console.log; // TODO: Remove once Optimization Detective likely updated, or when strict version requirement added in od_init action.
+
 	if ( externalBackgroundImages.length === 0 ) {
 		return;
 	}
@@ -168,12 +151,10 @@
 	// Get the last detected external background image which is going to be for the LCP element (or very likely will be).
 	const lcpElementExternalBackgroundImage = externalBackgroundImages.pop();
 
-	if ( isDebug ) {
-		log(
-			'Sending external background image for LCP element:',
-			lcpElementExternalBackgroundImage
-		);
-	}
+	log(
+		'Sending external background image for LCP element:',
+		lcpElementExternalBackgroundImage
+	);
 
 	extendRootData( { lcpElementExternalBackgroundImage } );
 }
Index: helper.php
===================================================================
--- helper.php	(revision 3257264)
+++ helper.php	(working copy)
@@ -292,13 +292,21 @@
  *
  * @return WP_REST_Response|WP_HTTP_Response|WP_Error|mixed Result to send to the client.
  * @noinspection PhpDocMissingThrowsInspection
+ * @noinspection PhpDeprecationInspection
  */
 function image_prioritizer_filter_rest_request_before_callbacks( $response, array $handler, WP_REST_Request $request ) {
+	unset( $handler ); // Unused.
+
+	// Check for class existence and use constant or class method calls accordingly.
+	$route_endpoint = class_exists( 'OD_REST_URL_Metrics_Store_Endpoint' )
+						? OD_REST_URL_Metrics_Store_Endpoint::ROUTE_NAMESPACE . OD_REST_URL_Metrics_Store_Endpoint::ROUTE_BASE
+						: OD_REST_API_NAMESPACE . OD_URL_METRICS_ROUTE; // @phpstan-ignore constant.deprecated, constant.deprecated (To be replaced with class method calls in subsequent release.)
+
 	if (
 		$request->get_method() !== 'POST'
 		||
 		// The strtolower() and outer trim are due to \WP_REST_Server::match_request_to_handler() using case-insensitive pattern match and using '$' instead of '\z'.
-		OD_REST_API_NAMESPACE . OD_URL_METRICS_ROUTE !== rtrim( strtolower( ltrim( $request->get_route(), '/' ) ) )
+		( rtrim( strtolower( ltrim( $request->get_route(), '/' ) ) ) !== $route_endpoint )
 	) {
 		return $response;
 	}
Index: load.php
===================================================================
--- load.php	(revision 3257264)
+++ load.php	(working copy)
@@ -6,7 +6,7 @@
  * Requires at least: 6.6
  * Requires PHP: 7.2
  * Requires Plugins: optimization-detective
- * 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
@@ -72,7 +72,7 @@
 	}
 )(
 	'image_prioritizer_pending_plugin',
-	'1.0.0-beta1',
+	'1.0.0-beta2',
 	static function ( string $version ): void {
 		if ( defined( 'IMAGE_PRIORITIZER_VERSION' ) ) {
 			return;
Index: readme.txt
===================================================================
--- readme.txt	(revision 3257264)
+++ readme.txt	(working copy)
@@ -2,7 +2,7 @@
 
 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, image, lcp, lazy-load
@@ -72,6 +72,18 @@
 
 == Changelog ==
 
+= 1.0.0-beta2 =
+
+**Enhancements**
+
+* Update `OD_HTML_Tag_Processor::next_tag()` to allow `$query` arg and prepare to skip visiting tag closers by default. ([1872](https://github.com/WordPress/performance/pull/1872))
+* Expose the logging functions to client-side extensions and automatically account for the value of `isDebug`. ([1895](https://github.com/WordPress/performance/pull/1895))
+
+**Bug Fixes**
+
+* Fix URL encoding in Link HTTP response header. ([1907](https://github.com/WordPress/performance/pull/1907))
+* Fix unpredictable LCP element being identified in a URL Metric Group. ([1903](https://github.com/WordPress/performance/pull/1903))
+
 = 1.0.0-beta1 =
 
 **Enhancements**

optimization-detective

Important

Stable tag change: 1.0.0-beta2 → 1.0.0-beta3

svn status:

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
?       class-od-template-optimization-context.php
M       class-od-url-metric-group-collection.php
M       class-od-url-metric-group.php
M       class-od-url-metric.php
?       deprecated.php
M       detect.js
M       detect.min.js
M       detection.php
!       docs
!       docs/README.md
!       docs/extensions.md
!       docs/hooks.md
!       docs/introduction.md
M       helper.php
M       hooks.php
M       load.php
M       optimization.php
M       readme.txt
M       site-health.php
?       storage/class-od-rest-url-metrics-store-endpoint.php
M       storage/class-od-url-metrics-post-type.php
M       storage/data.php
!       storage/rest-api.php
M       types.ts
svn diff
Index: class-od-element.php
===================================================================
--- class-od-element.php	(revision 3257264)
+++ class-od-element.php	(working copy)
@@ -33,15 +33,6 @@
 	protected $data;
 
 	/**
-	 * Transitional XPath.
-	 *
-	 * @since 1.0.0
-	 * @todo Remove logic related to transitional_xpath in a subsequent release once URL Metrics have been collected with the new format.
-	 * @var non-empty-string|null
-	 */
-	protected $transitional_xpath = null;
-
-	/**
 	 * URL Metric that this element belongs to.
 	 *
 	 * @since 0.7.0
@@ -98,9 +89,6 @@
 	 * @return mixed|null The property value, or null if not set.
 	 */
 	public function get( string $key ) {
-		if ( 'xpath' === $key ) {
-			return $this->get_xpath();
-		}
 		return $this->data[ $key ] ?? null;
 	}
 
@@ -130,59 +118,11 @@
 	 * Gets XPath for element.
 	 *
 	 * @since 0.7.0
-	 * @since 1.0.0 Returns the transitional XPath format. To access the underlying raw XPath, access the 'xpath' key of the jsonSerialize response.
-	 * @todo Remove logic related to transitional_xpath in a subsequent release once URL Metrics have been collected with the new format.
 	 *
 	 * @return non-empty-string XPath.
 	 */
 	public function get_xpath(): string {
-
-		if ( ! isset( $this->transitional_xpath ) ) {
-			$replacements = array(
-
-				/*
-				 * Convert the original XPath format for elements in the BODY.
-				 *
-				 * Example:
-				 *   /*[1][self::HTML]/*[2][self::BODY]/*[1][self::DIV]/*[1][self::IMG]
-				 *   =>
-				 *   /HTML/BODY/DIV/*[1][self::IMG]
-				 */
-				'#^/\*\[1]\[self::HTML]/\*\[2]\[self::BODY]/\*\[\d+]\[self::([a-zA-Z0-9:_-]+)]#' => '/HTML/BODY/$1',
-
-				/*
-				 * Convert the original XPath format for elements in the HEAD.
-				 *
-				 * Example:
-				 *   /*[1][self::HTML]/*[1][self::HEAD]/*[1][self::META]
-				 *   =>
-				 *   /HTML/HEAD/*[1][self::META]
-				 */
-				'#^/\*\[1\]\[self::HTML\]/\*\[1\]\[self::HEAD]#' => '/HTML/HEAD',
-
-				/*
-				 * Convert the new XPath format for elements in the BODY.
-				 *
-				 * Note that the new XPath format for elements in the HEAD does not need to be converted to the
-				 * transitional format since disambiguating attributes are not used in the HEAD.
-				 *
-				 * Example:
-				 *   /HTML/BODY/DIV[@id='page']/*[1][self::IMG]
-				 *   =>
-				 *   /HTML/BODY/DIV/*[1][self::IMG]
-				 */
-				'#^(/HTML/BODY/\w+)\[@[^\]]+?]#' => '$1',
-			);
-			foreach ( $replacements as $search => $replace ) {
-				$xpath = preg_replace( $search, $replace, $this->data['xpath'], -1, $count );
-				if ( $count > 0 ) {
-					$this->transitional_xpath = $xpath;
-					break;
-				}
-			}
-		}
-
-		return $this->transitional_xpath ?? $this->data['xpath'];
+		return $this->data['xpath'];
 	}
 
 	/**
@@ -249,9 +189,6 @@
 	 */
 	#[ReturnTypeWillChange]
 	public function offsetGet( $offset ) {
-		if ( 'xpath' === $offset ) {
-			return $this->get_xpath();
-		}
 		return $this->data[ $offset ] ?? null;
 	}
 
Index: class-od-html-tag-processor.php
===================================================================
--- class-od-html-tag-processor.php	(revision 3257264)
+++ class-od-html-tag-processor.php	(working copy)
@@ -185,19 +185,6 @@
 	private $bookmarked_open_stacks = array();
 
 	/**
-	 * Stored XPath for the current tag.
-	 *
-	 * This is used so that repeated calls to {@see self::get_stored_xpath()} won't needlessly reconstruct the string.
-	 * This gets cleared whenever {@see self::open_tags()} iterates to the next tag.
-	 *
-	 * @todo Remove this once the XPath transitional period is over.
-	 *
-	 * @since 0.4.0
-	 * @var string|null
-	 */
-	private $current_stored_xpath = null;
-
-	/**
 	 * (Transitional) XPath for the current tag.
 	 *
 	 * This is used to store the old XPath format in a transitional period until which new URL Metrics are expected to
@@ -246,43 +233,40 @@
 	/**
 	 * Finds the next tag.
 	 *
-	 * Unlike the base class, this subclass disallows querying. This is to ensure the breadcrumbs can be tracked.
-	 * It will _always_ visit tag closers.
+	 * Unlike the base class, this subclass currently visits tag closers by default.
+	 * However, for the 1.0.0 release this method will behave the same as the method in
+	 * the base class, where it skips tag closers by default.
 	 *
 	 * @inheritDoc
 	 * @since 0.4.0
+	 * @since 1.0.0 Passing a $query is now allowed. In the 1.0.0 release, this will default to skipping tag closers.
 	 *
-	 * @param array{tag_name?: string|null, match_offset?: int|null, class_name?: string|null, tag_closers?: string|null}|null $query Query, but only null is accepted for this subclass.
+	 * @param array{tag_name?: string|null, match_offset?: int|null, class_name?: string|null, tag_closers?: string|null}|null $query Query.
 	 * @return bool Whether a tag was matched.
-	 *
-	 * @throws InvalidArgumentException If attempting to pass a query.
 	 */
 	public function next_tag( $query = null ): bool {
-		if ( null !== $query ) {
-			throw new InvalidArgumentException( esc_html__( 'Processor subclass does not support queries.', 'optimization-detective' ) );
+		if ( null === $query ) {
+			$query = array( 'tag_closers' => 'visit' );
+			$this->warn(
+				__METHOD__,
+				esc_html__( 'Previously this method always visited tag closers and did not allow a query to be supplied. Now, however, a query can be supplied. To align this method with the behavior of the base class, a future version of this method will default to skipping tag closers.', 'optimization-detective' )
+			);
 		}
-
-		// Elements in the Admin Bar are not relevant for optimization, so this loop ensures that no tags in the Admin Bar are visited.
-		do {
-			$matched = parent::next_tag( array( 'tag_closers' => 'visit' ) );
-		} while ( $matched && $this->is_admin_bar() );
-		return $matched;
+		return parent::next_tag( $query );
 	}
 
 	/**
 	 * Finds the next open tag.
 	 *
+	 * This method will soon be equivalent to calling {@see self::next_tag()} without passing any `$query`.
+	 *
 	 * @since 0.4.0
+	 * @deprecated n.e.x.t Use {@see self::next_tag()} instead.
 	 *
 	 * @return bool Whether a tag was matched.
 	 */
 	public function next_open_tag(): bool {
-		while ( $this->next_tag() ) {
-			if ( ! $this->is_tag_closer() ) {
-				return true;
-			}
-		}
-		return false;
+		return $this->next_tag( array( 'tag_closers' => 'skip' ) );
 	}
 
 	/**
@@ -318,8 +302,7 @@
 	 * @return bool Whether a token was parsed.
 	 */
 	public function next_token(): bool {
-		$this->current_stored_xpath = null; // Clear cache.
-		$this->current_xpath        = null; // Clear cache.
+		$this->current_xpath = null; // Clear cache.
 		++$this->cursor_move_count;
 		if ( ! parent::next_token() ) {
 			$this->open_stack_tags       = array();
@@ -653,23 +636,25 @@
 	 * This predicate will be included once the transitional period is over.
 	 *
 	 * @since 0.4.0
-	 * @todo Replace the logic herein with what is in get_stored_xpath() once the transitional period is over.
 	 *
 	 * @return string XPath.
 	 */
 	public function get_xpath(): string {
-		/*
-		 * This transitional format is used by default for all extensions. The non-transitional format is used only in
-		 * od_optimize_template_output_buffer() when setting the data-od-xpath attribute. This is so that the new format
-		 * will replace the old format as new URL Metrics are collected. After a month of the new format being live, the
-		 * transitional format can be eliminated. See the corresponding logic in OD_Element for normalizing both the
-		 * old and new XPath formats to use the transitional format.
-		 */
 		if ( null === $this->current_xpath ) {
 			$this->current_xpath = '';
 			foreach ( $this->get_indexed_breadcrumbs() as $i => list( $tag_name, $index, $attributes ) ) {
-				if ( $i < 2 || ( 2 === $i && '/HTML/BODY' === $this->current_xpath ) ) {
+				if ( $i < 2 ) {
 					$this->current_xpath .= "/$tag_name";
+				} elseif ( 2 === $i && '/HTML/BODY' === $this->current_xpath ) {
+					$segment = "/$tag_name";
+					foreach ( $attributes as $attribute_name => $attribute_value ) {
+						$segment .= sprintf(
+							"[@%s='%s']",
+							$attribute_name,
+							$attribute_value // Note: $attribute_value has already been validated to only contain safe characters /^[a-zA-Z0-9_.\s:-]*/ which do not need escaping.
+						);
+					}
+					$this->current_xpath .= $segment;
 				} else {
 					$this->current_xpath .= sprintf( '/*[%d][self::%s]', $index + 1, $tag_name );
 				}
@@ -681,49 +666,32 @@
 	/**
 	 * Gets stored XPath for the current open tag.
 	 *
-	 * This method is temporary for a transition period while new URL Metrics are collected for active installs. Once
-	 * the transition period is over, the logic in this method can be moved to {@see self::get_xpath()} and this method
-	 * can simply be an alias for that one. See related logic in {@see OD_Element::get_xpath()}. This function is only
-	 * used internally by Optimization Detective in {@see od_optimize_template_output_buffer()}.
+	 * This method was temporary for a transition period while new URL Metrics are collected for active installs
 	 *
 	 * @since 1.0.0
-	 * @todo Move the logic in this method to the get_xpath() method and let this be an alias for that method once the transitional period is over.
 	 * @access private
+	 * @deprecated
+	 * @codeCoverageIgnore
 	 *
 	 * @return string XPath.
 	 */
 	public function get_stored_xpath(): string {
-		if ( null === $this->current_stored_xpath ) {
-			$this->current_stored_xpath = '';
-			foreach ( $this->get_indexed_breadcrumbs() as $i => list( $tag_name, $index, $attributes ) ) {
-				if ( $i < 2 ) {
-					$this->current_stored_xpath .= "/$tag_name";
-				} elseif ( 2 === $i && '/HTML/BODY' === $this->current_stored_xpath ) {
-					$segment = "/$tag_name";
-					foreach ( $attributes as $attribute_name => $attribute_value ) {
-						$segment .= sprintf(
-							"[@%s='%s']",
-							$attribute_name,
-							$attribute_value // Note: $attribute_value has already been validated to only contain safe characters /^[a-zA-Z0-9_.\s:-]*/ which do not need escaping.
-						);
-					}
-					$this->current_stored_xpath .= $segment;
-				} else {
-					$this->current_stored_xpath .= sprintf( '/*[%d][self::%s]', $index + 1, $tag_name );
-				}
-			}
-		}
-		return $this->current_stored_xpath;
+		return $this->get_xpath();
 	}
 
 	/**
 	 * Returns whether the processor is currently at or inside the admin bar.
 	 *
+	 * This is only intended to be used internally by Optimization Detective as part of the "optimization loop". Tag
+	 * visitors should not rely on this method as it may be deprecated in the future, especially with a migration to
+	 * WP_HTML_Processor after {@link https://core.trac.wordpress.org/ticket/63020} is implemented.
+	 *
 	 * @since 1.0.0
+	 * @access private
 	 *
 	 * @return bool Whether at or inside the admin bar.
 	 */
-	private function is_admin_bar(): bool {
+	public function is_admin_bar(): bool {
 		return (
 			isset( $this->open_stack_tags[2], $this->open_stack_attributes[2]['id'] )
 			&&
@@ -734,29 +702,33 @@
 	}
 
 	/**
-	 * Append HTML to the HEAD.
+	 * Appends raw HTML to the HEAD.
 	 *
-	 * The provided HTML must be valid! No validation is performed.
+	 *  The provided HTML must be valid for insertion in the HEAD. No validation is currently performed. However, in the
+	 *  future the HTML Processor may be used to ensure the validity of the provided HTML. At that time, when invalid
+	 *  HTML is provided, this method may emit a `_doing_it_wrong()` warning.
 	 *
 	 * @since 0.4.0
 	 *
-	 * @param string $html HTML to inject.
+	 * @param string $raw_html Raw HTML to inject.
 	 */
-	public function append_head_html( string $html ): void {
-		$this->buffered_text_replacements[ self::END_OF_HEAD_BOOKMARK ][] = $html;
+	public function append_head_html( string $raw_html ): void {
+		$this->buffered_text_replacements[ self::END_OF_HEAD_BOOKMARK ][] = $raw_html;
 	}
 
 	/**
-	 * Append HTML to the BODY.
+	 * Appends raw HTML to the BODY.
 	 *
-	 * The provided HTML must be valid! No validation is performed.
+	 * The provided HTML must be valid for insertion in the BODY. No validation is currently performed. However, in the
+	 * future the HTML Processor may be used to ensure the validity of the provided HTML. At that time, when invalid
+	 * HTML is provided, this method may emit a `_doing_it_wrong()` warning.
 	 *
 	 * @since 0.4.0
 	 *
-	 * @param string $html HTML to inject.
+	 * @param string $raw_html Raw HTML to inject.
 	 */
-	public function append_body_html( string $html ): void {
-		$this->buffered_text_replacements[ self::END_OF_BODY_BOOKMARK ][] = $html;
+	public function append_body_html( string $raw_html ): void {
+		$this->buffered_text_replacements[ self::END_OF_BODY_BOOKMARK ][] = $raw_html;
 	}
 
 	/**
Index: class-od-link-collection.php
===================================================================
--- class-od-link-collection.php	(revision 3257264)
+++ class-od-link-collection.php	(working copy)
@@ -323,21 +323,19 @@
 	/**
 	 * Encodes a URL for serving in an HTTP response header.
 	 *
-	 * @since n.e.x.t
+	 * @since 1.0.0
 	 *
-	 * @param string $url URL to percent encode. Any existing percent encodings will first be decoded.
+	 * @param string $url URL to percent encode.
 	 * @return string Percent-encoded URL.
 	 */
 	private function encode_url_for_response_header( string $url ): string {
-		$decoded_url = urldecode( $url );
-
 		// Encode characters not allowed in a URL per RFC 3986 (anything that is not among the reserved and unreserved characters).
 		$encoded_url = (string) preg_replace_callback(
-			'/[^A-Za-z0-9\-._~:\/?#\[\]@!$&\'()*+,;=]/',
+			'/[^A-Za-z0-9\-._~:\/?#\[\]@!$&\'()*+,;=%]/',
 			static function ( $matches ) {
 				return rawurlencode( $matches[0] );
 			},
-			$decoded_url
+			$url
 		);
 		return esc_url_raw( $encoded_url );
 	}
Index: class-od-tag-visitor-context.php
===================================================================
--- class-od-tag-visitor-context.php	(revision 3257264)
+++ class-od-tag-visitor-context.php	(working copy)
@@ -110,7 +110,7 @@
 	 * @throws Error When property is unknown.
 	 */
 	public function __get( string $name ) {
-		// Note that there is intentionally not a case for 'visited_tag_state'.
+		// Note: There is intentionally not a 'visited_tag_state' case to expose $this->visited_tag_state.
 		switch ( $name ) {
 			case 'processor':
 				return $this->processor;
Index: class-od-url-metric-group-collection.php
===================================================================
--- class-od-url-metric-group-collection.php	(revision 3257264)
+++ class-od-url-metric-group-collection.php	(working copy)
@@ -233,7 +233,7 @@
 	}
 
 	/**
-	 * Gets the first URL Metric group.
+	 * Gets the first URL Metric group (with the lowest minimum viewport width, e.g. for mobile).
 	 *
 	 * This group normally represents viewports for mobile devices. This group always has a minimum viewport width of 0
 	 * and the maximum viewport width corresponds to the smallest defined breakpoint returned by
@@ -248,7 +248,7 @@
 	}
 
 	/**
-	 * Gets the last URL Metric group.
+	 * Gets the last URL Metric group (with the highest minimum viewport width, e.g. for desktop).
 	 *
 	 * 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()}.
@@ -266,6 +266,7 @@
 	 * Clears result cache.
 	 *
 	 * @since 0.3.0
+	 * @access private
 	 */
 	public function clear_cache(): void {
 		$this->result_cache = array();
@@ -298,6 +299,7 @@
 	 *
 	 * @since 0.1.0
 	 * @throws InvalidArgumentException If there is no group available to add a URL Metric to.
+	 * @access private
 	 *
 	 * @param OD_URL_Metric $new_url_metric New URL Metric.
 	 */
@@ -317,7 +319,7 @@
 	}
 
 	/**
-	 * Gets group for viewport width.
+	 * Gets the group for the provided viewport width.
 	 *
 	 * @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.
@@ -411,8 +413,12 @@
 	}
 
 	/**
-	 * Checks whether every group is complete.
+	 * Checks whether every group is complete (full sample of non-stale URL Metrics).
 	 *
+	 * Completeness means the full sample size of URL Metrics has been collected,
+	 * none of the collected URL Metrics are stale (with a mismatching ETag or a
+	 * timestamp older than the freshness TTL).
+	 *
 	 * @since 0.1.0
 	 * @see OD_URL_Metric_Group::is_complete()
 	 *
@@ -438,7 +444,7 @@
 	}
 
 	/**
-	 * Gets the groups with the provided LCP element XPath.
+	 * Gets the groups which have an LCP element with the provided XPath.
 	 *
 	 * @since 0.3.0
 	 * @see OD_URL_Metric_Group::get_lcp_element()
@@ -468,7 +474,7 @@
 	}
 
 	/**
-	 * Gets common LCP element.
+	 * Gets the LCP element which is shared by all groups, or at least the first group (mobile) and last group (desktop) if the intermediary groups are not populated.
 	 *
 	 * @since 0.3.0
 	 * @since 0.9.0 An LCP element is also considered common if it is the same in the narrowest and widest viewport groups, and all intermediate groups are empty.
@@ -585,7 +591,7 @@
 	}
 
 	/**
-	 * Gets all elements' status for whether they are positioned in any initial viewport.
+	 * Gets the status for whether each element is positioned in any initial viewport.
 	 *
 	 * An element is positioned in the initial viewport if its `boundingClientRect.top` is less than the
 	 * `viewport.height` for any of its recorded URL Metrics. Note that even though the element may be positioned in the
Index: class-od-url-metric-group.php
===================================================================
--- class-od-url-metric-group.php	(revision 3257264)
+++ class-od-url-metric-group.php	(working copy)
@@ -242,6 +242,7 @@
 	 * Adds a URL Metric to the group.
 	 *
 	 * @since 0.1.0
+	 * @access private
 	 *
 	 * @throws InvalidArgumentException If the viewport width of the URL Metric is not within the min/max bounds of the group.
 	 *
@@ -279,7 +280,8 @@
 	 * Determines whether the URL Metric group is complete.
 	 *
 	 * A group is complete if it has the full sample size of URL Metrics
-	 * and all of these URL Metrics are fresh.
+	 * and all of these URL Metrics are fresh (with a current ETag and a
+	 * timestamp that is not older than the freshness TTL).
 	 *
 	 * @since 0.1.0
 	 * @since 0.9.0 If the current environment's generated ETag does not match the URL Metric's ETag, the URL Metric is considered stale.
@@ -358,7 +360,20 @@
 			 */
 			$breadcrumb_element = array();
 
-			foreach ( $this->url_metrics as $url_metric ) {
+			// Prefer to use URL Metrics which have a current ETag.
+			$url_metrics = array_filter(
+				$this->url_metrics,
+				function ( OD_URL_Metric $url_metric ): bool {
+					return $url_metric->get_etag() === $this->get_collection()->get_current_etag();
+				}
+			);
+
+			// Otherwise, if no URL Metrics have a current ETag, fall back to using all the stale ones.
+			if ( count( $url_metrics ) === 0 ) {
+				$url_metrics = $this->url_metrics;
+			}
+
+			foreach ( $url_metrics as $url_metric ) {
 				foreach ( $url_metric->get_elements() as $element ) {
 					if ( ! $element->is_lcp() ) {
 						continue;
@@ -486,6 +501,7 @@
 	 * Clears result cache.
 	 *
 	 * @since 0.9.0
+	 * @access private
 	 */
 	public function clear_cache(): void {
 		$this->result_cache = array();
Index: class-od-url-metric.php
===================================================================
--- class-od-url-metric.php	(revision 3257264)
+++ class-od-url-metric.php	(working copy)
@@ -145,11 +145,11 @@
 	}
 
 	/**
-	 * Gets the group that this URL Metric is a part of (which may not be any).
+	 * Gets the group that this URL Metric is a part of.
 	 *
 	 * @since 0.7.0
 	 *
-	 * @return OD_URL_Metric_Group|null Group.
+	 * @return OD_URL_Metric_Group|null Group. Null will never occur in the context of a tag visitor.
 	 */
 	public function get_group(): ?OD_URL_Metric_Group {
 		return $this->group;
@@ -159,6 +159,7 @@
 	 * Sets the group that this URL Metric is a part of.
 	 *
 	 * @since 0.7.0
+	 * @access private
 	 *
 	 * @param OD_URL_Metric_Group $group Group.
 	 *
@@ -177,6 +178,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.
+	 * @access private
 	 *
 	 * @todo Cache the return value?
 	 *
Index: detect.js
===================================================================
--- detect.js	(revision 3257264)
+++ detect.js	(working copy)
@@ -1,3 +1,5 @@
+// noinspection JSUnusedGlobalSymbols
+
 /**
  * @typedef {import("web-vitals").LCPMetric} LCPMetric
  * @typedef {import("web-vitals").LCPMetricWithAttribution} LCPMetricWithAttribution
@@ -17,6 +19,7 @@
  * @typedef {import("./types.ts").Extension} Extension
  * @typedef {import("./types.ts").ExtendedRootData} ExtendedRootData
  * @typedef {import("./types.ts").ExtendedElementData} ExtendedElementData
+ * @typedef {import("./types.ts").Logger} Logger
  */
 
 const win = window;
@@ -66,33 +69,60 @@
 }
 
 /**
- * Logs a message.
+ * Creates a logger object with log, warn, and error methods.
  *
- * @param {...*} message
+ * @param {boolean} [debugMode=false] - Whether to enable debug mode.
+ * @param {string}  [prefix='']       - Prefix to prepend to the console message.
+ * @return {Logger} Logger object with log, info, warn, and error methods.
  */
-function log( ...message ) {
-	// eslint-disable-next-line no-console
-	console.log( consoleLogPrefix, ...message );
-}
+function createLogger( debugMode = false, prefix = '' ) {
+	return {
+		/**
+		 * Logs a message if debug mode is enabled.
+		 *
+		 * @param {...*} message - The message(s) to log.
+		 */
+		log( ...message ) {
+			if ( debugMode ) {
+				// eslint-disable-next-line no-console
+				console.log( prefix, ...message );
+			}
+		},
 
-/**
- * Logs a warning.
- *
- * @param {...*} message
- */
-function warn( ...message ) {
-	// eslint-disable-next-line no-console
-	console.warn( consoleLogPrefix, ...message );
-}
+		/**
+		 * Logs an informational message if debug mode is enabled.
+		 *
+		 * @param {...*} message - The message(s) to log as info.
+		 */
+		info( ...message ) {
+			if ( debugMode ) {
+				// eslint-disable-next-line no-console
+				console.info( prefix, ...message );
+			}
+		},
 
-/**
- * Logs an error.
- *
- * @param {...*} message
- */
-function error( ...message ) {
-	// eslint-disable-next-line no-console
-	console.error( consoleLogPrefix, ...message );
+		/**
+		 * Logs a warning if debug mode is enabled.
+		 *
+		 * @param {...*} message - The message(s) to log as a warning.
+		 */
+		warn( ...message ) {
+			if ( debugMode ) {
+				// eslint-disable-next-line no-console
+				console.warn( prefix, ...message );
+			}
+		},
+
+		/**
+		 * Logs an error.
+		 *
+		 * @param {...*} message - The message(s) to log as an error.
+		 */
+		error( ...message ) {
+			// eslint-disable-next-line no-console
+			console.error( prefix, ...message );
+		},
+	};
 }
 
 /**
@@ -126,35 +156,52 @@
  * @param {string}               currentETag          - Current ETag.
  * @param {string}               currentUrl           - Current URL.
  * @param {URLMetricGroupStatus} urlMetricGroupStatus - URL Metric group status.
- * @return {Promise<string>} Session storage key.
+ * @param {Logger}               logger               - Logger.
+ * @return {Promise<string|null>} Session storage key for the current URL or null if crypto is not available or caused an error.
  */
 async function getAlreadySubmittedSessionStorageKey(
 	currentETag,
 	currentUrl,
-	urlMetricGroupStatus
+	urlMetricGroupStatus,
+	{ warn, error }
 ) {
-	const message = [
-		currentETag,
-		currentUrl,
-		urlMetricGroupStatus.minimumViewportWidth,
-		urlMetricGroupStatus.maximumViewportWidth || '',
-	].join( '-' );
+	if ( ! window.crypto || ! window.crypto.subtle ) {
+		warn(
+			'Unable to generate sessionStorage key for already-submitted URL since crypto is not available, likely due to to the page not being served via HTTPS.'
+		);
+		return null;
+	}
 
-	/*
-	 * 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 }`;
+	try {
+		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 }`;
+	} catch ( err ) {
+		error(
+			'Unable to generate sessionStorage key for already-submitted URL due to error:',
+			err
+		);
+		return null;
+	}
 }
 
 /**
@@ -169,7 +216,7 @@
 /**
  * Recursively freezes an object to prevent mutation.
  *
- * @param {Object} obj Object to recursively freeze.
+ * @param {Object} obj - Object to recursively freeze.
  */
 function recursiveFreeze( obj ) {
 	for ( const prop of Object.getOwnPropertyNames( obj ) ) {
@@ -248,7 +295,7 @@
 /**
  * Gets element data.
  *
- * @param {string} xpath XPath.
+ * @param {string} xpath - XPath.
  * @return {ElementData|null} Element data, or null if no element for the XPath exists.
  */
 function getElementData( xpath ) {
@@ -264,8 +311,8 @@
 /**
  * Extends element data.
  *
- * @param {string}              xpath      XPath.
- * @param {ExtendedElementData} properties Properties.
+ * @param {string}              xpath      - XPath.
+ * @param {ExtendedElementData} properties - Properties.
  */
 function extendElementData( xpath, properties ) {
 	if ( ! elementsByXPath.has( xpath ) ) {
@@ -290,23 +337,23 @@
 /**
  * Detects the LCP element, loaded images, client viewport and store for future optimizations.
  *
- * @param {Object}                 args                            Args.
- * @param {string[]}               args.extensionModuleUrls        URLs for extension script modules to import.
- * @param {number}                 args.minViewportAspectRatio     Minimum aspect ratio allowed for the viewport.
- * @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.
- * @param {number|null}            args.cachePurgePostId           Cache purge post ID.
- * @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.
+ * @param {Object}                 args                            - Args.
+ * @param {string[]}               args.extensionModuleUrls        - URLs for extension script modules to import.
+ * @param {number}                 args.minViewportAspectRatio     - Minimum aspect ratio allowed for the viewport.
+ * @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.
+ * @param {number|null}            args.cachePurgePostId           - Cache purge post ID.
+ * @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.
  */
 export default async function detect( {
 	minViewportAspectRatio,
@@ -326,6 +373,9 @@
 	webVitalsLibrarySrc,
 	urlMetricGroupCollection,
 } ) {
+	const logger = createLogger( isDebug, consoleLogPrefix );
+	const { log, warn, error } = logger;
+
 	if ( isDebug ) {
 		const allUrlMetrics = /** @type Array<UrlMetricDebugData> */ [];
 		for ( const group of urlMetricGroupCollection.groups ) {
@@ -345,11 +395,9 @@
 	}
 
 	if ( win.innerWidth === 0 || win.innerHeight === 0 ) {
-		if ( isDebug ) {
-			log(
-				'Window must have non-zero dimensions for URL Metric collection.'
-			);
-		}
+		log(
+			'Window must have non-zero dimensions for URL Metric collection.'
+		);
 		return;
 	}
 
@@ -359,9 +407,7 @@
 		urlMetricGroupStatuses
 	);
 	if ( urlMetricGroupStatus.complete ) {
-		if ( isDebug ) {
-			log( 'No need for URL Metrics from the current viewport.' );
-		}
+		log( 'No need for URL Metrics from the current viewport.' );
 		return;
 	}
 
@@ -370,9 +416,13 @@
 		await getAlreadySubmittedSessionStorageKey(
 			currentETag,
 			currentUrl,
-			urlMetricGroupStatus
+			urlMetricGroupStatus,
+			logger
 		);
-	if ( alreadySubmittedSessionStorageKey in sessionStorage ) {
+	if (
+		null !== alreadySubmittedSessionStorageKey &&
+		alreadySubmittedSessionStorageKey in sessionStorage
+	) {
 		const previousVisitTime = parseInt(
 			sessionStorage.getItem( alreadySubmittedSessionStorageKey ),
 			10
@@ -381,12 +431,10 @@
 			! 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;
-			}
+			log(
+				'The current client session already submitted a fresh URL Metric for this URL so a new one will not be collected now.'
+			);
+			return;
 		}
 	}
 
@@ -396,11 +444,9 @@
 		aspectRatio < minViewportAspectRatio ||
 		aspectRatio > maxViewportAspectRatio
 	) {
-		if ( isDebug ) {
-			warn(
-				`Viewport aspect ratio (${ aspectRatio }) is not in the accepted range of ${ minViewportAspectRatio } to ${ maxViewportAspectRatio }.`
-			);
-		}
+		warn(
+			`Viewport aspect ratio (${ aspectRatio }) is not in the accepted range of ${ minViewportAspectRatio } to ${ maxViewportAspectRatio }.`
+		);
 		return;
 	}
 
@@ -434,9 +480,7 @@
 	// od_is_url_metric_storage_locked() function returns true. However, the downside with that is page caching could
 	// result in metrics missed from being gathered when a user navigates around a site and primes the page cache.
 	if ( isStorageLocked( getCurrentTime(), storageLockTTL ) ) {
-		if ( isDebug ) {
-			warn( 'Aborted detection due to storage being locked.' );
-		}
+		warn( 'Aborted detection due to storage being locked.' );
 		return;
 	}
 
@@ -461,17 +505,13 @@
 	// TODO: Does this make sense here?
 	// Prevent detection when page is not scrolled to the initial viewport.
 	if ( doc.documentElement.scrollTop > 0 ) {
-		if ( isDebug ) {
-			warn(
-				'Aborted detection since initial scroll position of page is not at the top.'
-			);
-		}
+		warn(
+			'Aborted detection since initial scroll position of page is not at the top.'
+		);
 		return;
 	}
 
-	if ( isDebug ) {
-		log( 'Proceeding with detection' );
-	}
+	log( 'Proceeding with detection' );
 
 	/** @type {Map<string, Extension>} */
 	const extensions = new Map();
@@ -487,10 +527,19 @@
 			/** @type {Extension} */
 			const extension = await import( extensionModuleUrl );
 			extensions.set( extensionModuleUrl, extension );
+
+			const extensionLogger = createLogger(
+				isDebug,
+				`[Optimization Detective: ${
+					extension.name || 'Unnamed Extension'
+				}]`
+			);
+
 			// TODO: There should to be a way to pass additional args into the module. Perhaps extensionModuleUrls should be a mapping of URLs to args.
 			if ( extension.initialize instanceof Function ) {
 				const initializePromise = extension.initialize( {
 					isDebug,
+					...extensionLogger,
 					onTTFB,
 					onFCP,
 					onLCP,
@@ -607,9 +656,7 @@
 
 	// Stop observing.
 	disconnectIntersectionObserver();
-	if ( isDebug ) {
-		log( 'Detection is stopping.' );
-	}
+	log( 'Detection is stopping.' );
 
 	urlMetric = {
 		url: currentUrl,
@@ -625,9 +672,7 @@
 	for ( const elementIntersection of elementIntersections ) {
 		const xpath = breadcrumbedElementsMap.get( elementIntersection.target );
 		if ( ! xpath ) {
-			if ( isDebug ) {
-				error( 'Unable to look up XPath for element' );
-			}
+			warn( 'Unable to look up XPath for element' );
 			continue;
 		}
 
@@ -657,9 +702,7 @@
 		elementsByXPath.set( elementData.xpath, elementData );
 	}
 
-	if ( isDebug ) {
-		log( 'Current URL Metric:', urlMetric );
-	}
+	log( 'Current URL Metric:', urlMetric );
 
 	// Wait for the page to be hidden.
 	await new Promise( ( resolve ) => {
@@ -680,11 +723,7 @@
 	// Only proceed with submitting the URL Metric if viewport stayed the same size. Changing the viewport size (e.g. due
 	// to resizing a window or changing the orientation of a device) will result in unexpected metrics being collected.
 	if ( didWindowResize ) {
-		if ( isDebug ) {
-			log(
-				'Aborting URL Metric collection due to viewport size change.'
-			);
-		}
+		log( 'Aborting URL Metric collection due to viewport size change.' );
 		return;
 	}
 
@@ -701,9 +740,17 @@
 			extension,
 		] of extensions.entries() ) {
 			if ( extension.finalize instanceof Function ) {
+				const extensionLogger = createLogger(
+					isDebug,
+					`[Optimization Detective: ${
+						extension.name || 'Unnamed Extension'
+					}]`
+				);
+
 				try {
 					const finalizePromise = extension.finalize( {
 						isDebug,
+						...extensionLogger,
 						getRootData,
 						getElementData,
 						extendElementData,
@@ -748,10 +795,10 @@
 	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 payloadBlob = new Blob( [ jsonBody ], { type: 'application/json' } );
 	const percentOfBudget =
-		( jsonBody.length / ( maxBodyLengthKiB * 1000 ) ) * 100;
+		( payloadBlob.size / ( maxBodyLengthKiB * 1000 ) ) * 100;
 
 	/*
 	 * According to the fetch() spec:
@@ -759,15 +806,13 @@
 	 * 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
-			);
-		}
+	if ( payloadBlob.size > maxBodyLengthBytes ) {
+		error(
+			`Unable to send URL Metric because it is ${ payloadBlob.size.toLocaleString() } bytes, ${ Math.round(
+				percentOfBudget
+			) }% of ${ maxBodyLengthKiB } KiB limit:`,
+			urlMetric
+		);
 		return;
 	}
 
@@ -776,22 +821,22 @@
 	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 ( null !== alreadySubmittedSessionStorageKey ) {
+		sessionStorage.setItem(
+			alreadySubmittedSessionStorageKey,
+			String( getCurrentTime() )
+		);
+	}
 
-	if ( isDebug ) {
-		const message = `Sending URL Metric (${ jsonBody.length.toLocaleString() } bytes, ${ Math.round(
-			percentOfBudget
-		) }% of ${ maxBodyLengthKiB } KiB limit):`;
+	const message = `Sending URL Metric (${ payloadBlob.size.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 );
-		}
+	// 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 );
@@ -807,12 +852,7 @@
 		);
 	}
 	url.searchParams.set( 'hmac', urlMetricHMAC );
-	navigator.sendBeacon(
-		url,
-		new Blob( [ jsonBody ], {
-			type: 'application/json',
-		} )
-	);
+	navigator.sendBeacon( url, payloadBlob );
 
 	// Clean up.
 	breadcrumbedElementsMap.clear();
Index: detection.php
===================================================================
--- detection.php	(revision 3257264)
+++ detection.php	(working copy)
@@ -121,7 +121,7 @@
 		'maxViewportAspectRatio' => od_get_maximum_viewport_aspect_ratio(),
 		'isDebug'                => WP_DEBUG,
 		'extensionModuleUrls'    => $extension_module_urls,
-		'restApiEndpoint'        => rest_url( OD_REST_API_NAMESPACE . OD_URL_METRICS_ROUTE ),
+		'restApiEndpoint'        => rest_url( OD_REST_URL_Metrics_Store_Endpoint::ROUTE_NAMESPACE . OD_REST_URL_Metrics_Store_Endpoint::ROUTE_BASE ),
 		'currentETag'            => $current_etag,
 		'currentUrl'             => $current_url,
 		'urlMetricSlug'          => $slug,
@@ -157,3 +157,65 @@
 		array( 'type' => 'module' )
 	);
 }
+
+/**
+ * Registers the REST API endpoint for storing URL Metrics.
+ *
+ * @since 1.0.0
+ * @access private
+ */
+function od_register_rest_url_metric_store_endpoint(): void {
+	$endpoint_controller = new OD_REST_URL_Metrics_Store_Endpoint();
+
+	register_rest_route(
+		$endpoint_controller::ROUTE_NAMESPACE,
+		$endpoint_controller::ROUTE_BASE,
+		$endpoint_controller->get_registration_args()
+	);
+}
+
+/**
+ * Triggers post update actions for page caches to invalidate their caches related to the supplied cache purge post ID.
+ *
+ * This is intended to flush any page cache for the URL after the new URL Metric was submitted so that the optimizations
+ * which depend on that URL Metric can start to take effect.
+ *
+ * @since 1.0.0
+ *
+ * @param positive-int $cache_purge_post_id Cache purge post ID.
+ */
+function od_trigger_post_update_actions( int $cache_purge_post_id ): void {
+
+	$post = get_post( $cache_purge_post_id );
+	if ( ! ( $post instanceof WP_Post ) ) {
+		return;
+	}
+
+	// Fire actions that page caching plugins listen to flush caches.
+
+	/*
+	* The clean_post_cache action is used to flush page caches by:
+	* - Pantheon Advanced Cache <https://github.com/pantheon-systems/pantheon-advanced-page-cache/blob/e3b5552b0cb9268d9b696cb200af56cc044920d9/pantheon-advanced-page-cache.php#L185>
+	* - WP Super Cache <https://github.com/Automattic/wp-super-cache/blob/73b428d2fce397fd874b3056ad3120c343bc1a0c/wp-cache-phase2.php#L1615>
+	* - Batcache <https://github.com/Automattic/batcache/blob/ed0e6b2d9bcbab3924c49a6c3247646fb87a0957/batcache.php#L18>
+	*/
+	/** This action is documented in wp-includes/post.php. */
+	do_action( 'clean_post_cache', $post->ID, $post );
+
+	/*
+	* The transition_post_status action is used to flush page caches by:
+	* - Jetpack Boost <https://github.com/Automattic/jetpack-boost-production/blob/4090a3f9414c2171cd52d8a397f00b0d1151475f/app/modules/optimizations/page-cache/pre-wordpress/Boost_Cache.php#L76>
+	* - WP Super Cache <https://github.com/Automattic/wp-super-cache/blob/73b428d2fce397fd874b3056ad3120c343bc1a0c/wp-cache-phase2.php#L1616>
+	* - LightSpeed Cache <https://github.com/litespeedtech/lscache_wp/blob/7c707469b3c88b4f45d9955593b92f9aeaed54c3/src/purge.cls.php#L68>
+	*/
+	/** This action is documented in wp-includes/post.php. */
+	do_action( 'transition_post_status', $post->post_status, $post->post_status, $post );
+
+	/*
+	* The clean_post_cache action is used to flush page caches by:
+	* - W3 Total Cache <https://github.com/BoldGrid/w3-total-cache/blob/ab08f104294c6a8dcb00f1c66aaacd0615c42850/Util_AttachToActions.php#L32>
+	* - WP Rocket <https://github.com/wp-media/wp-rocket/blob/e5bca6673a3669827f3998edebc0c785210fe561/inc/common/purge.php#L283>
+	*/
+	/** This action is documented in wp-includes/post.php. */
+	do_action( 'save_post', $post->ID, $post, /* $update */ true );
+}
Index: helper.php
===================================================================
--- helper.php	(revision 3257264)
+++ helper.php	(working copy)
@@ -22,22 +22,6 @@
 	/**
 	 * 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.
Index: hooks.php
===================================================================
--- hooks.php	(revision 3257264)
+++ hooks.php	(working copy)
@@ -24,4 +24,6 @@
 add_filter( 'site_status_tests', 'od_add_rest_api_availability_test' );
 add_action( 'admin_init', 'od_maybe_run_rest_api_health_check' );
 add_action( 'after_plugin_row_meta', 'od_render_rest_api_health_check_admin_notice_in_plugin_row', 30 );
+add_action( 'rest_api_init', 'od_register_rest_url_metric_store_endpoint' );
+add_action( 'od_trigger_page_cache_invalidation', 'od_trigger_post_update_actions' );
 // @codeCoverageIgnoreEnd
Index: load.php
===================================================================
--- load.php	(revision 3257264)
+++ load.php	(working copy)
@@ -5,7 +5,7 @@
  * 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-beta2
+ * Version: 1.0.0-beta3
  * 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-beta2',
+	'1.0.0-beta3',
 	static function ( string $version ): void {
 		if ( defined( 'OPTIMIZATION_DETECTIVE_VERSION' ) ) {
 			return;
@@ -99,6 +99,9 @@
 
 		require_once __DIR__ . '/helper.php';
 
+		// Deprecations.
+		require_once __DIR__ . '/deprecated.php';
+
 		// Core infrastructure classes.
 		require_once __DIR__ . '/class-od-data-validation-exception.php';
 		require_once __DIR__ . '/class-od-html-tag-processor.php';
@@ -114,7 +117,7 @@
 		require_once __DIR__ . '/storage/class-od-url-metrics-post-type.php';
 		require_once __DIR__ . '/storage/class-od-storage-lock.php';
 		require_once __DIR__ . '/storage/data.php';
-		require_once __DIR__ . '/storage/rest-api.php';
+		require_once __DIR__ . '/storage/class-od-rest-url-metrics-store-endpoint.php';
 		require_once __DIR__ . '/storage/class-od-url-metric-store-request-context.php';
 
 		// Detection logic.
@@ -121,6 +124,7 @@
 		require_once __DIR__ . '/detection.php';
 
 		// Optimization logic.
+		require_once __DIR__ . '/class-od-template-optimization-context.php';
 		require_once __DIR__ . '/class-od-link-collection.php';
 		require_once __DIR__ . '/class-od-tag-visitor-registry.php';
 		require_once __DIR__ . '/class-od-visited-tag-state.php';
Index: optimization.php
===================================================================
--- optimization.php	(revision 3257264)
+++ optimization.php	(working copy)
@@ -238,7 +238,7 @@
 	// If the initial tag is not an open HTML tag, then abort since the buffer is not a complete HTML document.
 	$processor = new OD_HTML_Tag_Processor( $buffer );
 	if ( ! (
-		$processor->next_tag() &&
+		$processor->next_tag( array( 'tag_closers' => 'visit' ) ) &&
 		! $processor->is_tag_closer() &&
 		'HTML' === $processor->get_tag()
 	) ) {
@@ -245,85 +245,22 @@
 		return $buffer;
 	}
 
-	$slug = od_get_url_metrics_slug( od_get_normalized_query_vars() );
-	$post = OD_URL_Metrics_Post_Type::get_post( $slug );
+	$query_vars = od_get_normalized_query_vars();
+	$slug       = od_get_url_metrics_slug( $query_vars );
+	$post       = OD_URL_Metrics_Post_Type::get_post( $slug );
 
+	/**
+	 * Post ID.
+	 *
+	 * @var positive-int|null $post_id
+	 */
+	$post_id = $post instanceof WP_Post ? $post->ID : null;
+
 	$tag_visitor_registry = new OD_Tag_Visitor_Registry();
 
 	/**
 	 * 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.
@@ -330,8 +267,8 @@
 	 */
 	do_action( 'od_register_tag_visitors', $tag_visitor_registry );
 
-	$current_etag         = od_get_current_url_metrics_etag( $tag_visitor_registry, $wp_the_query, od_get_current_theme_template() );
-	$group_collection     = new OD_URL_Metric_Group_Collection(
+	$current_etag     = od_get_current_url_metrics_etag( $tag_visitor_registry, $wp_the_query, od_get_current_theme_template() );
+	$group_collection = new OD_URL_Metric_Group_Collection(
 		$post instanceof WP_Post ? OD_URL_Metrics_Post_Type::get_url_metrics_from_post( $post ) : array(),
 		$current_etag,
 		od_get_breakpoint_max_widths(),
@@ -338,7 +275,27 @@
 		od_get_url_metrics_breakpoint_sample_size(),
 		od_get_url_metric_freshness_ttl()
 	);
-	$link_collection      = new OD_Link_Collection();
+	$link_collection  = new OD_Link_Collection();
+
+	$template_optimization_context = new OD_Template_Optimization_Context(
+		$group_collection,
+		$link_collection,
+		$query_vars,
+		$slug,
+		$post_id
+	);
+
+	/**
+	 * Fires before Optimization Detective starts iterating over the document in the output buffer.
+	 *
+	 * This is before any of the registered tag visitors have been invoked.
+	 *
+	 * @since 1.0.0
+	 *
+	 * @param OD_Template_Optimization_Context $template_optimization_context Template optimization context.
+	 */
+	do_action( 'od_start_template_optimization', $template_optimization_context );
+
 	$visited_tag_state    = new OD_Visited_Tag_State();
 	$tag_visitor_context  = new OD_Tag_Visitor_Context(
 		$processor,
@@ -345,7 +302,7 @@
 		$group_collection,
 		$link_collection,
 		$visited_tag_state,
-		$post instanceof WP_Post && $post->ID > 0 ? $post->ID : null
+		$post_id
 	);
 	$current_tag_bookmark = 'optimization_detective_current_tag';
 	$visitors             = iterator_to_array( $tag_visitor_registry );
@@ -355,7 +312,12 @@
 
 	do {
 		// Never process anything inside NOSCRIPT since it will never show up in the DOM when scripting is enabled, and thus it can never be detected nor measured.
-		if ( in_array( 'NOSCRIPT', $processor->get_breadcrumbs(), true ) ) {
+		// Similarly, elements in the Admin Bar are not relevant for optimization, so this loop ensures that no tags in the Admin Bar are visited.
+		if (
+			in_array( 'NOSCRIPT', $processor->get_breadcrumbs(), true )
+			||
+			$processor->is_admin_bar()
+		) {
 			continue;
 		}
 
@@ -382,15 +344,31 @@
 		}
 
 		if ( $tracked_in_url_metrics && $needs_detection ) {
-			// TODO: Replace get_stored_xpath with get_xpath once the transitional period is over.
-			$xpath = $processor->get_stored_xpath();
-			$processor->set_meta_attribute( 'xpath', $xpath );
+			$processor->set_meta_attribute( 'xpath', $processor->get_xpath() );
 		}
 
 		$visited_tag_state->reset();
-	} while ( $processor->next_open_tag() );
+	} while ( $processor->next_tag( array( 'tag_closers' => 'skip' ) ) );
 
+	// Inject detection script.
+	// TODO: When optimizing above, if we find that there is a stored LCP element but it fails to match, it should perhaps set $needs_detection to true and send the request with an override nonce. However, this would require backtracking and adding the data-od-xpath attributes.
+	if ( $needs_detection ) {
+		$processor->append_body_html( od_get_detection_script( $slug, $group_collection ) );
+	}
+
+	/**
+	 * Fires after Optimization Detective has finished iterating over the document in the output buffer.
+	 *
+	 * This is after all the registered tag visitors have been invoked.
+	 *
+	 * @since 1.0.0
+	 *
+	 * @param OD_Template_Optimization_Context $template_optimization_context Template optimization context.
+	 */
+	do_action( 'od_finish_template_optimization', $template_optimization_context );
+
 	// Send any preload links in a Link response header and in a LINK tag injected at the end of the HEAD.
+	// Additional links may have been added at the od_finish_template_optimization action, so this must come after.
 	if ( count( $link_collection ) > 0 ) {
 		$response_header_links = $link_collection->get_response_header();
 		if ( ! is_null( $response_header_links ) && ! headers_sent() ) {
@@ -399,11 +377,5 @@
 		$processor->append_head_html( $link_collection->get_html() );
 	}
 
-	// Inject detection script.
-	// TODO: When optimizing above, if we find that there is a stored LCP element but it fails to match, it should perhaps set $needs_detection to true and send the request with an override nonce. However, this would require backtracking and adding the data-od-xpath attributes.
-	if ( $needs_detection ) {
-		$processor->append_body_html( od_get_detection_script( $slug, $group_collection ) );
-	}
-
 	return $processor->get_updated_html();
 }
Index: readme.txt
===================================================================
--- readme.txt	(revision 3257264)
+++ readme.txt	(working copy)
@@ -2,7 +2,7 @@
 
 Contributors: wordpressdotorg
 Tested up to: 6.7
-Stable tag:   1.0.0-beta2
+Stable tag:   1.0.0-beta3
 License:      GPLv2 or later
 License URI:  https://www.gnu.org/licenses/gpl-2.0.html
 Tags:         performance, optimization, rum
@@ -13,11 +13,11 @@
 
 This plugin captures real user metrics about what elements are displayed on the page across a variety of device form factors (e.g. desktop, tablet, and phone) in order to apply loading optimizations which are not possible with WordPress’s current server-side heuristics.
 
-This plugin is a dependency which does not provide end-user functionality on its own. For that, please install the dependent plugin [Image Prioritizer](https://wordpress.org/plugins/image-prioritizer/) or [Embed Optimizer](https://wordpress.org/plugins/embed-optimizer/) (among [others](https://github.com/WordPress/performance/labels/%5BPlugin%5D%20Optimization%20Detective) to come from the WordPress Core Performance team). There are currently **no settings** and no user interface for this plugin since it is designed to work without any configuration.
+This plugin is a framework dependency which does not provide optimization functionality on its own. For that, please install the [Image Prioritizer](https://wordpress.org/plugins/image-prioritizer/) and [Embed Optimizer](https://wordpress.org/plugins/embed-optimizer/) dependent plugins (among [others](https://github.com/WordPress/performance/labels/%5BPlugin%5D%20Optimization%20Detective) to come from the WordPress Core Performance team). There are currently **no settings** and no user interface for this plugin since it is designed to work without any configuration.
 
-Your site must have the **REST API accessible** to frontend visitors since this is how metrics are collected about how a page should be optimized.
+Your site must currently have the **REST API accessible** to unauthenticated frontend visitors since this is how real user metrics are collected about pages on your site; nevertheless, [exploration](https://github.com/WordPress/performance/issues/1311) is underway for providing alternative mechanisms for collecting the metrics. Also, please note that no metrics are currently collected from Safari since it does not yet support the [Largest Contentful Paint](https://developer.mozilla.org/en-US/docs/Web/API/LargestContentfulPaint) metric, but support for this [Core Web Vitals](https://web.dev/explore/learn-core-web-vitals) metric is coming this year [via Interop 2025](https://webkit.org/blog/16458/announcing-interop-2025/#core-web-vitals).
 
-Please refer to the [full plugin documentation](https://github.com/WordPress/performance/blob/trunk/plugins/optimization-detective/docs/README.md) for a [technical introduction](https://github.com/WordPress/performance/blob/trunk/plugins/optimization-detective/docs/introduction.md), [filter/action hooks](https://github.com/WordPress/performance/blob/trunk/plugins/optimization-detective/docs/hooks.md), and [extensions](https://github.com/WordPress/performance/blob/trunk/plugins/optimization-detective/docs/extensions.md) that show use cases and examples.
+Please refer to the [full plugin documentation](https://github.com/WordPress/performance/blob/trunk/plugins/optimization-detective/docs/README.md) for a [technical introduction](https://github.com/WordPress/performance/blob/trunk/plugins/optimization-detective/docs/introduction.md), [filter/action hooks](https://github.com/WordPress/performance/blob/trunk/plugins/optimization-detective/docs/hooks.md), and [extensions](https://github.com/WordPress/performance/blob/trunk/plugins/optimization-detective/docs/extensions.md) that show use cases and examples. You can also watch the talk from WordCamp Asia 2025: [Boosting Performance with Optimization Detective](https://weston.ruter.net/2025/02/21/boosting-performance-with-optimization-detective/).
 
 == Installation ==
 
@@ -55,6 +55,23 @@
 
 == Changelog ==
 
+= 1.0.0-beta3 =
+
+**Enhancements**
+
+* Fire actions before and after Optimization Detective processes a document. ([1919](https://github.com/WordPress/performance/pull/1919))
+* Update `OD_HTML_Tag_Processor::next_tag()` to allow `$query` arg and prepare to skip visiting tag closers by default. ([1872](https://github.com/WordPress/performance/pull/1872))
+* Expose the logging functions to client-side extensions and automatically account for the value of `isDebug`. ([1895](https://github.com/WordPress/performance/pull/1895))
+* Update URL Metric storage REST API endpoint to return status code `423 Locked` instead of `403 Forbidden`. ([1863](https://github.com/WordPress/performance/pull/1863))
+* De-duplicate logic between REST API and URL Metrics post type. ([1867](https://github.com/WordPress/performance/pull/1867))
+
+**Bug Fixes**
+
+* Fix URL encoding in Link HTTP response header. ([1907](https://github.com/WordPress/performance/pull/1907))
+* Fix triggering post update actions after storing a URL Metric and refactor REST API endpoint logic into class. ([1865](https://github.com/WordPress/performance/pull/1865))
+* Fix unpredictable LCP element being identified in a URL Metric Group. ([1903](https://github.com/WordPress/performance/pull/1903))
+* Handle missing Web Crypto API in non-HTTPS contexts when generating the already-submitted `sessionStorage` key. ([1911](https://github.com/WordPress/performance/pull/1911))
+
 = 1.0.0-beta2 =
 
 **Enhancements**
Index: site-health.php
===================================================================
--- site-health.php	(revision 3257264)
+++ site-health.php	(working copy)
@@ -91,7 +91,7 @@
 		sprintf(
 			/* translators: %s is the REST API endpoint */
 			__( 'To collect URL Metrics from visitors the REST API must be available to unauthenticated users. Specifically, visitors must be able to perform a <code>POST</code> request to the <code>%s</code> endpoint.', 'optimization-detective' ),
-			'/' . OD_REST_API_NAMESPACE . OD_URL_METRICS_ROUTE
+			'/' . OD_REST_URL_Metrics_Store_Endpoint::ROUTE_NAMESPACE . OD_REST_URL_Metrics_Store_Endpoint::ROUTE_BASE
 		),
 		array( 'code' => array() )
 	) . '</p>';
@@ -192,7 +192,7 @@
 	if ( false !== $response ) {
 		return $response;
 	}
-	$rest_url = get_rest_url( null, OD_REST_API_NAMESPACE . OD_URL_METRICS_ROUTE );
+	$rest_url = get_rest_url( null, OD_REST_URL_Metrics_Store_Endpoint::ROUTE_NAMESPACE . OD_REST_URL_Metrics_Store_Endpoint::ROUTE_BASE );
 	$response = wp_remote_post(
 		$rest_url,
 		array(
Index: storage/class-od-url-metrics-post-type.php
===================================================================
--- storage/class-od-url-metrics-post-type.php	(revision 3257264)
+++ storage/class-od-url-metrics-post-type.php	(working copy)
@@ -200,22 +200,36 @@
 	}
 
 	/**
-	 * Stores URL Metric by merging it with the other URL Metrics which share the same normalized query vars.
+	 * Inserts or updates the od_url_metrics post with a provided collection of URL Metrics.
 	 *
-	 * @since 0.1.0
-	 * @todo There is duplicate logic here with od_handle_rest_request().
+	 * This method updates an existing URL Metrics post or creates a new one if it doesn't exist.
 	 *
-	 * @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.
+	 * @since 1.0.0
+	 *
+	 * @param non-empty-string               $slug Slug (hash of normalized query vars).
+	 * @param OD_URL_Metric_Group_Collection $url_metric_group_collection URL Metric group collection containing the metrics to be stored.
+	 * @return positive-int|WP_Error Post ID on success, or WP_Error on failure.
 	 */
-	public static function store_url_metric( string $slug, OD_URL_Metric $new_url_metric ) {
-		$post_data = array(
+	public static function update_post( string $slug, OD_URL_Metric_Group_Collection $url_metric_group_collection ) {
+		$url_metrics = $url_metric_group_collection->get_flattened_url_metrics();
+		if ( 0 === count( $url_metrics ) ) {
+			return new WP_Error( 'no_url_metrics', __( 'No URL Metrics in the group collection.', 'optimization-detective' ) );
+		}
+
+		// Sort URL Metrics in descending order by timestamp.
+		usort(
+			$url_metrics,
+			static function ( OD_URL_Metric $a, OD_URL_Metric $b ): int {
+				return $b->get_timestamp() <=> $a->get_timestamp();
+			}
+		);
+		$latest_url_metric = $url_metrics[0];
+		$post_data         = array(
 			// The URL is supplied as the post title in order to aid with debugging. Note that an od-url-metrics post stores
 			// multiple URL Metric instances, each of which also contains the URL for which the metric was captured. The URL
 			// appearing in the post title is therefore the most recent URL seen for the URL Metrics which have the same
 			// normalized query vars among them.
-			'post_title' => $new_url_metric->get_url(),
+			'post_title' => $latest_url_metric->get_url(),
 		);
 
 		$post = self::get_post( $slug );
@@ -222,31 +236,12 @@
 		if ( $post instanceof WP_Post ) {
 			$post_data['ID']        = $post->ID;
 			$post_data['post_name'] = $post->post_name;
-			$url_metrics            = self::get_url_metrics_from_post( $post );
 		} else {
 			$post_data['post_name'] = $slug;
-			$url_metrics            = array();
 		}
 
-		$etag = $new_url_metric->get_etag();
-
-		$group_collection = new OD_URL_Metric_Group_Collection(
-			$url_metrics,
-			$etag,
-			od_get_breakpoint_max_widths(),
-			od_get_url_metrics_breakpoint_sample_size(),
-			od_get_url_metric_freshness_ttl()
-		);
-
-		try {
-			$group = $group_collection->get_group_for_viewport_width( $new_url_metric->get_viewport_width() );
-			$group->add_url_metric( $new_url_metric );
-		} catch ( InvalidArgumentException $e ) {
-			return new WP_Error( 'invalid_url_metric', $e->getMessage() );
-		}
-
 		$post_data['post_content'] = wp_json_encode(
-			$group_collection->get_flattened_url_metrics(),
+			$url_metric_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'] ) ) {
Index: storage/data.php
===================================================================
--- storage/data.php	(revision 3257264)
+++ storage/data.php	(working copy)
@@ -37,7 +37,7 @@
 
 	if ( $freshness_ttl < 0 ) {
 		_doing_it_wrong(
-			__FUNCTION__,
+			esc_html( "Filter: 'od_url_metric_freshness_ttl'" ),
 			esc_html(
 				sprintf(
 					/* translators: %s is the TTL freshness */
@@ -382,15 +382,13 @@
  * @return positive-int[] Breakpoint max widths, sorted in ascending order.
  */
 function od_get_breakpoint_max_widths(): array {
-	$function_name = __FUNCTION__;
-
 	$breakpoint_max_widths = array_map(
-		static function ( $original_breakpoint ) use ( $function_name ): int {
+		static function ( $original_breakpoint ): int {
 			$breakpoint = $original_breakpoint;
 			if ( $breakpoint <= 0 ) {
 				$breakpoint = 1;
 				_doing_it_wrong(
-					esc_html( $function_name ),
+					esc_html( "Filter: 'od_breakpoint_max_widths'" ),
 					esc_html(
 						sprintf(
 							/* translators: %s is the actual breakpoint max width */
@@ -447,7 +445,7 @@
 
 	if ( $sample_size <= 0 ) {
 		_doing_it_wrong(
-			__FUNCTION__,
+			esc_html( "Filter: 'od_url_metrics_breakpoint_sample_size'" ),
 			esc_html(
 				sprintf(
 					/* translators: %s is the sample size */
Index: types.ts
===================================================================
--- types.ts	(revision 3257264)
+++ types.ts	(working copy)
@@ -1,13 +1,13 @@
 // h/t https://stackoverflow.com/a/59801602/93579
 type ExcludeProps< T > = { [ k: string ]: any } & { [ K in keyof T ]?: never };
 
-import { onTTFB, onFCP, onLCP, onINP, onCLS } from 'web-vitals';
+import { onCLS, onFCP, onINP, onLCP, onTTFB } from 'web-vitals';
 import {
-	onTTFB as onTTFBWithAttribution,
+	onCLS as onCLSWithAttribution,
 	onFCP as onFCPWithAttribution,
+	onINP as onINPWithAttribution,
 	onLCP as onLCPWithAttribution,
-	onINP as onINPWithAttribution,
-	onCLS as onCLSWithAttribution,
+	onTTFB as onTTFBWithAttribution,
 } from 'web-vitals/attribution';
 
 export interface ElementData {
@@ -49,8 +49,21 @@
 export type OnINPWithAttributionFunction = typeof onINPWithAttribution;
 export type OnCLSWithAttributionFunction = typeof onCLSWithAttribution;
 
+export type LogFunction = ( ...message: any[] ) => void;
+
+export interface Logger {
+	log: LogFunction;
+	info: LogFunction;
+	warn: LogFunction;
+	error: LogFunction;
+}
+
 export type InitializeArgs = {
 	readonly isDebug: boolean;
+	readonly log: LogFunction;
+	readonly info: LogFunction;
+	readonly warn: LogFunction;
+	readonly error: LogFunction;
 	readonly onTTFB: OnTTFBFunction | OnTTFBWithAttributionFunction;
 	readonly onFCP: OnFCPFunction | OnFCPWithAttributionFunction;
 	readonly onLCP: OnLCPFunction | OnLCPWithAttributionFunction;
@@ -69,11 +82,16 @@
 		properties: ExtendedElementData
 	) => void;
 	readonly isDebug: boolean;
+	readonly log: LogFunction;
+	readonly info: LogFunction;
+	readonly warn: LogFunction;
+	readonly error: LogFunction;
 };
 
 export type FinalizeCallback = ( args: FinalizeArgs ) => Promise< void >;
 
 export interface Extension {
-	initialize?: InitializeCallback;
-	finalize?: FinalizeCallback;
+	readonly name?: string;
+	readonly initialize?: InitializeCallback;
+	readonly finalize?: FinalizeCallback;
 }

performance-lab

Note

No changes.

speculation-rules

Important

Stable tag change: 1.4.0 → 1.5.0

svn status:

!       helper.php
M       hooks.php
M       load.php
?       plugin-api.php
M       readme.txt
M       settings.php
M       uninstall.php
?       wp-core-api.php
svn diff
Index: hooks.php
===================================================================
--- hooks.php	(revision 3257264)
+++ hooks.php	(working copy)
@@ -12,48 +12,18 @@
 }
 // @codeCoverageIgnoreEnd
 
-/**
- * Prints the speculation rules.
- *
- * For browsers that do not support speculation rules yet, the `script[type="speculationrules"]` tag will be ignored.
- *
- * @since 1.0.0
- */
-function plsr_print_speculation_rules(): void {
-	// Skip speculative loading for logged-in users.
-	if ( is_user_logged_in() ) {
-		return;
-	}
+// Conditionally use either the WordPress Core API, or load the plugin's API implementation otherwise.
+if ( function_exists( 'wp_get_speculation_rules_configuration' ) ) {
+	require_once __DIR__ . '/wp-core-api.php';
 
-	// Skip speculative loading for sites without pretty permalinks, unless explicitly enabled.
-	if ( ! (bool) get_option( 'permalink_structure' ) ) {
-		/**
-		 * Filters whether speculative loading should be enabled even though the site does not use pretty permalinks.
-		 *
-		 * Since query parameters are commonly used by plugins for dynamic behavior that can change state, ideally any
-		 * such URLs are excluded from speculative loading. If the site does not use pretty permalinks though, they are
-		 * impossible to recognize. Therefore speculative loading is disabled by default for those sites.
-		 *
-		 * For site owners of sites without pretty permalinks that are certain their site is not using such a pattern,
-		 * this filter can be used to still enable speculative loading at their own risk.
-		 *
-		 * @since 1.4.0
-		 *
-		 * @param bool $enabled Whether speculative loading is enabled even without pretty permalinks.
-		 */
-		$enabled = (bool) apply_filters( 'plsr_enabled_without_pretty_permalinks', false );
+	add_filter( 'wp_speculation_rules_configuration', 'plsr_filter_speculation_rules_configuration' );
+	add_filter( 'wp_speculation_rules_href_exclude_paths', 'plsr_filter_speculation_rules_exclude_paths', 10, 2 );
+} else {
+	require_once __DIR__ . '/class-plsr-url-pattern-prefixer.php';
+	require_once __DIR__ . '/plugin-api.php';
 
-		if ( ! $enabled ) {
-			return;
-		}
-	}
-
-	wp_print_inline_script_tag(
-		(string) wp_json_encode( plsr_get_speculation_rules() ),
-		array( 'type' => 'speculationrules' )
-	);
+	add_action( 'wp_footer', 'plsr_print_speculation_rules' );
 }
-add_action( 'wp_footer', 'plsr_print_speculation_rules' );
 
 /**
  * Displays the HTML generator meta tag for the Speculative Loading plugin.
Index: load.php
===================================================================
--- load.php	(revision 3257264)
+++ load.php	(working copy)
@@ -5,7 +5,7 @@
  * Description: Enables browsers to speculatively prerender or prefetch pages to achieve near-instant loads based on user interaction.
  * Requires at least: 6.6
  * Requires PHP: 7.2
- * Version: 1.4.0
+ * Version: 1.5.0
  * Author: WordPress Performance Team
  * Author URI: https://make.wordpress.org/performance/
  * License: GPLv2 or later
@@ -19,7 +19,6 @@
 if ( ! defined( 'ABSPATH' ) ) {
 	exit; // Exit if accessed directly.
 }
-// @codeCoverageIgnoreEnd
 
 (
 	/**
@@ -66,7 +65,7 @@
 	}
 )(
 	'plsr_pending_plugin_info',
-	'1.4.0',
+	'1.5.0',
 	static function ( string $version ): void {
 
 		// Define the constant.
@@ -77,9 +76,8 @@
 		define( 'SPECULATION_RULES_VERSION', $version );
 		define( 'SPECULATION_RULES_MAIN_FILE', plugin_basename( __FILE__ ) );
 
-		require_once __DIR__ . '/class-plsr-url-pattern-prefixer.php';
-		require_once __DIR__ . '/helper.php';
 		require_once __DIR__ . '/hooks.php';
 		require_once __DIR__ . '/settings.php';
 	}
 );
+// @codeCoverageIgnoreEnd
Index: readme.txt
===================================================================
--- readme.txt	(revision 3257264)
+++ readme.txt	(working copy)
@@ -2,7 +2,7 @@
 
 Contributors: wordpressdotorg
 Tested up to: 6.7
-Stable tag:   1.4.0
+Stable tag:   1.5.0
 License:      GPLv2 or later
 License URI:  https://www.gnu.org/licenses/gpl-2.0.html
 Tags:         performance, javascript, speculation rules, prerender, prefetch
@@ -119,6 +119,12 @@
 
 == Changelog ==
 
+= 1.5.0 =
+
+**Enhancements**
+
+* Add support for Speculative Loading WP Core API, loading the plugin's own API implementation conditionally. ([1883](https://github.com/WordPress/performance/pull/1883))
+
 = 1.4.0 =
 
 **Enhancements**
Index: settings.php
===================================================================
--- settings.php	(revision 3257264)
+++ settings.php	(working copy)
@@ -224,7 +224,8 @@
 			$choices = plsr_get_eagerness_labels();
 			break;
 		default:
-			return; // Invalid (and this case should never occur).
+			// Invalid (and this case should never occur).
+			return; // @codeCoverageIgnore
 	}
 
 	$value = $option[ $args['field'] ];
Index: uninstall.php
===================================================================
--- uninstall.php	(revision 3257264)
+++ uninstall.php	(working copy)
@@ -8,7 +8,7 @@
 
 // If uninstall.php is not called by WordPress, bail.
 if ( ! defined( 'WP_UNINSTALL_PLUGIN' ) ) {
-	exit;
+	exit;// @codeCoverageIgnore
 }
 
 // For a multisite, delete the option for all sites (however limited to 100 sites to avoid memory limit or timeout problems in large scale networks).

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 3257264)
+++ 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,11 +25,11 @@
 	$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 ) {
-		$config['debug'] = true;
+		$config['debug'] = true;// @codeCoverageIgnore
 	}
 
 	/**
@@ -70,3 +72,129 @@
 	 */
 	return (array) apply_filters( 'plwwo_configuration', $config );
 }
+
+/**
+ * Registers defaults scripts for Web Worker Offloading.
+ *
+ * @since 0.1.0
+ * @access private
+ *
+ * @param WP_Scripts $scripts WP_Scripts instance.
+ */
+function plwwo_register_default_scripts( WP_Scripts $scripts ): void {
+	// The source code for partytown.js is built from <https://github.com/BuilderIO/partytown/blob/b292a14047a0c12ca05ba97df1833935d42fdb66/src/lib/main/snippet.ts>.
+	// See webpack config in the WordPress/performance repo: <https://github.com/WordPress/performance/blob/282a068f3eb2575d37aeb9034e894e7140fcddca/webpack.config.js#L84-L130>.
+	if ( defined( 'SCRIPT_DEBUG' ) && SCRIPT_DEBUG ) {
+		$partytown_js_path = '/build/debug/partytown.js';// @codeCoverageIgnore
+	} else {
+		$partytown_js_path = '/build/partytown.js';
+	}
+
+	$partytown_js = file_get_contents( __DIR__ . $partytown_js_path ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- It's a local filesystem path not a remote request.
+	if ( false === $partytown_js ) {
+		return;// @codeCoverageIgnore
+	}
+
+	$scripts->add(
+		'web-worker-offloading',
+		'',
+		array(),
+		WEB_WORKER_OFFLOADING_VERSION,
+		array( 'in_footer' => false )
+	);
+
+	$scripts->add_inline_script(
+		'web-worker-offloading',
+		sprintf(
+			'window.partytown = {...(window.partytown || {}), ...%s};',
+			wp_json_encode( plwwo_get_configuration() )
+		),
+		'before'
+	);
+
+	$scripts->add_inline_script( 'web-worker-offloading', $partytown_js );
+}
+
+/**
+ * Prepends web-worker-offloading to the list of scripts to print if one of the queued scripts is offloaded to a worker.
+ *
+ * @since 0.1.0
+ * @access private
+ *
+ * @param string[]|mixed $script_handles An array of enqueued script dependency handles.
+ * @return string[] Script handles.
+ */
+function plwwo_filter_print_scripts_array( $script_handles ): array {
+	$scripts = wp_scripts();
+	foreach ( (array) $script_handles as $handle ) {
+		if ( true === (bool) $scripts->get_data( $handle, 'worker' ) ) {
+			$scripts->set_group( 'web-worker-offloading', false, 0 ); // Try to print in the head.
+			array_unshift( $script_handles, 'web-worker-offloading' );
+			break;
+		}
+	}
+	return $script_handles;
+}
+
+/**
+ * Updates script type for handles having `web-worker-offloading` as dependency.
+ *
+ * @since 0.1.0
+ * @access private
+ *
+ * @param string|mixed $tag    Script tag.
+ * @param string       $handle Script handle.
+ * @return string|mixed Script tag with type="text/partytown" for eligible scripts.
+ */
+function plwwo_update_script_type( $tag, string $handle ) {
+	if (
+		is_string( $tag )
+		&&
+		(bool) wp_scripts()->get_data( $handle, 'worker' )
+	) {
+		$html_processor = new WP_HTML_Tag_Processor( $tag );
+		while ( $html_processor->next_tag( array( 'tag_name' => 'SCRIPT' ) ) ) {
+			if ( $html_processor->get_attribute( 'id' ) === "{$handle}-js" ) {
+				$html_processor->set_attribute( 'type', 'text/partytown' );
+				$tag = $html_processor->get_updated_html();
+				break;
+			}
+		}
+	}
+	return $tag;
+}
+
+/**
+ * Filters inline script attributes to offload to a worker if the script has been opted-in.
+ *
+ * @since 0.1.0
+ * @access private
+ *
+ * @param array<string, mixed>|mixed $attributes Attributes.
+ * @return array<string, mixed> Attributes.
+ */
+function plwwo_filter_inline_script_attributes( $attributes ): array {
+	$attributes = (array) $attributes;
+	if (
+		isset( $attributes['id'] )
+		&&
+		1 === preg_match( '/^(?P<handle>.+)-js-(?:before|after)$/', $attributes['id'], $matches )
+		&&
+		(bool) wp_scripts()->get_data( $matches['handle'], 'worker' )
+	) {
+		$attributes['type'] = 'text/partytown';
+	}
+	return $attributes;
+}
+
+/**
+ * Displays the HTML generator meta tag for the Web Worker Offloading plugin.
+ *
+ * See {@see 'wp_head'}.
+ *
+ * @since 0.1.1
+ */
+function plwwo_render_generator_meta_tag(): void {
+	// Use the plugin slug as it is immutable.
+	echo '<meta name="generator" content="web-worker-offloading ' . esc_attr( WEB_WORKER_OFFLOADING_VERSION ) . '">' . "\n";
+}
Index: hooks.php
===================================================================
--- hooks.php	(revision 3257264)
+++ hooks.php	(working copy)
@@ -6,137 +6,13 @@
  * @package web-worker-offloading
  */
 
+// @codeCoverageIgnoreStart
 if ( ! defined( 'ABSPATH' ) ) {
 	exit; // Exit if accessed directly.
 }
-
-/**
- * Registers defaults scripts for Web Worker Offloading.
- *
- * @since 0.1.0
- * @access private
- *
- * @param WP_Scripts $scripts WP_Scripts instance.
- */
-function plwwo_register_default_scripts( WP_Scripts $scripts ): void {
-	// The source code for partytown.js is built from <https://github.com/BuilderIO/partytown/blob/b292a14047a0c12ca05ba97df1833935d42fdb66/src/lib/main/snippet.ts>.
-	// See webpack config in the WordPress/performance repo: <https://github.com/WordPress/performance/blob/282a068f3eb2575d37aeb9034e894e7140fcddca/webpack.config.js#L84-L130>.
-	if ( defined( 'SCRIPT_DEBUG' ) && SCRIPT_DEBUG ) {
-		$partytown_js_path = '/build/debug/partytown.js';
-	} else {
-		$partytown_js_path = '/build/partytown.js';
-	}
-
-	$partytown_js = file_get_contents( __DIR__ . $partytown_js_path ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- It's a local filesystem path not a remote request.
-	if ( false === $partytown_js ) {
-		return;
-	}
-
-	$scripts->add(
-		'web-worker-offloading',
-		'',
-		array(),
-		WEB_WORKER_OFFLOADING_VERSION,
-		array( 'in_footer' => false )
-	);
-
-	$scripts->add_inline_script(
-		'web-worker-offloading',
-		sprintf(
-			'window.partytown = {...(window.partytown || {}), ...%s};',
-			wp_json_encode( plwwo_get_configuration() )
-		),
-		'before'
-	);
-
-	$scripts->add_inline_script( 'web-worker-offloading', $partytown_js );
-}
 add_action( 'wp_default_scripts', 'plwwo_register_default_scripts' );
-
-/**
- * Prepends web-worker-offloading to the list of scripts to print if one of the queued scripts is offloaded to a worker.
- *
- * @since 0.1.0
- * @access private
- *
- * @param string[]|mixed $script_handles An array of enqueued script dependency handles.
- * @return string[] Script handles.
- */
-function plwwo_filter_print_scripts_array( $script_handles ): array {
-	$scripts = wp_scripts();
-	foreach ( (array) $script_handles as $handle ) {
-		if ( true === (bool) $scripts->get_data( $handle, 'worker' ) ) {
-			$scripts->set_group( 'web-worker-offloading', false, 0 ); // Try to print in the head.
-			array_unshift( $script_handles, 'web-worker-offloading' );
-			break;
-		}
-	}
-	return $script_handles;
-}
 add_filter( 'print_scripts_array', 'plwwo_filter_print_scripts_array', PHP_INT_MAX );
-
-/**
- * Updates script type for handles having `web-worker-offloading` as dependency.
- *
- * @since 0.1.0
- * @access private
- *
- * @param string|mixed $tag    Script tag.
- * @param string       $handle Script handle.
- * @return string|mixed Script tag with type="text/partytown" for eligible scripts.
- */
-function plwwo_update_script_type( $tag, string $handle ) {
-	if (
-		is_string( $tag )
-		&&
-		(bool) wp_scripts()->get_data( $handle, 'worker' )
-	) {
-		$html_processor = new WP_HTML_Tag_Processor( $tag );
-		while ( $html_processor->next_tag( array( 'tag_name' => 'SCRIPT' ) ) ) {
-			if ( $html_processor->get_attribute( 'id' ) === "{$handle}-js" ) {
-				$html_processor->set_attribute( 'type', 'text/partytown' );
-				$tag = $html_processor->get_updated_html();
-				break;
-			}
-		}
-	}
-	return $tag;
-}
 add_filter( 'script_loader_tag', 'plwwo_update_script_type', 10, 2 );
-
-/**
- * Filters inline script attributes to offload to a worker if the script has been opted-in.
- *
- * @since 0.1.0
- * @access private
- *
- * @param array<string, mixed>|mixed $attributes Attributes.
- * @return array<string, mixed> Attributes.
- */
-function plwwo_filter_inline_script_attributes( $attributes ): array {
-	$attributes = (array) $attributes;
-	if (
-		isset( $attributes['id'] )
-		&&
-		1 === preg_match( '/^(?P<handle>.+)-js-(?:before|after)$/', $attributes['id'], $matches )
-		&&
-		(bool) wp_scripts()->get_data( $matches['handle'], 'worker' )
-	) {
-		$attributes['type'] = 'text/partytown';
-	}
-	return $attributes;
-}
 add_filter( 'wp_inline_script_attributes', 'plwwo_filter_inline_script_attributes' );
-
-/**
- * Displays the HTML generator meta tag for the Web Worker Offloading plugin.
- *
- * See {@see 'wp_head'}.
- *
- * @since 0.1.1
- */
-function plwwo_render_generator_meta_tag(): void {
-	// Use the plugin slug as it is immutable.
-	echo '<meta name="generator" content="web-worker-offloading ' . esc_attr( WEB_WORKER_OFFLOADING_VERSION ) . '">' . "\n";
-}
 add_action( 'wp_head', 'plwwo_render_generator_meta_tag' );
+// @codeCoverageIgnoreEnd
Index: load.php
===================================================================
--- load.php	(revision 3257264)
+++ load.php	(working copy)
@@ -15,9 +15,9 @@
  * @package web-worker-offloading
  */
 
-// Exit if accessed directly.
+// @codeCoverageIgnoreStart
 if ( ! defined( 'ABSPATH' ) ) {
-	exit;
+	exit; // Exit if accessed directly.
 }
 
 // Define the constant.
@@ -48,3 +48,4 @@
 require_once __DIR__ . '/helper.php';
 require_once __DIR__ . '/hooks.php';
 require_once __DIR__ . '/third-party.php';
+// @codeCoverageIgnoreEnd
Index: third-party/google-site-kit.php
===================================================================
--- third-party/google-site-kit.php	(revision 3257264)
+++ 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.
@@ -41,7 +43,7 @@
 
 	return $configuration;
 }
-add_filter( 'plwwo_configuration', 'plwwo_google_site_kit_configure' );
+add_filter( 'plwwo_configuration', 'plwwo_google_site_kit_configure' ); // @codeCoverageIgnore
 
 plwwo_mark_scripts_for_offloading(
 	array(
@@ -68,4 +70,4 @@
 	return $attributes;
 }
 
-add_filter( 'wp_inline_script_attributes', 'plwwo_google_site_kit_filter_inline_script_attributes' );
+add_filter( 'wp_inline_script_attributes', 'plwwo_google_site_kit_filter_inline_script_attributes' ); // @codeCoverageIgnore
Index: third-party/seo-by-rank-math.php
===================================================================
--- third-party/seo-by-rank-math.php	(revision 3257264)
+++ 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.
@@ -30,7 +32,7 @@
 	$configuration['forward'][] = 'gtag';
 	return $configuration;
 }
-add_filter( 'plwwo_configuration', 'plwwo_rank_math_configure' );
+add_filter( 'plwwo_configuration', 'plwwo_rank_math_configure' ); // @codeCoverageIgnore
 
 /*
  * Note: The following integration is not targeting the \RankMath\Analytics\GTag::enqueue_gtag_js() code which is only
@@ -58,7 +60,7 @@
 	return $attributes;
 }
 
-add_filter( 'wp_script_attributes', 'plwwo_rank_math_filter_script_attributes' );
+add_filter( 'wp_script_attributes', 'plwwo_rank_math_filter_script_attributes' ); // @codeCoverageIgnore
 
 /**
  * Filters inline script attributes to offload Rank Math's GTag script tag to Partytown.
@@ -78,4 +80,4 @@
 	return $attributes;
 }
 
-add_filter( 'wp_inline_script_attributes', 'plwwo_rank_math_filter_inline_script_attributes' );
+add_filter( 'wp_inline_script_attributes', 'plwwo_rank_math_filter_inline_script_attributes' ); // @codeCoverageIgnore
Index: third-party/woocommerce.php
===================================================================
--- third-party/woocommerce.php	(revision 3257264)
+++ 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.
@@ -31,7 +33,7 @@
 
 	return $configuration;
 }
-add_filter( 'plwwo_configuration', 'plwwo_woocommerce_configure' );
+add_filter( 'plwwo_configuration', 'plwwo_woocommerce_configure' ); // @codeCoverageIgnore
 
 plwwo_mark_scripts_for_offloading(
 	// Note: 'woocommerce-google-analytics-integration' is intentionally not included because for some reason events like add_to_cart don't get tracked.
Index: third-party.php
===================================================================
--- third-party.php	(revision 3257264)
+++ third-party.php	(working copy)
@@ -6,9 +6,11 @@
  * @package web-worker-offloading
  */
 
+// @codeCoverageIgnoreStart
 if ( ! defined( 'ABSPATH' ) ) {
 	exit; // Exit if accessed directly.
 }
+// @codeCoverageIgnoreEnd
 
 /**
  * Adds scripts to be offloaded to a worker.

webp-uploads

Important

Stable tag change: 2.5.0 → 2.5.1

svn status:

M       deprecated.php
M       helper.php
M       hooks.php
M       image-edit.php
M       load.php
M       picture-element.php
M       readme.txt
M       rest-api.php
svn diff
Index: deprecated.php
===================================================================
--- deprecated.php	(revision 3257264)
+++ 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 3257264)
+++ 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 );
 		}
 	}
@@ -82,11 +82,11 @@
  * @since 1.0.0
  * @access private
  *
- * @param int                                          $attachment_id         The ID of the attachment from where this image would be created.
- * @param string                                       $image_size            The size name that would be used to create the image source, out of the registered subsizes.
- * @param array{ width: int, height: int, crop: bool } $size_data             An array with the dimensions of the image: height, width and crop.
- * @param string                                       $mime                  The target mime in which the image should be created.
- * @param string|null                                  $destination_file_name The path where the file would be stored, including the extension. If null, `generate_filename` is used to create the destination file name.
+ * @param int                                                               $attachment_id         The ID of the attachment from where this image would be created.
+ * @param string                                                            $image_size            The size name that would be used to create the image source, out of the registered subsizes.
+ * @param array{ width: int, height: int, crop: bool|array{string, string}} $size_data             An array with the dimensions of the image: height, width and crop.
+ * @param string                                                            $mime                  The target mime in which the image should be created.
+ * @param string|null                                                       $destination_file_name The path where the file would be stored, including the extension. If null, `generate_filename` is used to create the destination file name.
  *
  * @return array{ file: string, filesize: int }|WP_Error An array with the file and filesize if the image was created correctly, otherwise a WP_Error.
  */
@@ -109,7 +109,7 @@
 	 * @param array{
 	 *            width: int,
 	 *            height: int,
-	 *            crop: bool
+	 *            crop: bool|array{string, string}
 	 *        }               $size_data     An array with the dimensions of the image.
 	 * @param string          $mime          The target mime in which the image should be created.
 	 */
@@ -156,7 +156,7 @@
 
 	$height = isset( $size_data['height'] ) ? (int) $size_data['height'] : 0;
 	$width  = isset( $size_data['width'] ) ? (int) $size_data['width'] : 0;
-	$crop   = isset( $size_data['crop'] ) && $size_data['crop'];
+	$crop   = isset( $size_data['crop'] ) ? $size_data['crop'] : false;
 	if ( $width <= 0 && $height <= 0 ) {
 		return new WP_Error( 'image_wrong_dimensions', __( 'At least one of the dimensions must be a positive number.', 'webp-uploads' ) );
 	}
@@ -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' ) );
 	}
 
@@ -241,7 +241,7 @@
 	}
 
 	if ( isset( $sizes[ $size ]['crop'] ) ) {
-		$size_data['crop'] = (bool) $sizes[ $size ]['crop'];
+		$size_data['crop'] = $sizes[ $size ]['crop'];
 	}
 
 	return webp_uploads_generate_additional_image_source( $attachment_id, $size, $size_data, $mime );
Index: hooks.php
===================================================================
--- hooks.php	(revision 3257264)
+++ 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 3257264)
+++ 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: load.php
===================================================================
--- load.php	(revision 3257264)
+++ load.php	(working copy)
@@ -5,7 +5,7 @@
  * Description: Converts images to more modern formats such as WebP or AVIF during upload.
  * Requires at least: 6.6
  * Requires PHP: 7.2
- * Version: 2.5.0
+ * Version: 2.5.1
  * Author: WordPress Performance Team
  * Author URI: https://make.wordpress.org/performance/
  * License: GPLv2 or later
@@ -26,7 +26,7 @@
 	return;
 }
 
-define( 'WEBP_UPLOADS_VERSION', '2.5.0' );
+define( 'WEBP_UPLOADS_VERSION', '2.5.1' );
 define( 'WEBP_UPLOADS_MAIN_FILE', plugin_basename( __FILE__ ) );
 
 require_once __DIR__ . '/helper.php';
Index: picture-element.php
===================================================================
--- picture-element.php	(revision 3257264)
+++ picture-element.php	(working copy)
@@ -41,14 +41,33 @@
 		array_unshift( $image_sizes, $image_meta );
 	}
 
+	// Extract sizes using regex to parse image tag, then use to retrieve tag.
+	$width     = 0;
+	$height    = 0;
+	$processor = new WP_HTML_Tag_Processor( $image );
+	if ( $processor->next_tag( array( 'tag_name' => 'IMG' ) ) ) {
+		$width  = (int) $processor->get_attribute( 'width' );
+		$height = (int) $processor->get_attribute( 'height' );
+	}
+	$size_to_use = ( $width > 0 && $height > 0 ) ? array( $width, $height ) : 'full';
+
+	$image_src = wp_get_attachment_image_src( $attachment_id, $size_to_use );
+	if ( false === $image_src ) {
+		return $image;
+	}
+	list( $src, $width, $height ) = $image_src;
+	$size_array                   = array( absint( $width ), absint( $height ) );
+
 	// Collect all the sub size image mime types.
 	$mime_type_data = array();
 	foreach ( $image_sizes as $size ) {
 		if ( isset( $size['sources'] ) && isset( $size['width'] ) && isset( $size['height'] ) ) {
 			foreach ( $size['sources'] as $mime_type => $data ) {
-				$mime_type_data[ $mime_type ]                         = $mime_type_data[ $mime_type ] ?? array();
-				$mime_type_data[ $mime_type ]['w'][ $size['width'] ]  = $data;
-				$mime_type_data[ $mime_type ]['h'][ $size['height'] ] = $data;
+				if ( wp_image_matches_ratio( $size_array[0], $size_array[1], $size['width'], $size['height'] ) ) {
+					$mime_type_data[ $mime_type ]                         = $mime_type_data[ $mime_type ] ?? array();
+					$mime_type_data[ $mime_type ]['w'][ $size['width'] ]  = $data;
+					$mime_type_data[ $mime_type ]['h'][ $size['height'] ] = $data;
+				}
 			}
 		}
 	}
@@ -97,23 +116,6 @@
 	// Add each mime type to the picture's sources.
 	$picture_sources = '';
 
-	// Extract sizes using regex to parse image tag, then use to retrieve tag.
-	$width     = 0;
-	$height    = 0;
-	$processor = new WP_HTML_Tag_Processor( $image );
-	if ( $processor->next_tag( array( 'tag_name' => 'IMG' ) ) ) {
-		$width  = (int) $processor->get_attribute( 'width' );
-		$height = (int) $processor->get_attribute( 'height' );
-	}
-	$size_to_use = ( $width > 0 && $height > 0 ) ? array( $width, $height ) : 'full';
-
-	$image_src = wp_get_attachment_image_src( $attachment_id, $size_to_use );
-	if ( false === $image_src ) {
-		return $image;
-	}
-	list( $src, $width, $height ) = $image_src;
-	$size_array                   = array( absint( $width ), absint( $height ) );
-
 	// Gets the srcset and sizes from the IMG tag.
 	$sizes  = $processor->get_attribute( 'sizes' );
 	$srcset = $processor->get_attribute( 'srcset' );
Index: readme.txt
===================================================================
--- readme.txt	(revision 3257264)
+++ readme.txt	(working copy)
@@ -2,7 +2,7 @@
 
 Contributors: wordpressdotorg
 Tested up to: 6.7
-Stable tag:   2.5.0
+Stable tag:   2.5.1
 License:      GPLv2 or later
 License URI:  https://www.gnu.org/licenses/gpl-2.0.html
 Tags:         performance, images, webp, avif, modern image formats
@@ -60,6 +60,13 @@
 
 == Changelog ==
 
+= 2.5.1 =
+
+**Bug Fixes**
+
+* Fix Modern Image Format not cropping image if crop is an array. ([1887](https://github.com/WordPress/performance/pull/1887))
+* Fix incorrect image size selection in `PICTURE` element. ([1885](https://github.com/WordPress/performance/pull/1885))
+
 = 2.5.0 =
 
 **Enhancements**
Index: rest-api.php
===================================================================
--- rest-api.php	(revision 3257264)
+++ 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 westonruter marked this pull request as ready for review March 17, 2025 16:03
@github-actions
Copy link

github-actions bot commented Mar 17, 2025

The following accounts have interacted with this PR and/or linked issues. I will continue to update these lists as activity occurs. You can also manually ask me to refresh this list by adding the props-bot label.

If you're merging code through a pull request on GitHub, copy and paste the following into the bottom of the merge commit message.

Co-authored-by: westonruter <[email protected]>
Co-authored-by: felixarntz <[email protected]>
Co-authored-by: b1ink0 <[email protected]>

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

Copy link
Member

@felixarntz felixarntz left a comment

Choose a reason for hiding this comment

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

@westonruter Mostly looks good, just a few notes regarding docs.

Copy link
Member

@felixarntz felixarntz left a comment

Choose a reason for hiding this comment

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

Thanks @westonruter!

@westonruter westonruter enabled auto-merge March 17, 2025 17:16
@westonruter westonruter requested a review from b1ink0 March 17, 2025 17:44
@westonruter westonruter merged commit 0d0ca8b into release/2025-03-17 Mar 17, 2025
17 checks passed
@westonruter westonruter deleted the publish/2025-03-17 branch March 17, 2025 17:45
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 no milestone PRs that do not have a defined milestone for release 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.

4 participants