Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Prepare 3.6.0 release #1657

Merged
merged 8 commits into from
Nov 18, 2024
Merged

Prepare 3.6.0 release #1657

merged 8 commits into from
Nov 18, 2024

Conversation

westonruter
Copy link
Member

@westonruter westonruter commented Nov 16, 2024

See #1645
Previously #1609

  • Bump versions for 3.6.0 release
  • Run npm run since
  • Run npm run readme

The following plugins are included in this release:

  1. performance-lab 3.6.0
  2. optimization-detective v0.8.0
  3. webp-uploads v2.3.0

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

westonruter commented Nov 16, 2024

@westonruter
Copy link
Member Author

westonruter commented Nov 16, 2024

Pending release diffs, only including plugins pending release, generated via:

npm run generate-pending-release-diffs --silent performance-lab optimization-detective webp-uploads

performance-lab

Important

Stable tag change: 3.5.1 → 3.6.0

svn status:

M       includes/admin/load.php
?       includes/admin/plugin-activate-ajax.js
M       includes/admin/plugins.php
?       includes/admin/rest-api.php
?       includes/site-health/avif-headers
M       includes/site-health/load.php
M       load.php
M       readme.txt
svn diff
Index: includes/admin/load.php
===================================================================
--- includes/admin/load.php	(revision 3190458)
+++ includes/admin/load.php	(working copy)
@@ -49,9 +49,6 @@
 
 	// Handle style for settings page.
 	add_action( 'admin_head', 'perflab_print_features_page_style' );
-
-	// Handle script for settings page.
-	add_action( 'admin_footer', 'perflab_print_plugin_progress_indicator_script' );
 }
 
 /**
@@ -228,8 +225,14 @@
 	wp_enqueue_style( 'thickbox' );
 	wp_enqueue_script( 'plugin-install' );
 
-	// Enqueue the a11y script.
-	wp_enqueue_script( 'wp-a11y' );
+	// Enqueue plugin activate AJAX script and localize script data.
+	wp_enqueue_script(
+		'perflab-plugin-activate-ajax',
+		plugin_dir_url( PERFLAB_MAIN_FILE ) . 'includes/admin/plugin-activate-ajax.js',
+		array( 'wp-i18n', 'wp-a11y', 'wp-api-fetch' ),
+		PERFLAB_VERSION,
+		true
+	);
 }
 
 /**
@@ -397,42 +400,6 @@
 }
 
 /**
- * Callback function that print plugin progress indicator script.
- *
- * @since 3.1.0
- */
-function perflab_print_plugin_progress_indicator_script(): void {
-	$js_function = <<<JS
-		function addPluginProgressIndicator( message ) {
-			document.addEventListener( 'DOMContentLoaded', function () {
-				document.addEventListener( 'click', function ( event ) {
-					if (
-						event.target.classList.contains(
-							'perflab-install-active-plugin'
-						)
-					) {
-						const target = event.target;
-						target.classList.add( 'updating-message' );
-						target.textContent = message;
-
-						wp.a11y.speak( message );
-					}
-				} );
-			} );
-		}
-JS;
-
-	wp_print_inline_script_tag(
-		sprintf(
-			'( %s )( %s );',
-			$js_function,
-			wp_json_encode( __( 'Activating...', 'default' ) )
-		),
-		array( 'type' => 'module' )
-	);
-}
-
-/**
  * Gets the URL to the plugin settings screen if one exists.
  *
  * @since 3.1.0
Index: includes/admin/plugins.php
===================================================================
--- includes/admin/plugins.php	(revision 3190458)
+++ includes/admin/plugins.php	(working copy)
@@ -19,13 +19,16 @@
  * @return array{name: string, slug: string, short_description: string, requires: string|false, requires_php: string|false, requires_plugins: string[], download_link: string, version: string}|WP_Error Array of plugin data or WP_Error if failed.
  */
 function perflab_query_plugin_info( string $plugin_slug ) {
-	$transient_key = 'perflab_plugins_info-v2';
+	$transient_key = 'perflab_plugins_info';
 	$plugins       = get_transient( $transient_key );
 
 	if ( is_array( $plugins ) ) {
 		// If the specific plugin_slug is not in the cache, return an error.
 		if ( ! isset( $plugins[ $plugin_slug ] ) ) {
-			return new WP_Error( 'plugin_not_found', __( 'Plugin not found.', 'performance-lab' ) );
+			return new WP_Error(
+				'plugin_not_found',
+				__( 'Plugin not found in cached API response.', 'performance-lab' )
+			);
 		}
 		return $plugins[ $plugin_slug ]; // Return cached plugin info if found.
 	}
@@ -71,7 +74,7 @@
 	$plugins            = array();
 	$standalone_plugins = array_merge(
 		array_flip( perflab_get_standalone_plugins() ),
-		array( 'optimization-detective' => array() ) // TODO: Programmatically discover the plugin dependencies and add them here.
+		array( 'optimization-detective' => array() ) // TODO: Programmatically discover the plugin dependencies and add them here. See <https://github.com/WordPress/performance/issues/1616>.
 	);
 	foreach ( $response->plugins as $plugin_data ) {
 		if ( ! isset( $standalone_plugins[ $plugin_data['slug'] ] ) ) {
@@ -83,7 +86,10 @@
 	set_transient( $transient_key, $plugins, HOUR_IN_SECONDS );
 
 	if ( ! isset( $plugins[ $plugin_slug ] ) ) {
-		return new WP_Error( 'plugin_not_found', __( 'Plugin not found.', 'performance-lab' ) );
+		return new WP_Error(
+			'plugin_not_found',
+			__( 'Plugin not found in API response.', 'performance-lab' )
+		);
 	}
 
 	/**
@@ -324,6 +330,11 @@
 		return $plugin_data;
 	}
 
+	// Add recommended plugins (soft dependencies) to the list of plugins installed and activated.
+	if ( 'embed-optimizer' === $plugin_slug ) {
+		$plugin_data['requires_plugins'][] = 'optimization-detective';
+	}
+
 	// Install and activate plugin dependencies first.
 	foreach ( $plugin_data['requires_plugins'] as $requires_plugin_slug ) {
 		$result = perflab_install_and_activate_plugin( $requires_plugin_slug );
@@ -354,8 +365,11 @@
 		}
 
 		$plugins = get_plugins( '/' . $plugin_slug );
-		if ( empty( $plugins ) ) {
-			return new WP_Error( 'plugin_not_found', __( 'Plugin not found.', 'default' ) );
+		if ( count( $plugins ) === 0 ) {
+			return new WP_Error(
+				'plugin_not_found',
+				__( 'Plugin not found among installed plugins.', 'performance-lab' )
+			);
 		}
 
 		$plugin_file_names = array_keys( $plugins );
@@ -426,8 +440,9 @@
 		);
 
 		$action_links[] = sprintf(
-			'<a class="button perflab-install-active-plugin" href="%s">%s</a>',
+			'<a class="button perflab-install-active-plugin" href="%s" data-plugin-slug="%s">%s</a>',
 			esc_url( $url ),
+			esc_attr( $plugin_data['slug'] ),
 			esc_html__( 'Activate', 'default' )
 		);
 	} else {
Index: includes/site-health/load.php
===================================================================
--- includes/site-health/load.php	(revision 3190458)
+++ includes/site-health/load.php	(working copy)
@@ -25,3 +25,7 @@
 // AVIF Support site health check.
 require_once __DIR__ . '/avif-support/helper.php';
 require_once __DIR__ . '/avif-support/hooks.php';
+
+// AVIF headers site health check.
+require_once __DIR__ . '/avif-headers/helper.php';
+require_once __DIR__ . '/avif-headers/hooks.php';
Index: load.php
===================================================================
--- load.php	(revision 3190458)
+++ load.php	(working copy)
@@ -5,7 +5,7 @@
  * Description: Performance plugin from the WordPress Performance Team, which is a collection of standalone performance features.
  * Requires at least: 6.5
  * Requires PHP: 7.2
- * Version: 3.5.1
+ * Version: 3.6.0
  * Author: WordPress Performance Team
  * Author URI: https://make.wordpress.org/performance/
  * License: GPLv2 or later
@@ -19,7 +19,7 @@
 	exit; // Exit if accessed directly.
 }
 
-define( 'PERFLAB_VERSION', '3.5.1' );
+define( 'PERFLAB_VERSION', '3.6.0' );
 define( 'PERFLAB_MAIN_FILE', __FILE__ );
 define( 'PERFLAB_PLUGIN_DIR_PATH', plugin_dir_path( PERFLAB_MAIN_FILE ) );
 define( 'PERFLAB_SCREEN', 'performance-lab' );
@@ -339,3 +339,6 @@
 	require_once PERFLAB_PLUGIN_DIR_PATH . 'includes/admin/server-timing.php';
 	require_once PERFLAB_PLUGIN_DIR_PATH . 'includes/admin/plugins.php';
 }
+
+// Load REST API.
+require_once PERFLAB_PLUGIN_DIR_PATH . 'includes/admin/rest-api.php';
Index: readme.txt
===================================================================
--- readme.txt	(revision 3190458)
+++ readme.txt	(working copy)
@@ -2,7 +2,7 @@
 
 Contributors: wordpressdotorg
 Tested up to: 6.7
-Stable tag:   3.5.1
+Stable tag:   3.6.0
 License:      GPLv2 or later
 License URI:  https://www.gnu.org/licenses/gpl-2.0.html
 Tags:         performance, site health, measurement, optimization, diagnostics
@@ -71,6 +71,18 @@
 
 == Changelog ==
 
+= 3.6.0 =
+
+**Enhancements**
+
+* Use AJAX for activating features / plugins in Performance Lab. ([1646](https://github.com/WordPress/performance/pull/1646))
+* Introduce AVIF header health check. ([1612](https://github.com/WordPress/performance/pull/1612))
+* Install and activate Optimization Detective when the Embed Optimizer feature is activated from the Performance screen. ([1644](https://github.com/WordPress/performance/pull/1644))
+
+**Bug Fixes**
+
+* Fix uses of 'Plugin not found' string. ([1651](https://github.com/WordPress/performance/pull/1651))
+
 = 3.5.1 =
 
 **Bug Fixes**

optimization-detective

Important

Stable tag change: 0.7.0 → 0.8.0

svn status:

M       build/web-vitals.js
M       class-od-html-tag-processor.php
M       class-od-url-metric.php
M       detect.js
?       detect.min.js
M       detection.php
M       optimization.php
M       readme.txt
M       storage/class-od-url-metrics-post-type.php
M       storage/data.php
M       storage/rest-api.php
svn diff
Index: build/web-vitals.js
===================================================================
--- build/web-vitals.js	(revision 3190458)
+++ build/web-vitals.js	(working copy)
@@ -1 +1 @@
-var e,n,t,r,i,o=-1,a=function(e){addEventListener("pageshow",(function(n){n.persisted&&(o=n.timeStamp,e(n))}),!0)},c=function(){var e=self.performance&&performance.getEntriesByType&&performance.getEntriesByType("navigation")[0];if(e&&e.responseStart>0&&e.responseStart<performance.now())return e},u=function(){var e=c();return e&&e.activationStart||0},f=function(e,n){var t=c(),r="navigate";return o>=0?r="back-forward-cache":t&&(document.prerendering||u()>0?r="prerender":document.wasDiscarded?r="restore":t.type&&(r=t.type.replace(/_/g,"-"))),{name:e,value:void 0===n?-1:n,rating:"good",delta:0,entries:[],id:"v4-".concat(Date.now(),"-").concat(Math.floor(8999999999999*Math.random())+1e12),navigationType:r}},s=function(e,n,t){try{if(PerformanceObserver.supportedEntryTypes.includes(e)){var r=new PerformanceObserver((function(e){Promise.resolve().then((function(){n(e.getEntries())}))}));return r.observe(Object.assign({type:e,buffered:!0},t||{})),r}}catch(e){}},d=function(e,n,t,r){var i,a;return function(o){n.value>=0&&(o||r)&&((a=n.value-(i||0))||void 0===i)&&(i=n.value,n.delta=a,n.rating=function(e,n){return e>n[1]?"poor":e>n[0]?"needs-improvement":"good"}(n.value,t),e(n))}},l=function(e){requestAnimationFrame((function(){return requestAnimationFrame((function(){return e()}))}))},p=function(e){document.addEventListener("visibilitychange",(function(){"hidden"===document.visibilityState&&e()}))},v=function(e){var n=!1;return function(){n||(e(),n=!0)}},m=-1,h=function(){return"hidden"!==document.visibilityState||document.prerendering?1/0:0},g=function(e){"hidden"===document.visibilityState&&m>-1&&(m="visibilitychange"===e.type?e.timeStamp:0,T())},y=function(){addEventListener("visibilitychange",g,!0),addEventListener("prerenderingchange",g,!0)},T=function(){removeEventListener("visibilitychange",g,!0),removeEventListener("prerenderingchange",g,!0)},E=function(){return m<0&&(m=h(),y(),a((function(){setTimeout((function(){m=h(),y()}),0)}))),{get firstHiddenTime(){return m}}},C=function(e){document.prerendering?addEventListener("prerenderingchange",(function(){return e()}),!0):e()},b=[1800,3e3],S=function(e,n){n=n||{},C((function(){var t,r=E(),i=f("FCP"),o=s("paint",(function(e){e.forEach((function(e){"first-contentful-paint"===e.name&&(o.disconnect(),e.startTime<r.firstHiddenTime&&(i.value=Math.max(e.startTime-u(),0),i.entries.push(e),t(!0)))}))}));o&&(t=d(e,i,b,n.reportAllChanges),a((function(r){i=f("FCP"),t=d(e,i,b,n.reportAllChanges),l((function(){i.value=performance.now()-r.timeStamp,t(!0)}))})))}))},L=[.1,.25],w=function(e,n){n=n||{},S(v((function(){var t,r=f("CLS",0),i=0,o=[],c=function(e){e.forEach((function(e){if(!e.hadRecentInput){var n=o[0],t=o[o.length-1];i&&e.startTime-t.startTime<1e3&&e.startTime-n.startTime<5e3?(i+=e.value,o.push(e)):(i=e.value,o=[e])}})),i>r.value&&(r.value=i,r.entries=o,t())},u=s("layout-shift",c);u&&(t=d(e,r,L,n.reportAllChanges),p((function(){c(u.takeRecords()),t(!0)})),a((function(){i=0,r=f("CLS",0),t=d(e,r,L,n.reportAllChanges),l((function(){return t()}))})),setTimeout(t,0))})))},A=0,I=1/0,P=0,M=function(e){e.forEach((function(e){e.interactionId&&(I=Math.min(I,e.interactionId),P=Math.max(P,e.interactionId),A=P?(P-I)/7+1:0)}))},k=function(){return e?A:performance.interactionCount||0},F=function(){"interactionCount"in performance||e||(e=s("event",M,{type:"event",buffered:!0,durationThreshold:0}))},D=[],x=new Map,R=0,B=function(){var e=Math.min(D.length-1,Math.floor((k()-R)/50));return D[e]},H=[],q=function(e){if(H.forEach((function(n){return n(e)})),e.interactionId||"first-input"===e.entryType){var n=D[D.length-1],t=x.get(e.interactionId);if(t||D.length<10||e.duration>n.latency){if(t)e.duration>t.latency?(t.entries=[e],t.latency=e.duration):e.duration===t.latency&&e.startTime===t.entries[0].startTime&&t.entries.push(e);else{var r={id:e.interactionId,latency:e.duration,entries:[e]};x.set(r.id,r),D.push(r)}D.sort((function(e,n){return n.latency-e.latency})),D.length>10&&D.splice(10).forEach((function(e){return x.delete(e.id)}))}}},O=function(e){var n=self.requestIdleCallback||self.setTimeout,t=-1;return e=v(e),"hidden"===document.visibilityState?e():(t=n(e),p(e)),t},N=[200,500],j=function(e,n){"PerformanceEventTiming"in self&&"interactionId"in PerformanceEventTiming.prototype&&(n=n||{},C((function(){var t;F();var r,i=f("INP"),o=function(e){O((function(){e.forEach(q);var n=B();n&&n.latency!==i.value&&(i.value=n.latency,i.entries=n.entries,r())}))},c=s("event",o,{durationThreshold:null!==(t=n.durationThreshold)&&void 0!==t?t:40});r=d(e,i,N,n.reportAllChanges),c&&(c.observe({type:"first-input",buffered:!0}),p((function(){o(c.takeRecords()),r(!0)})),a((function(){R=k(),D.length=0,x.clear(),i=f("INP"),r=d(e,i,N,n.reportAllChanges)})))})))},_=[2500,4e3],z={},G=function(e,n){n=n||{},C((function(){var t,r=E(),i=f("LCP"),o=function(e){n.reportAllChanges||(e=e.slice(-1)),e.forEach((function(e){e.startTime<r.firstHiddenTime&&(i.value=Math.max(e.startTime-u(),0),i.entries=[e],t())}))},c=s("largest-contentful-paint",o);if(c){t=d(e,i,_,n.reportAllChanges);var m=v((function(){z[i.id]||(o(c.takeRecords()),c.disconnect(),z[i.id]=!0,t(!0))}));["keydown","click"].forEach((function(e){addEventListener(e,(function(){return O(m)}),!0)})),p(m),a((function(r){i=f("LCP"),t=d(e,i,_,n.reportAllChanges),l((function(){i.value=performance.now()-r.timeStamp,z[i.id]=!0,t(!0)}))}))}}))},J=[800,1800],K=function e(n){document.prerendering?C((function(){return e(n)})):"complete"!==document.readyState?addEventListener("load",(function(){return e(n)}),!0):setTimeout(n,0)},Q=function(e,n){n=n||{};var t=f("TTFB"),r=d(e,t,J,n.reportAllChanges);K((function(){var i=c();i&&(t.value=Math.max(i.responseStart-u(),0),t.entries=[i],r(!0),a((function(){t=f("TTFB",0),(r=d(e,t,J,n.reportAllChanges))(!0)})))}))},U={passive:!0,capture:!0},V=new Date,W=function(e,i){n||(n=i,t=e,r=new Date,Z(removeEventListener),X())},X=function(){if(t>=0&&t<r-V){var e={entryType:"first-input",name:n.type,target:n.target,cancelable:n.cancelable,startTime:n.timeStamp,processingStart:n.timeStamp+t};i.forEach((function(n){n(e)})),i=[]}},Y=function(e){if(e.cancelable){var n=(e.timeStamp>1e12?new Date:performance.now())-e.timeStamp;"pointerdown"==e.type?function(e,n){var t=function(){W(e,n),i()},r=function(){i()},i=function(){removeEventListener("pointerup",t,U),removeEventListener("pointercancel",r,U)};addEventListener("pointerup",t,U),addEventListener("pointercancel",r,U)}(n,e):W(n,e)}},Z=function(e){["mousedown","keydown","touchstart","pointerdown"].forEach((function(n){return e(n,Y,U)}))},$=[100,300],ee=function(e,r){r=r||{},C((function(){var o,c=E(),u=f("FID"),l=function(e){e.startTime<c.firstHiddenTime&&(u.value=e.processingStart-e.startTime,u.entries.push(e),o(!0))},m=function(e){e.forEach(l)},h=s("first-input",m);o=d(e,u,$,r.reportAllChanges),h&&(p(v((function(){m(h.takeRecords()),h.disconnect()}))),a((function(){var a;u=f("FID"),o=d(e,u,$,r.reportAllChanges),i=[],t=-1,n=null,Z(addEventListener),a=l,i.push(a),X()})))}))};export{L as CLSThresholds,b as FCPThresholds,$ as FIDThresholds,N as INPThresholds,_ as LCPThresholds,J as TTFBThresholds,w as onCLS,S as onFCP,ee as onFID,j as onINP,G as onLCP,Q as onTTFB};
\ No newline at end of file
+var e,n,t,r,i,o=-1,a=function(e){addEventListener("pageshow",(function(n){n.persisted&&(o=n.timeStamp,e(n))}),!0)},c=function(){var e=self.performance&&performance.getEntriesByType&&performance.getEntriesByType("navigation")[0];if(e&&e.responseStart>0&&e.responseStart<performance.now())return e},u=function(){var e=c();return e&&e.activationStart||0},f=function(e,n){var t=c(),r="navigate";o>=0?r="back-forward-cache":t&&(document.prerendering||u()>0?r="prerender":document.wasDiscarded?r="restore":t.type&&(r=t.type.replace(/_/g,"-")));return{name:e,value:void 0===n?-1:n,rating:"good",delta:0,entries:[],id:"v4-".concat(Date.now(),"-").concat(Math.floor(8999999999999*Math.random())+1e12),navigationType:r}},s=function(e,n,t){try{if(PerformanceObserver.supportedEntryTypes.includes(e)){var r=new PerformanceObserver((function(e){Promise.resolve().then((function(){n(e.getEntries())}))}));return r.observe(Object.assign({type:e,buffered:!0},t||{})),r}}catch(e){}},d=function(e,n,t,r){var i,o;return function(a){n.value>=0&&(a||r)&&((o=n.value-(i||0))||void 0===i)&&(i=n.value,n.delta=o,n.rating=function(e,n){return e>n[1]?"poor":e>n[0]?"needs-improvement":"good"}(n.value,t),e(n))}},l=function(e){requestAnimationFrame((function(){return requestAnimationFrame((function(){return e()}))}))},p=function(e){document.addEventListener("visibilitychange",(function(){"hidden"===document.visibilityState&&e()}))},v=function(e){var n=!1;return function(){n||(e(),n=!0)}},m=-1,h=function(){return"hidden"!==document.visibilityState||document.prerendering?1/0:0},g=function(e){"hidden"===document.visibilityState&&m>-1&&(m="visibilitychange"===e.type?e.timeStamp:0,T())},y=function(){addEventListener("visibilitychange",g,!0),addEventListener("prerenderingchange",g,!0)},T=function(){removeEventListener("visibilitychange",g,!0),removeEventListener("prerenderingchange",g,!0)},E=function(){return m<0&&(m=h(),y(),a((function(){setTimeout((function(){m=h(),y()}),0)}))),{get firstHiddenTime(){return m}}},C=function(e){document.prerendering?addEventListener("prerenderingchange",(function(){return e()}),!0):e()},b=[1800,3e3],S=function(e,n){n=n||{},C((function(){var t,r=E(),i=f("FCP"),o=s("paint",(function(e){e.forEach((function(e){"first-contentful-paint"===e.name&&(o.disconnect(),e.startTime<r.firstHiddenTime&&(i.value=Math.max(e.startTime-u(),0),i.entries.push(e),t(!0)))}))}));o&&(t=d(e,i,b,n.reportAllChanges),a((function(r){i=f("FCP"),t=d(e,i,b,n.reportAllChanges),l((function(){i.value=performance.now()-r.timeStamp,t(!0)}))})))}))},L=[.1,.25],w=function(e,n){n=n||{},S(v((function(){var t,r=f("CLS",0),i=0,o=[],c=function(e){e.forEach((function(e){if(!e.hadRecentInput){var n=o[0],t=o[o.length-1];i&&e.startTime-t.startTime<1e3&&e.startTime-n.startTime<5e3?(i+=e.value,o.push(e)):(i=e.value,o=[e])}})),i>r.value&&(r.value=i,r.entries=o,t())},u=s("layout-shift",c);u&&(t=d(e,r,L,n.reportAllChanges),p((function(){c(u.takeRecords()),t(!0)})),a((function(){i=0,r=f("CLS",0),t=d(e,r,L,n.reportAllChanges),l((function(){return t()}))})),setTimeout(t,0))})))},A=0,I=1/0,P=0,M=function(e){e.forEach((function(e){e.interactionId&&(I=Math.min(I,e.interactionId),P=Math.max(P,e.interactionId),A=P?(P-I)/7+1:0)}))},k=function(){return e?A:performance.interactionCount||0},F=function(){"interactionCount"in performance||e||(e=s("event",M,{type:"event",buffered:!0,durationThreshold:0}))},D=[],x=new Map,R=0,B=function(){var e=Math.min(D.length-1,Math.floor((k()-R)/50));return D[e]},H=[],q=function(e){if(H.forEach((function(n){return n(e)})),e.interactionId||"first-input"===e.entryType){var n=D[D.length-1],t=x.get(e.interactionId);if(t||D.length<10||e.duration>n.latency){if(t)e.duration>t.latency?(t.entries=[e],t.latency=e.duration):e.duration===t.latency&&e.startTime===t.entries[0].startTime&&t.entries.push(e);else{var r={id:e.interactionId,latency:e.duration,entries:[e]};x.set(r.id,r),D.push(r)}D.sort((function(e,n){return n.latency-e.latency})),D.length>10&&D.splice(10).forEach((function(e){return x.delete(e.id)}))}}},O=function(e){var n=self.requestIdleCallback||self.setTimeout,t=-1;return e=v(e),"hidden"===document.visibilityState?e():(t=n(e),p(e)),t},N=[200,500],j=function(e,n){"PerformanceEventTiming"in self&&"interactionId"in PerformanceEventTiming.prototype&&(n=n||{},C((function(){var t;F();var r,i=f("INP"),o=function(e){O((function(){e.forEach(q);var n=B();n&&n.latency!==i.value&&(i.value=n.latency,i.entries=n.entries,r())}))},c=s("event",o,{durationThreshold:null!==(t=n.durationThreshold)&&void 0!==t?t:40});r=d(e,i,N,n.reportAllChanges),c&&(c.observe({type:"first-input",buffered:!0}),p((function(){o(c.takeRecords()),r(!0)})),a((function(){R=k(),D.length=0,x.clear(),i=f("INP"),r=d(e,i,N,n.reportAllChanges)})))})))},_=[2500,4e3],z={},G=function(e,n){n=n||{},C((function(){var t,r=E(),i=f("LCP"),o=function(e){n.reportAllChanges||(e=e.slice(-1)),e.forEach((function(e){e.startTime<r.firstHiddenTime&&(i.value=Math.max(e.startTime-u(),0),i.entries=[e],t())}))},c=s("largest-contentful-paint",o);if(c){t=d(e,i,_,n.reportAllChanges);var m=v((function(){z[i.id]||(o(c.takeRecords()),c.disconnect(),z[i.id]=!0,t(!0))}));["keydown","click"].forEach((function(e){addEventListener(e,(function(){return O(m)}),{once:!0,capture:!0})})),p(m),a((function(r){i=f("LCP"),t=d(e,i,_,n.reportAllChanges),l((function(){i.value=performance.now()-r.timeStamp,z[i.id]=!0,t(!0)}))}))}}))},J=[800,1800],K=function e(n){document.prerendering?C((function(){return e(n)})):"complete"!==document.readyState?addEventListener("load",(function(){return e(n)}),!0):setTimeout(n,0)},Q=function(e,n){n=n||{};var t=f("TTFB"),r=d(e,t,J,n.reportAllChanges);K((function(){var i=c();i&&(t.value=Math.max(i.responseStart-u(),0),t.entries=[i],r(!0),a((function(){t=f("TTFB",0),(r=d(e,t,J,n.reportAllChanges))(!0)})))}))},U={passive:!0,capture:!0},V=new Date,W=function(e,i){n||(n=i,t=e,r=new Date,Z(removeEventListener),X())},X=function(){if(t>=0&&t<r-V){var e={entryType:"first-input",name:n.type,target:n.target,cancelable:n.cancelable,startTime:n.timeStamp,processingStart:n.timeStamp+t};i.forEach((function(n){n(e)})),i=[]}},Y=function(e){if(e.cancelable){var n=(e.timeStamp>1e12?new Date:performance.now())-e.timeStamp;"pointerdown"==e.type?function(e,n){var t=function(){W(e,n),i()},r=function(){i()},i=function(){removeEventListener("pointerup",t,U),removeEventListener("pointercancel",r,U)};addEventListener("pointerup",t,U),addEventListener("pointercancel",r,U)}(n,e):W(n,e)}},Z=function(e){["mousedown","keydown","touchstart","pointerdown"].forEach((function(n){return e(n,Y,U)}))},$=[100,300],ee=function(e,r){r=r||{},C((function(){var o,c=E(),u=f("FID"),l=function(e){e.startTime<c.firstHiddenTime&&(u.value=e.processingStart-e.startTime,u.entries.push(e),o(!0))},m=function(e){e.forEach(l)},h=s("first-input",m);o=d(e,u,$,r.reportAllChanges),h&&(p(v((function(){m(h.takeRecords()),h.disconnect()}))),a((function(){var a;u=f("FID"),o=d(e,u,$,r.reportAllChanges),i=[],t=-1,n=null,Z(addEventListener),a=l,i.push(a),X()})))}))};export{L as CLSThresholds,b as FCPThresholds,$ as FIDThresholds,N as INPThresholds,_ as LCPThresholds,J as TTFBThresholds,w as onCLS,S as onFCP,ee as onFID,j as onINP,G as onLCP,Q as onTTFB};
Index: class-od-html-tag-processor.php
===================================================================
--- class-od-html-tag-processor.php	(revision 3190458)
+++ class-od-html-tag-processor.php	(working copy)
@@ -625,6 +625,8 @@
 	 *
 	 * @since 0.4.0
 	 *
+	 * @phpstan-param callable-string $function_name
+	 *
 	 * @param string $function_name Function name.
 	 * @param string $message       Warning message.
 	 */
Index: class-od-url-metric.php
===================================================================
--- class-od-url-metric.php	(revision 3190458)
+++ class-od-url-metric.php	(working copy)
@@ -78,7 +78,7 @@
 	 *
 	 * @throws OD_Data_Validation_Exception When the input is invalid.
 	 *
-	 * @param array<string, mixed> $data URL metric data.
+	 * @param array<string, mixed> $data URL Metric data.
 	 */
 	public function __construct( array $data ) {
 		if ( ! isset( $data['uuid'] ) ) {
@@ -125,7 +125,7 @@
 	}
 
 	/**
-	 * 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 (which may not be any).
 	 *
 	 * @since 0.7.0
 	 *
@@ -136,7 +136,7 @@
 	}
 
 	/**
-	 * Sets the group that this URL metric is a part of.
+	 * Sets the group that this URL Metric is a part of.
 	 *
 	 * @since 0.7.0
 	 *
@@ -154,6 +154,8 @@
 	/**
 	 * Gets JSON schema for URL Metric.
 	 *
+	 * @since 0.1.0
+	 *
 	 * @todo Cache the return value?
 	 *
 	 * @return array<string, mixed> Schema.
@@ -200,7 +202,7 @@
 			'required'             => true,
 			'properties'           => array(
 				'uuid'      => array(
-					'description' => __( 'The UUID for the URL metric.', 'optimization-detective' ),
+					'description' => __( 'The UUID for the URL Metric.', 'optimization-detective' ),
 					'type'        => 'string',
 					'format'      => 'uuid',
 					'required'    => true,
@@ -232,7 +234,7 @@
 					'additionalProperties' => false,
 				),
 				'timestamp' => array(
-					'description' => __( 'Timestamp at which the URL metric was captured.', 'optimization-detective' ),
+					'description' => __( 'Timestamp at which the URL Metric was captured.', 'optimization-detective' ),
 					'type'        => 'number',
 					'required'    => true,
 					'readonly'    => true, // Omit from REST API.
@@ -284,7 +286,7 @@
 		);
 
 		/**
-		 * Filters additional schema properties which should be allowed at the root of a URL metric.
+		 * Filters additional schema properties which should be allowed at the root of a URL Metric.
 		 *
 		 * @since 0.6.0
 		 *
@@ -296,7 +298,7 @@
 		}
 
 		/**
-		 * Filters additional schema properties which should be allowed for an elements item in a URL metric.
+		 * Filters additional schema properties which should be allowed for an element's item in a URL Metric.
 		 *
 		 * @since 0.6.0
 		 *
@@ -407,6 +409,8 @@
 	/**
 	 * Gets UUID.
 	 *
+	 * @since 0.6.0
+	 *
 	 * @return string UUID.
 	 */
 	public function get_uuid(): string {
@@ -416,6 +420,8 @@
 	/**
 	 * Gets URL.
 	 *
+	 * @since 0.1.0
+	 *
 	 * @return string URL.
 	 */
 	public function get_url(): string {
@@ -425,6 +431,8 @@
 	/**
 	 * Gets viewport data.
 	 *
+	 * @since 0.1.0
+	 *
 	 * @return ViewportRect Viewport data.
 	 */
 	public function get_viewport(): array {
@@ -434,6 +442,8 @@
 	/**
 	 * Gets viewport width.
 	 *
+	 * @since 0.1.0
+	 *
 	 * @return int Viewport width.
 	 */
 	public function get_viewport_width(): int {
@@ -443,6 +453,8 @@
 	/**
 	 * Gets timestamp.
 	 *
+	 * @since 0.1.0
+	 *
 	 * @return float Timestamp.
 	 */
 	public function get_timestamp(): float {
@@ -452,6 +464,8 @@
 	/**
 	 * Gets elements.
 	 *
+	 * @since 0.1.0
+	 *
 	 * @return OD_Element[] Elements.
 	 */
 	public function get_elements(): array {
@@ -469,6 +483,8 @@
 	/**
 	 * Specifies data which should be serialized to JSON.
 	 *
+	 * @since 0.1.0
+	 *
 	 * @return Data Exports to be serialized by json_encode().
 	 */
 	public function jsonSerialize(): array {
Index: detect.js
===================================================================
--- detect.js	(revision 3190458)
+++ detect.js	(working copy)
@@ -1 +1,558 @@
-const win=window,doc=win.document,consoleLogPrefix="[Optimization Detective]",storageLockTimeSessionKey="odStorageLockTime";function isStorageLocked(e,t){if(0===t)return!1;try{const n=parseInt(sessionStorage.getItem(storageLockTimeSessionKey));return!isNaN(n)&&e<n+1e3*t}catch(e){return!1}}function setStorageLock(e){try{sessionStorage.setItem(storageLockTimeSessionKey,String(e))}catch(e){}}function log(...e){console.log(consoleLogPrefix,...e)}function warn(...e){console.warn(consoleLogPrefix,...e)}function error(...e){console.error(consoleLogPrefix,...e)}function isViewportNeeded(e,t){let n=!1;for(const{minimumViewportWidth:o,complete:i}of t){if(!(e>=o))break;n=!i}return n}function getCurrentTime(){return Date.now()}function recursiveFreeze(e){for(const t of Object.getOwnPropertyNames(e)){const n=e[t];null!==n&&"object"==typeof n&&recursiveFreeze(n)}Object.freeze(e)}let urlMetric;const reservedRootPropertyKeys=new Set(["url","viewport","elements"]);function getRootData(){const e=structuredClone(urlMetric);return recursiveFreeze(e),e}function extendRootData(e){for(const t of Object.getOwnPropertyNames(e))if(reservedRootPropertyKeys.has(t))throw new Error(`Disallowed setting of key '${t}' on root.`);Object.assign(urlMetric,e)}const elementsByXPath=new Map,reservedElementPropertyKeys=new Set(["isLCP","isLCPCandidate","xpath","intersectionRatio","intersectionRect","boundingClientRect"]);function getElementData(e){const t=elementsByXPath.get(e);if(t){const e=structuredClone(t);return recursiveFreeze(e),e}return null}function extendElementData(e,t){if(!elementsByXPath.has(e))throw new Error(`Unknown element with XPath: ${e}`);for(const e of Object.getOwnPropertyNames(t))if(reservedElementPropertyKeys.has(e))throw new Error(`Disallowed setting of key '${e}' on element.`);const n=elementsByXPath.get(e);Object.assign(n,t)}export default async function detect({serveTime:e,detectionTimeWindow:t,minViewportAspectRatio:n,maxViewportAspectRatio:o,isDebug:i,extensionModuleUrls:r,restApiEndpoint:s,restApiNonce:c,currentUrl:a,urlMetricSlug:l,urlMetricNonce:d,urlMetricGroupStatuses:u,storageLockTTL:g,webVitalsLibrarySrc:w,urlMetricGroupCollection:f}){const m=getCurrentTime();if(i&&log("Stored URL metric group collection:",f),m-e>t)return void(i&&warn("Aborted detection due to being outside detection time window."));if(!isViewportNeeded(win.innerWidth,u))return void(i&&log("No need for URL metrics from the current viewport."));const p=win.innerWidth/win.innerHeight;if(p<n||p>o)return void(i&&warn(`Viewport aspect ratio (${p}) is not in the accepted range of ${n} to ${o}.`));if(await new Promise((e=>{"loading"!==doc.readyState?e():doc.addEventListener("DOMContentLoaded",e,{once:!0})})),await new Promise((e=>{"complete"===doc.readyState?e():win.addEventListener("load",e,{once:!0})})),"function"==typeof requestIdleCallback&&await new Promise((e=>{requestIdleCallback(e)})),isStorageLocked(m,g))return void(i&&warn("Aborted detection due to storage being locked."));if(doc.documentElement.scrollTop>0)return void(i&&warn("Aborted detection since initial scroll position of page is not at the top."));i&&log("Proceeding with detection");const h=new Map;for(const e of r)try{const t=await import(e);h.set(e,t),t.initialize instanceof Function&&t.initialize({isDebug:i})}catch(t){error(`Failed to initialize extension '${e}':`,t)}const y=doc.body.querySelectorAll("[data-od-xpath]"),v=new Map([...y].map((e=>[e,e.dataset.odXpath]))),L=[];let P;function b(){P instanceof IntersectionObserver&&(P.disconnect(),win.removeEventListener("scroll",b))}v.size>0&&(await new Promise((e=>{P=new IntersectionObserver((t=>{for(const e of t)L.push(e);e()}),{root:null,threshold:0});for(const e of v.keys())P.observe(e)})),win.addEventListener("scroll",b,{once:!0,passive:!0}));const{onLCP:R}=await import(w),S=[];await new Promise((e=>{R((t=>{S.push(t),e()}),{reportAllChanges:!0})})),b(),i&&log("Detection is stopping."),urlMetric={url:a,viewport:{width:win.innerWidth,height:win.innerHeight},elements:[]};const C=S.at(-1);for(const e of L){const t=v.get(e.target);if(!t){i&&error("Unable to look up XPath for element");continue}const n={isLCP:e.target===C?.entries[0]?.element,isLCPCandidate:!!S.find((t=>t.entries[0]?.element===e.target)),xpath:t,intersectionRatio:e.intersectionRatio,intersectionRect:e.intersectionRect,boundingClientRect:e.boundingClientRect};urlMetric.elements.push(n),elementsByXPath.set(n.xpath,n)}if(i&&log("Current URL metric:",urlMetric),await new Promise((e=>{win.addEventListener("pagehide",e,{once:!0}),win.addEventListener("pageswap",e,{once:!0}),doc.addEventListener("visibilitychange",(()=>{"hidden"===document.visibilityState&&e()}),{once:!0})})),h.size>0)for(const[e,t]of h.entries())if(t.finalize instanceof Function)try{await t.finalize({isDebug:i,getRootData,getElementData,extendElementData,extendRootData})}catch(t){error(`Unable to finalize module '${e}':`,t)}setStorageLock(getCurrentTime()),i&&log("Sending URL metric:",urlMetric);const D=new URL(s);D.searchParams.set("_wpnonce",c),D.searchParams.set("slug",l),D.searchParams.set("nonce",d),navigator.sendBeacon(D,new Blob([JSON.stringify(urlMetric)],{type:"application/json"})),v.clear()}
\ No newline at end of file
+/**
+ * @typedef {import("web-vitals").LCPMetric} LCPMetric
+ * @typedef {import("./types.ts").ElementData} ElementData
+ * @typedef {import("./types.ts").URLMetric} URLMetric
+ * @typedef {import("./types.ts").URLMetricGroupStatus} URLMetricGroupStatus
+ * @typedef {import("./types.ts").Extension} Extension
+ * @typedef {import("./types.ts").ExtendedRootData} ExtendedRootData
+ * @typedef {import("./types.ts").ExtendedElementData} ExtendedElementData
+ */
+
+const win = window;
+const doc = win.document;
+
+const consoleLogPrefix = '[Optimization Detective]';
+
+const storageLockTimeSessionKey = 'odStorageLockTime';
+
+/**
+ * Checks whether storage is locked.
+ *
+ * @param {number} currentTime    - Current time in milliseconds.
+ * @param {number} storageLockTTL - Storage lock TTL in seconds.
+ * @return {boolean} Whether storage is locked.
+ */
+function isStorageLocked( currentTime, storageLockTTL ) {
+	if ( storageLockTTL === 0 ) {
+		return false;
+	}
+
+	try {
+		const storageLockTime = parseInt(
+			sessionStorage.getItem( storageLockTimeSessionKey )
+		);
+		return (
+			! isNaN( storageLockTime ) &&
+			currentTime < storageLockTime + storageLockTTL * 1000
+		);
+	} catch ( e ) {
+		return false;
+	}
+}
+
+/**
+ * Sets the storage lock.
+ *
+ * @param {number} currentTime - Current time in milliseconds.
+ */
+function setStorageLock( currentTime ) {
+	try {
+		sessionStorage.setItem(
+			storageLockTimeSessionKey,
+			String( currentTime )
+		);
+	} catch ( e ) {}
+}
+
+/**
+ * Logs a message.
+ *
+ * @param {...*} message
+ */
+function log( ...message ) {
+	// eslint-disable-next-line no-console
+	console.log( consoleLogPrefix, ...message );
+}
+
+/**
+ * Logs a warning.
+ *
+ * @param {...*} message
+ */
+function warn( ...message ) {
+	// eslint-disable-next-line no-console
+	console.warn( consoleLogPrefix, ...message );
+}
+
+/**
+ * Logs an error.
+ *
+ * @param {...*} message
+ */
+function error( ...message ) {
+	// eslint-disable-next-line no-console
+	console.error( consoleLogPrefix, ...message );
+}
+
+/**
+ * Checks whether the URL Metric(s) for the provided viewport width is needed.
+ *
+ * @param {number}                 viewportWidth          - Current viewport width.
+ * @param {URLMetricGroupStatus[]} urlMetricGroupStatuses - Viewport group statuses.
+ * @return {boolean} Whether URL Metrics are needed.
+ */
+function isViewportNeeded( viewportWidth, urlMetricGroupStatuses ) {
+	let lastWasLacking = false;
+	for ( const { minimumViewportWidth, complete } of urlMetricGroupStatuses ) {
+		if ( viewportWidth >= minimumViewportWidth ) {
+			lastWasLacking = ! complete;
+		} else {
+			break;
+		}
+	}
+	return lastWasLacking;
+}
+
+/**
+ * Gets the current time in milliseconds.
+ *
+ * @return {number} Current time in milliseconds.
+ */
+function getCurrentTime() {
+	return Date.now();
+}
+
+/**
+ * Recursively freezes an object to prevent mutation.
+ *
+ * @param {Object} obj Object to recursively freeze.
+ */
+function recursiveFreeze( obj ) {
+	for ( const prop of Object.getOwnPropertyNames( obj ) ) {
+		const value = obj[ prop ];
+		if ( null !== value && typeof value === 'object' ) {
+			recursiveFreeze( value );
+		}
+	}
+	Object.freeze( obj );
+}
+
+/**
+ * URL Metric being assembled for submission.
+ *
+ * @type {URLMetric}
+ */
+let urlMetric;
+
+/**
+ * Reserved root property keys.
+ *
+ * @see {URLMetric}
+ * @see {ExtendedElementData}
+ * @type {Set<string>}
+ */
+const reservedRootPropertyKeys = new Set( [ 'url', 'viewport', 'elements' ] );
+
+/**
+ * Gets root URL Metric data.
+ *
+ * @return {URLMetric} URL Metric.
+ */
+function getRootData() {
+	const immutableUrlMetric = structuredClone( urlMetric );
+	recursiveFreeze( immutableUrlMetric );
+	return immutableUrlMetric;
+}
+
+/**
+ * Extends root URL Metric data.
+ *
+ * @param {ExtendedRootData} properties
+ */
+function extendRootData( properties ) {
+	for ( const key of Object.getOwnPropertyNames( properties ) ) {
+		if ( reservedRootPropertyKeys.has( key ) ) {
+			throw new Error( `Disallowed setting of key '${ key }' on root.` );
+		}
+	}
+	Object.assign( urlMetric, properties );
+}
+
+/**
+ * Mapping of XPath to element data.
+ *
+ * @type {Map<string, ElementData>}
+ */
+const elementsByXPath = new Map();
+
+/**
+ * Reserved element property keys.
+ *
+ * @see {ElementData}
+ * @see {ExtendedRootData}
+ * @type {Set<string>}
+ */
+const reservedElementPropertyKeys = new Set( [
+	'isLCP',
+	'isLCPCandidate',
+	'xpath',
+	'intersectionRatio',
+	'intersectionRect',
+	'boundingClientRect',
+] );
+
+/**
+ * Gets element data.
+ *
+ * @param {string} xpath XPath.
+ * @return {ElementData|null} Element data, or null if no element for the XPath exists.
+ */
+function getElementData( xpath ) {
+	const elementData = elementsByXPath.get( xpath );
+	if ( elementData ) {
+		const cloned = structuredClone( elementData );
+		recursiveFreeze( cloned );
+		return cloned;
+	}
+	return null;
+}
+
+/**
+ * Extends element data.
+ *
+ * @param {string}              xpath      XPath.
+ * @param {ExtendedElementData} properties Properties.
+ */
+function extendElementData( xpath, properties ) {
+	if ( ! elementsByXPath.has( xpath ) ) {
+		throw new Error( `Unknown element with XPath: ${ xpath }` );
+	}
+	for ( const key of Object.getOwnPropertyNames( properties ) ) {
+		if ( reservedElementPropertyKeys.has( key ) ) {
+			throw new Error(
+				`Disallowed setting of key '${ key }' on element.`
+			);
+		}
+	}
+	const elementData = elementsByXPath.get( xpath );
+	Object.assign( elementData, properties );
+}
+
+/**
+ * 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.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 {string}                 args.webVitalsLibrarySrc        The URL for the web-vitals library.
+ * @param {Object}                 [args.urlMetricGroupCollection] URL Metric group collection, when in debug mode.
+ */
+export default async function detect( {
+	minViewportAspectRatio,
+	maxViewportAspectRatio,
+	isDebug,
+	extensionModuleUrls,
+	restApiEndpoint,
+	currentUrl,
+	urlMetricSlug,
+	cachePurgePostId,
+	urlMetricHMAC,
+	urlMetricGroupStatuses,
+	storageLockTTL,
+	webVitalsLibrarySrc,
+	urlMetricGroupCollection,
+} ) {
+	if ( isDebug ) {
+		log( 'Stored URL Metric group collection:', urlMetricGroupCollection );
+	}
+
+	// Abort if the current viewport is not among those which need URL Metrics.
+	if ( ! isViewportNeeded( win.innerWidth, urlMetricGroupStatuses ) ) {
+		if ( isDebug ) {
+			log( 'No need for URL Metrics from the current viewport.' );
+		}
+		return;
+	}
+
+	// Abort if the viewport aspect ratio is not in a common range.
+	const aspectRatio = win.innerWidth / win.innerHeight;
+	if (
+		aspectRatio < minViewportAspectRatio ||
+		aspectRatio > maxViewportAspectRatio
+	) {
+		if ( isDebug ) {
+			warn(
+				`Viewport aspect ratio (${ aspectRatio }) is not in the accepted range of ${ minViewportAspectRatio } to ${ maxViewportAspectRatio }.`
+			);
+		}
+		return;
+	}
+
+	// Ensure the DOM is loaded (although it surely already is since we're executing in a module).
+	await new Promise( ( resolve ) => {
+		if ( doc.readyState !== 'loading' ) {
+			resolve();
+		} else {
+			doc.addEventListener( 'DOMContentLoaded', resolve, { once: true } );
+		}
+	} );
+
+	// Wait until the resources on the page have fully loaded.
+	await new Promise( ( resolve ) => {
+		if ( doc.readyState === 'complete' ) {
+			resolve();
+		} else {
+			win.addEventListener( 'load', resolve, { once: true } );
+		}
+	} );
+
+	// Wait yet further until idle.
+	if ( typeof requestIdleCallback === 'function' ) {
+		await new Promise( ( resolve ) => {
+			requestIdleCallback( resolve );
+		} );
+	}
+
+	// TODO: Does this make sense here? Should it be moved up above the isViewportNeeded condition?
+	// As an alternative to this, the od_print_detection_script() function can short-circuit if the
+	// 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.' );
+		}
+		return;
+	}
+
+	// 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.'
+			);
+		}
+		return;
+	}
+
+	if ( isDebug ) {
+		log( 'Proceeding with detection' );
+	}
+
+	/** @type {Map<string, Extension>} */
+	const extensions = new Map();
+	for ( const extensionModuleUrl of extensionModuleUrls ) {
+		try {
+			/** @type {Extension} */
+			const extension = await import( extensionModuleUrl );
+			extensions.set( extensionModuleUrl, extension );
+			// TODO: There should to be a way to pass additional args into the module. Perhaps extensionModuleUrls should be a mapping of URLs to args. It's important to pass webVitalsLibrarySrc to the extension so that onLCP, onCLS, or onINP can be obtained.
+			if ( extension.initialize instanceof Function ) {
+				extension.initialize( { isDebug } );
+			}
+		} catch ( err ) {
+			error(
+				`Failed to initialize extension '${ extensionModuleUrl }':`,
+				err
+			);
+		}
+	}
+
+	const breadcrumbedElements = doc.body.querySelectorAll( '[data-od-xpath]' );
+
+	/** @type {Map<Element, string>} */
+	const breadcrumbedElementsMap = new Map(
+		[ ...breadcrumbedElements ].map(
+			/**
+			 * @param {HTMLElement} element
+			 * @return {[HTMLElement, string]} Tuple of element and its XPath.
+			 */
+			( element ) => [ element, element.dataset.odXpath ]
+		)
+	);
+
+	/** @type {IntersectionObserverEntry[]} */
+	const elementIntersections = [];
+
+	/** @type {?IntersectionObserver} */
+	let intersectionObserver;
+
+	function disconnectIntersectionObserver() {
+		if ( intersectionObserver instanceof IntersectionObserver ) {
+			intersectionObserver.disconnect();
+			win.removeEventListener( 'scroll', disconnectIntersectionObserver ); // Clean up, even though this is registered with once:true.
+		}
+	}
+
+	// Wait for the intersection observer to report back on the initially-visible elements.
+	// Note that the first callback will include _all_ observed entries per <https://github.com/w3c/IntersectionObserver/issues/476>.
+	if ( breadcrumbedElementsMap.size > 0 ) {
+		await new Promise( ( resolve ) => {
+			intersectionObserver = new IntersectionObserver(
+				( entries ) => {
+					for ( const entry of entries ) {
+						elementIntersections.push( entry );
+					}
+					resolve();
+				},
+				{
+					root: null, // To watch for intersection relative to the device's viewport.
+					threshold: 0.0, // As soon as even one pixel is visible.
+				}
+			);
+
+			for ( const element of breadcrumbedElementsMap.keys() ) {
+				intersectionObserver.observe( element );
+			}
+		} );
+
+		// Stop observing as soon as the page scrolls since we only want initial-viewport elements.
+		win.addEventListener( 'scroll', disconnectIntersectionObserver, {
+			once: true,
+			passive: true,
+		} );
+	}
+
+	const { onLCP } = await import( webVitalsLibrarySrc );
+
+	/** @type {LCPMetric[]} */
+	const lcpMetricCandidates = [];
+
+	// Obtain at least one LCP candidate. More may be reported before the page finishes loading.
+	await new Promise( ( resolve ) => {
+		onLCP(
+			( /** @type LCPMetric */ metric ) => {
+				lcpMetricCandidates.push( metric );
+				resolve();
+			},
+			{
+				// This avoids needing to click to finalize LCP candidate. While this is helpful for testing, it also
+				// ensures that we always get an LCP candidate reported. Otherwise, the callback may never fire if the
+				// user never does a click or keydown, per <https://github.com/GoogleChrome/web-vitals/blob/07f6f96/src/onLCP.ts#L99-L107>.
+				reportAllChanges: true,
+			}
+		);
+	} );
+
+	// Stop observing.
+	disconnectIntersectionObserver();
+	if ( isDebug ) {
+		log( 'Detection is stopping.' );
+	}
+
+	urlMetric = {
+		url: currentUrl,
+		viewport: {
+			width: win.innerWidth,
+			height: win.innerHeight,
+		},
+		elements: [],
+	};
+
+	const lcpMetric = lcpMetricCandidates.at( -1 );
+
+	for ( const elementIntersection of elementIntersections ) {
+		const xpath = breadcrumbedElementsMap.get( elementIntersection.target );
+		if ( ! xpath ) {
+			if ( isDebug ) {
+				error( 'Unable to look up XPath for element' );
+			}
+			continue;
+		}
+
+		const element = /** @type {Element|null} */ (
+			lcpMetric?.entries[ 0 ]?.element
+		);
+		const isLCP = elementIntersection.target === element;
+
+		/** @type {ElementData} */
+		const elementData = {
+			isLCP,
+			isLCPCandidate: !! lcpMetricCandidates.find(
+				( lcpMetricCandidate ) => {
+					const candidateElement = /** @type {Element|null} */ (
+						lcpMetricCandidate.entries[ 0 ]?.element
+					);
+					return candidateElement === elementIntersection.target;
+				}
+			),
+			xpath,
+			intersectionRatio: elementIntersection.intersectionRatio,
+			intersectionRect: elementIntersection.intersectionRect,
+			boundingClientRect: elementIntersection.boundingClientRect,
+		};
+
+		urlMetric.elements.push( elementData );
+		elementsByXPath.set( elementData.xpath, elementData );
+	}
+
+	if ( isDebug ) {
+		log( 'Current URL Metric:', urlMetric );
+	}
+
+	// Wait for the page to be hidden.
+	await new Promise( ( resolve ) => {
+		win.addEventListener( 'pagehide', resolve, { once: true } );
+		win.addEventListener( 'pageswap', resolve, { once: true } );
+		doc.addEventListener(
+			'visibilitychange',
+			() => {
+				if ( document.visibilityState === 'hidden' ) {
+					// TODO: This will fire even when switching tabs.
+					resolve();
+				}
+			},
+			{ once: true }
+		);
+	} );
+
+	if ( extensions.size > 0 ) {
+		for ( const [
+			extensionModuleUrl,
+			extension,
+		] of extensions.entries() ) {
+			if ( extension.finalize instanceof Function ) {
+				try {
+					await extension.finalize( {
+						isDebug,
+						getRootData,
+						getElementData,
+						extendElementData,
+						extendRootData,
+					} );
+				} catch ( err ) {
+					error(
+						`Unable to finalize module '${ extensionModuleUrl }':`,
+						err
+					);
+				}
+			}
+		}
+	}
+
+	// Even though the server may reject the REST API request, we still have to set the storage lock
+	// because we can't look at the response when sending a beacon.
+	setStorageLock( getCurrentTime() );
+
+	if ( isDebug ) {
+		log( 'Sending URL Metric:', urlMetric );
+	}
+
+	const url = new URL( restApiEndpoint );
+	url.searchParams.set( 'slug', urlMetricSlug );
+	if ( typeof cachePurgePostId === 'number' ) {
+		url.searchParams.set(
+			'cache_purge_post_id',
+			cachePurgePostId.toString()
+		);
+	}
+	url.searchParams.set( 'hmac', urlMetricHMAC );
+	navigator.sendBeacon(
+		url,
+		new Blob( [ JSON.stringify( urlMetric ) ], {
+			type: 'application/json',
+		} )
+	);
+
+	// Clean up.
+	breadcrumbedElementsMap.clear();
+}
Index: detection.php
===================================================================
--- detection.php	(revision 3190458)
+++ detection.php	(working copy)
@@ -11,30 +11,63 @@
 }
 
 /**
+ * Obtains the ID for a post related to this response so that page caches can be told to invalidate their cache.
+ *
+ * If the queried object for the response is a post, then that post's ID is used. Otherwise, it uses the ID of the first
+ * post in The Loop.
+ *
+ * When the queried object is a post (e.g. is_singular, is_posts_page, is_front_page w/ show_on_front=page), then this
+ * is the perfect match. A page caching plugin will be able to most reliably invalidate the cache for a URL via
+ * this ID if the relevant actions are triggered for the post (e.g. clean_post_cache, save_post, transition_post_status).
+ *
+ * Otherwise, if the response is an archive page or the front page where show_on_front=posts (i.e. is_home), then
+ * there is no singular post object that represents the URL. In this case, we obtain the first post in the main
+ * loop. By triggering the relevant actions for this post ID, page caches will have their best shot at invalidating
+ * the related URLs. Page caching plugins which leverage surrogate keys will be the most reliable here. Otherwise,
+ * caching plugins may just resort to automatically purging the cache for the homepage whenever any post is edited,
+ * which is better than nothing.
+ *
+ * There should not be any situation by default in which a page optimized with Optimization Detective does not have such
+ * a post available for cache purging. As seen in {@see od_can_optimize_response()}, when such a post ID is not
+ * available for cache purging then it returns false, as it also does in another case like if is_404().
+ *
+ * @since 0.8.0
+ * @access private
+ *
+ * @return int|null Post ID or null if none found.
+ */
+function od_get_cache_purge_post_id(): ?int {
+	$queried_object = get_queried_object();
+	if ( $queried_object instanceof WP_Post ) {
+		return $queried_object->ID;
+	}
+
+	global $wp_query;
+	if (
+		$wp_query instanceof WP_Query
+		&&
+		$wp_query->post_count > 0
+		&&
+		isset( $wp_query->posts[0] )
+		&&
+		$wp_query->posts[0] instanceof WP_Post
+	) {
+		return $wp_query->posts[0]->ID;
+	}
+
+	return null;
+}
+
+/**
  * Prints the script for detecting loaded images and the LCP element.
  *
  * @since 0.1.0
  * @access private
  *
- * @param string                         $slug             URL metrics slug.
- * @param OD_URL_Metric_Group_Collection $group_collection URL metric group collection.
+ * @param string                         $slug             URL Metrics slug.
+ * @param OD_URL_Metric_Group_Collection $group_collection URL Metric group collection.
  */
 function od_get_detection_script( string $slug, OD_URL_Metric_Group_Collection $group_collection ): string {
-	/**
-	 * Filters the time window between serve time and run time in which loading detection is allowed to run.
-	 *
-	 * This is the allowance of milliseconds between when the page was first generated (and perhaps cached) and when the
-	 * detect function on the page is allowed to perform its detection logic and submit the request to store the results.
-	 * This avoids situations in which there is missing URL Metrics in which case a site with page caching which
-	 * also has a lot of traffic could result in a cache stampede.
-	 *
-	 * @since 0.1.0
-	 * @todo The value should probably be something like the 99th percentile of Time To Last Byte (TTLB) for WordPress sites in CrUX.
-	 *
-	 * @param int $detection_time_window Detection time window in milliseconds.
-	 */
-	$detection_time_window = apply_filters( 'od_detection_time_window', 5000 );
-
 	$web_vitals_lib_data = require __DIR__ . '/build/web-vitals.asset.php';
 	$web_vitals_lib_src  = add_query_arg( 'ver', $web_vitals_lib_data['version'], plugin_dir_url( __FILE__ ) . 'build/web-vitals.js' );
 
@@ -47,19 +80,19 @@
 	 */
 	$extension_module_urls = (array) apply_filters( 'od_extension_module_urls', array() );
 
+	$cache_purge_post_id = od_get_cache_purge_post_id();
+
 	$current_url = od_get_current_url();
 	$detect_args = array(
-		'serveTime'              => microtime( true ) * 1000, // In milliseconds for comparison with `Date.now()` in JavaScript.
-		'detectionTimeWindow'    => $detection_time_window,
 		'minViewportAspectRatio' => od_get_minimum_viewport_aspect_ratio(),
 		'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 ),
-		'restApiNonce'           => wp_create_nonce( 'wp_rest' ),
 		'currentUrl'             => $current_url,
 		'urlMetricSlug'          => $slug,
-		'urlMetricNonce'         => od_get_url_metrics_storage_nonce( $slug, $current_url ),
+		'cachePurgePostId'       => od_get_cache_purge_post_id(),
+		'urlMetricHMAC'          => od_get_url_metrics_storage_hmac( $slug, $current_url, $cache_purge_post_id ),
 		'urlMetricGroupStatuses' => array_map(
 			static function ( OD_URL_Metric_Group $group ): array {
 				return array(
@@ -79,7 +112,7 @@
 	return wp_get_inline_script_tag(
 		sprintf(
 			'import detect from %s; detect( %s );',
-			wp_json_encode( add_query_arg( 'ver', OPTIMIZATION_DETECTIVE_VERSION, plugin_dir_url( __FILE__ ) . 'detect.js' ) ),
+			wp_json_encode( add_query_arg( 'ver', OPTIMIZATION_DETECTIVE_VERSION, plugin_dir_url( __FILE__ ) . sprintf( 'detect%s.js', wp_scripts_get_suffix() ) ) ),
 			wp_json_encode( $detect_args )
 		),
 		array( 'type' => 'module' )
Index: optimization.php
===================================================================
--- optimization.php	(revision 3190458)
+++ optimization.php	(working copy)
@@ -120,7 +120,10 @@
 		// users, additional elements will be present like the script from wp_customize_support_script() which will
 		// interfere with the XPath indices. Note that od_get_normalized_query_vars() is varied by is_user_logged_in()
 		// so membership sites and e-commerce sites will still be able to be optimized for their normal visitors.
-		current_user_can( 'customize' )
+		current_user_can( 'customize' ) ||
+		// Page caching plugins can only reliably be told to invalidate a cached page when a post is available to trigger
+		// the relevant actions on.
+		null === od_get_cache_purge_post_id()
 	);
 
 	/**
Index: readme.txt
===================================================================
--- readme.txt	(revision 3190458)
+++ readme.txt	(working copy)
@@ -2,7 +2,7 @@
 
 Contributors: wordpressdotorg
 Tested up to: 6.7
-Stable tag:   0.7.0
+Stable tag:   0.8.0
 License:      GPLv2 or later
 License URI:  https://www.gnu.org/licenses/gpl-2.0.html
 Tags:         performance, optimization, rum
@@ -11,8 +11,10 @@
 
 == Description ==
 
-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/) (among [others](https://github.com/WordPress/performance/labels/%5BPlugin%5D%20Optimization%20Detective) to come from the WordPress Core Performance team).
+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).
+
 = Background =
 
 WordPress uses [server-side heuristics](https://make.wordpress.org/core/2023/07/13/image-performance-enhancements-in-wordpress-6-3/) to make educated guesses about which images are likely to be in the initial viewport. Likewise, it uses server-side heuristics to identify a hero image which is likely to be the Largest Contentful Paint (LCP) element. To optimize page loading, it avoids lazy-loading any of these images while also adding `fetchpriority=high` to the hero image. When these heuristics are applied successfully, the LCP metric for page loading can be improved 5-10%. Unfortunately, however, there are limitations to the heuristics that make the correct identification of which image is the LCP element only about 50% effective. See [Analyzing the Core Web Vitals performance impact of WordPress 6.3 in the field](https://make.wordpress.org/core/2023/09/19/analyzing-the-core-web-vitals-performance-impact-of-wordpress-6-3-in-the-field/). For example, it is [common](https://github.com/GoogleChromeLabs/wpp-research/pull/73) for the LCP element to vary between different viewport widths, such as desktop versus mobile. Since WordPress's heuristics are completely server-side it has no knowledge of how the page is actually laid out, and it cannot prioritize loading of images according to the client's viewport width.
@@ -21,11 +23,18 @@
 
 = Technical Foundation =
 
-At the core of Optimization Detective is the “URL Metric”, information about a page according to how it was loaded by a client with a specific viewport width. This includes which elements were visible in the initial viewport and which one was the LCP element. Each URL on a site can have an associated set of these URL Metrics (stored in a custom post type) which are gathered from real users. It gathers a sample of URL Metrics according to common responsive breakpoints (e.g. mobile, tablet, and desktop). When no more URL Metrics are needed for a URL due to the sample size being obtained for the breakpoints, it discontinues serving the JavaScript to gather the metrics (leveraging the [web-vitals.js](https://github.com/GoogleChrome/web-vitals) library). With the URL Metrics in hand, the output-buffered page is sent through the HTML Tag Processor and--when the [Image Prioritizer](https://wordpress.org/plugins/image-prioritizer/) dependent plugin is installed--the images which were the LCP element for various breakpoints will get prioritized with high-priority preload links (along with `fetchpriority=high` on the actual `img` tag when it is the common LCP element across all breakpoints). LCP elements with background images added via inline `background-image` styles are also prioritized with preload links.
+At the core of Optimization Detective is the “URL Metric”, information about a page according to how it was loaded by a client with a specific viewport width. This includes which elements were visible in the initial viewport and which one was the LCP element. The URL Metric data is also extensible. Each URL on a site can have an associated set of these URL Metrics (stored in a custom post type) which are gathered from the visits of real users. It gathers samples of URL Metrics which are grouped according to WordPress's default responsive breakpoints:
 
+1. Mobile: 0-480px
+2. Phablet: 481-600px
+3. Tablet: 601-782px
+4. Desktop: \>782px
+
+When no more URL Metrics are needed for a URL due to the sample size being obtained for the viewport group, it discontinues serving the JavaScript to gather the metrics (leveraging the [web-vitals.js](https://github.com/GoogleChrome/web-vitals) library). With the URL Metrics in hand, the output-buffered page is sent through the HTML Tag Processor and--when the [Image Prioritizer](https://wordpress.org/plugins/image-prioritizer/) dependent plugin is installed--the images which were the LCP element for various breakpoints will get prioritized with high-priority preload links (along with `fetchpriority=high` on the actual `img` tag when it is the common LCP element across all breakpoints). LCP elements with background images added via inline `background-image` styles are also prioritized with preload links.
+
 URL Metrics have a “freshness TTL” after which they will be stale and the JavaScript will be served again to start gathering metrics again to ensure that the right elements continue to get their loading prioritized. When a URL Metrics custom post type hasn't been touched in a while, it is automatically garbage-collected.
 
-👉 **Note:** This plugin optimizes pages for actual visitors, and it depends on visitors to optimize pages (since URL metrics need to be collected). As such, you won't see optimizations applied immediately after activating the plugin (and dependent plugin(s)). And since administrator users are not normal visitors typically, optimizations are not applied for admins by default (but this can be overridden with the `od_can_optimize_response` filter below). URL metrics are not collected for administrators because it is likely that additional elements will be present on the page which are not also shown to non-administrators, meaning the URL metrics could not reliably be reused between them. 
+👉 **Note:** This plugin optimizes pages for actual visitors, and it depends on visitors to optimize pages (since URL Metrics need to be collected). As such, you won't see optimizations applied immediately after activating the plugin (and dependent plugin(s)). And since administrator users are not normal visitors typically, optimizations are not applied for admins by default (but this can be overridden with the `od_can_optimize_response` filter below). URL Metrics are not collected for administrators because it is likely that additional elements will be present on the page which are not also shown to non-administrators, meaning the URL Metrics could not reliably be reused between them.
 
 There are currently **no settings** and no user interface for this plugin since it is designed to work without any configuration.
 
@@ -37,17 +46,44 @@
 
 Fires when the Optimization Detective is initializing. This action is useful for loading extension code that depends on Optimization Detective to be running. The version of the plugin is passed as the sole argument so that if the required version is not present, the callback can short circuit.
 
-**Filter:** `od_breakpoint_max_widths` (default: [480, 600, 782])
+**Action:** `od_register_tag_visitors` (argument: `OD_Tag_Visitor_Registry`)
 
-Filters the breakpoint max widths to group URL metrics for various viewports. Each number represents the maximum width (inclusive) for a given breakpoint. So if there is one number, 480, then this means there will be two viewport groupings, one for 0<=480, and another >480. If instead there were three provided breakpoints (320, 480, 576) then this means there will be four groups:
+Fires to register tag visitors before walking over the document to perform optimizations.
 
- 1. 0-320 (small smartphone)
- 2. 321-480 (normal smartphone)
- 3. 481-576 (phablets)
- 4. >576 (desktop)
+For example, to register a new tag visitor that targets `H1` elements:
 
-The default breakpoints are reused from Gutenberg which appear to be used the most in media queries that affect frontend styles.
+`
+<?php
+add_action(
+	'od_register_tag_visitors',
+	static function ( OD_Tag_Visitor_Registry $registry ) {
+		$registry->register(
+			'my-plugin/h1',
+			static function ( OD_Tag_Visitor_Context $context ): bool {
+				if ( $context->processor->get_tag() !== 'H1' ) {
+					return false;
+				}
+				// Now optimize based on stored URL Metrics in $context->url_metric_group_collection.
+				// ...
 
+				// Returning true causes the tag to be tracked in URL Metrics. If there is no need
+				// for this, as in there is no reference to $context->url_metric_group_collection
+				// in a tag visitor, then this can instead return false.
+				return true;
+			}
+		);
+	}
+);
+`
+
+Refer to [Image Prioritizer](https://github.com/WordPress/performance/tree/trunk/plugins/image-prioritizer) and [Embed Optimizer](https://github.com/WordPress/performance/tree/trunk/plugins/embed-optimizer) for real world examples of how tag visitors are used. Registered tag visitors need only be callables, so in addition to providing a closure you may provide a `callable-string` or even a class which has an `__invoke()` method.
+
+**Filter:** `od_breakpoint_max_widths` (default: `array(480, 600, 782)`)
+
+Filters the breakpoint max widths to group URL Metrics for various viewports. Each number represents the maximum width (inclusive) for a given breakpoint. So if there is one number, 480, then this means there will be two viewport groupings, one for 0\<=480, and another \>480. If instead there are the two breakpoints defined, 480 and 782, then this means there will be three viewport groups of URL Metrics, one for 0\<=480 (i.e. mobile), another 481\<=782 (i.e. phablet/tablet), and another \>782 (i.e. desktop).
+
+These default breakpoints are reused from Gutenberg which appear to be used the most in media queries that affect frontend styles.
+
 **Filter:** `od_can_optimize_response` (default: boolean condition, see below)
 
 Filters whether the current response can be optimized. By default, detection and optimization are only performed when:
@@ -67,7 +103,7 @@
 
 **Filter:** `od_url_metrics_breakpoint_sample_size` (default: 3)
 
-Filters the sample size for a breakpoint's URL metrics on a given URL. The sample size must be greater than zero. During development, it may be helpful to reduce the sample size to 1:
+Filters the sample size for a breakpoint's URL Metrics on a given URL. The sample size must be greater than zero. During development, it may be helpful to reduce the sample size to 1:
 
 `
 <?php
@@ -76,7 +112,7 @@
 } );
 `
 
-**Filter:** `od_url_metric_storage_lock_ttl` (default: 1 minute)
+**Filter:** `od_url_metric_storage_lock_ttl` (default: 1 minute in seconds)
 
 Filters how long a given IP is locked from submitting another metric-storage REST API request. Filtering the TTL to zero will disable any metric storage locking. This is useful, for example, to disable locking when a user is logged-in with code like the following:
 
@@ -87,9 +123,9 @@
 } );
 `
 
-**Filter:** `od_url_metric_freshness_ttl` (default: 1 day)
+**Filter:** `od_url_metric_freshness_ttl` (default: 1 day in seconds)
 
-Filters the freshness age (TTL) for a given URL metric. The freshness TTL must be at least zero, in which it considers URL metrics to always be stale. In practice, the value should be at least an hour. During development, this can be useful to set to zero:
+Filters the freshness age (TTL) for a given URL Metric. The freshness TTL must be at least zero, in which it considers URL Metrics to always be stale. In practice, the value should be at least an hour. During development, this can be useful to set to zero:
 
 `
 <?php
@@ -96,23 +132,17 @@
 add_filter( 'od_url_metric_freshness_ttl', '__return_zero' );
 `
 
-**Filter:** `od_detection_time_window` (default: 5 seconds)
-
-Filters the time window between serve time and run time in which loading detection is allowed to run. This amount is the allowance between when the page was first generated (and perhaps cached) and when the detect function on the page is allowed to perform its detection logic and submit the request to store the results. This avoids situations in which there are missing URL Metrics in which case a site with page caching which also has a lot of traffic could result in a cache stampede.
-
 **Filter:** `od_minimum_viewport_aspect_ratio` (default: 0.4)
 
-Filters the minimum allowed viewport aspect ratio for URL metrics.
+Filters the minimum allowed viewport aspect ratio for URL Metrics.
 
-The 0.4 value is intended to accommodate the phone with the greatest known aspect
-ratio at 21:9 when rotated 90 degrees to 9:21 (0.429).
+The 0.4 value is intended to accommodate the phone with the greatest known aspect ratio at 21:9 when rotated 90 degrees to 9:21 (0.429).
 
 **Filter:** `od_maximum_viewport_aspect_ratio` (default: 2.5)
 
-Filters the maximum allowed viewport aspect ratio for URL metrics.
+Filters the maximum allowed viewport aspect ratio for URL Metrics.
 
-The 2.5 value is intended to accommodate the phone with the greatest known aspect
-ratio at 21:9 (2.333).
+The 2.5 value is intended to accommodate the phone with the greatest known aspect ratio at 21:9 (2.333).
 
 During development when you have the DevTools console open, for example, the viewport aspect ratio will be wider than normal. In this case, you may want to increase the maximum aspect ratio:
 
@@ -125,8 +155,82 @@
 
 **Filter:** `od_template_output_buffer` (default: the HTML response)
 
-Filters the template output buffer prior to sending to the client. This filter is added to implement [#43258](https://core.trac.wordpress.org/ticket/43258) in WordPress core.
+Filters the template output buffer prior to sending to the client. This filter is added to implement [\#43258](https://core.trac.wordpress.org/ticket/43258) in WordPress core.
 
+**Filter:** `od_url_metric_schema_element_item_additional_properties` (default: empty array)
+
+Filters additional schema properties which should be allowed for an element's item in a URL Metric.
+
+For example to add a `resizedBoundingClientRect` property:
+
+`
+<?php
+add_filter(
+	'od_url_metric_schema_element_item_additional_properties',
+	static function ( array $additional_properties ): array {
+		$additional_properties['resizedBoundingClientRect'] = array(
+			'type'       => 'object',
+			'properties' => array_fill_keys(
+				array(
+					'width',
+					'height',
+					'x',
+					'y',
+					'top',
+					'right',
+					'bottom',
+					'left',
+				),
+				array(
+					'type'     => 'number',
+					'required' => true,
+				)
+			),
+		);
+		return $additional_properties;
+	}
+);
+`
+
+See also [example usage](https://github.com/WordPress/performance/blob/6bb8405c5c446e3b66c2bfa3ae03ba61b188bca2/plugins/embed-optimizer/hooks.php#L81-L110) in Embed Optimizer.
+
+**Filter:** `od_url_metric_schema_root_additional_properties` (default: empty array)
+
+Filters additional schema properties which should be allowed at the root of a URL Metric.
+
+The usage here is the same as the previous filter, except it allows new properties to be added to the root of the URL Metric and not just to one of the object items in the `elements` property.
+
+**Filter:** `od_extension_module_urls` (default: empty array of strings)
+
+Filters the list of extension script module URLs to import when performing detection.
+
+For example:
+
+`
+<?php
+add_filter(
+	'od_extension_module_urls',
+	static function ( array $extension_module_urls ): array {
+		$extension_module_urls[] = add_query_arg( 'ver', '1.0', plugin_dir_url( __FILE__ ) . 'detect.js' );
+		return $extension_module_urls;
+	}
+);
+`
+
+See also [example usage](https://github.com/WordPress/performance/blob/6bb8405c5c446e3b66c2bfa3ae03ba61b188bca2/plugins/embed-optimizer/hooks.php#L128-L144) in Embed Optimizer. Note in particular the structure of the plugin’s [detect.js](https://github.com/WordPress/performance/blob/trunk/plugins/embed-optimizer/detect.js) script module, how it exports `initialize` and `finalize` functions which Optimization Detective then calls when the page loads and when the page unloads, at which time the URL Metric is constructed and sent to the server for storage. Refer also to the [TypeScript type definitions](https://github.com/WordPress/performance/blob/trunk/plugins/optimization-detective/types.ts).
+
+**Action:** `od_url_metric_stored` (argument: `OD_URL_Metric_Store_Request_Context`)
+
+Fires whenever a URL Metric was successfully stored.
+
+The supplied context object includes these properties:
+
+* `$request`: The `WP_REST_Request` for storing the URL Metric.
+* `$post_id`: The post ID for the `od_url_metric` post.
+* `$url_metric`: The newly-stored URL Metric.
+* `$url_metric_group`: The viewport group that the URL Metric was added to.
+* `$url_metric_group_collection`: The `OD_URL_Metric_Group_Collection` instance to which the URL Metric was added.
+
 == Installation ==
 
 = Installation from within WordPress =
@@ -161,11 +265,25 @@
 
 == Changelog ==
 
+= 0.8.0 =
+
+**Enhancements**
+
+* Serve unminified scripts when `SCRIPT_DEBUG` is enabled. ([1643](https://github.com/WordPress/performance/pull/1643))
+* Bump web-vitals from 4.2.3 to 4.2.4. ([1628](https://github.com/WordPress/performance/pull/1628))
+
+**Bug Fixes**
+
+* Eliminate the detection time window which prevented URL Metrics from being gathered when page caching is present. ([1640](https://github.com/WordPress/performance/pull/1640))
+* Revise the use of nonces in requests to store a URL Metric and block cross-origin requests. ([1637](https://github.com/WordPress/performance/pull/1637))
+* Send post ID of queried object or first post in loop in URL Metric storage request to schedule page cache validation. ([1641](https://github.com/WordPress/performance/pull/1641))
+* Fix phpstan errors. ([1627](https://github.com/WordPress/performance/pull/1627))
+
 = 0.7.0 =
 
 **Enhancements**
 
-* Send gathered URL metric data when the page is hidden/unloaded as opposed to once the page has loaded; this enables the ability to track layout shifts and INP scores over the life of the page. ([1373](https://github.com/WordPress/performance/pull/1373))
+* Send gathered URL Metric data when the page is hidden/unloaded as opposed to once the page has loaded; this enables the ability to track layout shifts and INP scores over the life of the page. ([1373](https://github.com/WordPress/performance/pull/1373))
 * Introduce client-side extensions in the form of script modules which are loaded when the detection logic runs. ([1373](https://github.com/WordPress/performance/pull/1373))
 * Add an `od_init` action for extensions to load their code. ([1373](https://github.com/WordPress/performance/pull/1373))
 * Introduce `OD_Element` class and improve PHP API. ([1585](https://github.com/WordPress/performance/pull/1585))
@@ -180,9 +298,9 @@
 
 **Enhancements**
 
-* Allow URL metric schema to be extended. ([1492](https://github.com/WordPress/performance/pull/1492))
+* Allow URL Metric schema to be extended. ([1492](https://github.com/WordPress/performance/pull/1492))
 * Clarify docs around a tag visitor's boolean return value. ([1479](https://github.com/WordPress/performance/pull/1479))
-* Include UUID with each URL metric. ([1489](https://github.com/WordPress/performance/pull/1489))
+* Include UUID with each URL Metric. ([1489](https://github.com/WordPress/performance/pull/1489))
 * Introduce get_cursor_move_count() to use instead of get_seek_count() and get_next_token_count(). ([1478](https://github.com/WordPress/performance/pull/1478))
 
 **Bug Fixes**
@@ -224,11 +342,11 @@
 
 **Enhancements**
 
-* Log URL metrics group collection to console when debugging is enabled (`WP_DEBUG` is true). ([1295](https://github.com/WordPress/performance/pull/1295))
+* Log URL Metrics group collection to console when debugging is enabled (`WP_DEBUG` is true). ([1295](https://github.com/WordPress/performance/pull/1295))
 
 **Bug Fixes**
 
-* Include non-intersecting elements in URL metrics to fix lazy-load optimization. ([1293](https://github.com/WordPress/performance/pull/1293))
+* Include non-intersecting elements in URL Metrics to fix lazy-load optimization. ([1293](https://github.com/WordPress/performance/pull/1293))
 
 = 0.3.0 =
 
Index: storage/class-od-url-metrics-post-type.php
===================================================================
--- storage/class-od-url-metrics-post-type.php	(revision 3190458)
+++ storage/class-od-url-metrics-post-type.php	(working copy)
@@ -51,7 +51,7 @@
 	}
 
 	/**
-	 * Registers post type for URL metrics storage.
+	 * Registers post type for URL Metrics storage.
 	 *
 	 * This the configuration for this post type is similar to the oembed_cache in core.
 	 *
@@ -78,11 +78,11 @@
 	}
 
 	/**
-	 * Gets URL metrics post.
+	 * Gets URL Metrics post.
 	 *
 	 * @since 0.1.0
 	 *
-	 * @param string $slug URL metrics slug.
+	 * @param string $slug URL Metrics slug.
 	 * @return WP_Post|null Post object if exists.
 	 */
 	public static function get_post( string $slug ): ?WP_Post {
@@ -109,16 +109,20 @@
 	}
 
 	/**
-	 * Parses post content in URL metrics post.
+	 * Parses post content in URL Metrics post.
 	 *
 	 * @since 0.1.0
 	 *
-	 * @param WP_Post $post URL metrics post.
-	 * @return OD_URL_Metric[] URL metrics.
+	 * @param WP_Post $post URL Metrics post.
+	 * @return OD_URL_Metric[] URL Metrics.
 	 */
 	public static function get_url_metrics_from_post( WP_Post $post ): array {
 		$this_function = __METHOD__;
 		$trigger_error = static function ( string $message, int $error_level = E_USER_NOTICE ) use ( $this_function ): void {
+			// Default to E_USER_NOTICE.
+			if ( ! in_array( $error_level, array( E_USER_NOTICE, E_USER_WARNING, E_USER_ERROR, E_USER_DEPRECATED ), true ) ) {
+				$error_level = E_USER_NOTICE;
+			}
 			wp_trigger_error( $this_function, esc_html( $message ), $error_level );
 		};
 
@@ -171,7 +175,7 @@
 									$e->getMessage() . $suffix
 								),
 								// This is not a warning because schema changes will happen, and so it is expected
-								// that this will result in existing URL metrics being invalidated.
+								// that this will result in existing URL Metrics being invalidated.
 								E_USER_NOTICE
 							);
 
@@ -185,13 +189,13 @@
 	}
 
 	/**
-	 * Stores URL metric by merging it with the other URL metrics which share the same normalized query vars.
+	 * Stores URL Metric by merging it with the other URL Metrics which share the same normalized query vars.
 	 *
 	 * @since 0.1.0
 	 * @todo There is duplicate logic here with od_handle_rest_request().
 	 *
 	 * @param string        $slug           Slug (hash of normalized query vars).
-	 * @param OD_URL_Metric $new_url_metric New URL metric.
+	 * @param OD_URL_Metric $new_url_metric New URL Metric.
 	 * @return int|WP_Error Post ID or WP_Error otherwise.
 	 */
 	public static function store_url_metric( string $slug, OD_URL_Metric $new_url_metric ) {
Index: storage/data.php
===================================================================
--- storage/data.php	(revision 3190458)
+++ storage/data.php	(working copy)
@@ -11,9 +11,9 @@
 }
 
 /**
- * Gets the freshness age (TTL) for a given URL metric.
+ * Gets the freshness age (TTL) for a given URL Metric.
  *
- * When a URL metric expires it is eligible to be replaced by a newer one if its viewport lies within the same breakpoint.
+ * When a URL Metric expires it is eligible to be replaced by a newer one if its viewport lies within the same breakpoint.
  *
  * @since 0.1.0
  * @access private
@@ -22,9 +22,9 @@
  */
 function od_get_url_metric_freshness_ttl(): int {
 	/**
-	 * Filters the freshness age (TTL) for a given URL metric.
+	 * Filters the freshness age (TTL) for a given URL Metric.
 	 *
-	 * The freshness TTL must be at least zero, in which it considers URL metrics to always be stale.
+	 * The freshness TTL must be at least zero, in which it considers URL Metrics to always be stale.
 	 * In practice, the value should be at least an hour.
 	 *
 	 * @since 0.1.0
@@ -54,7 +54,7 @@
 /**
  * Gets the normalized query vars for the current request.
  *
- * This is used as a cache key for stored URL metrics.
+ * This is used as a cache key for stored URL Metrics.
  *
  * TODO: For non-singular requests, consider adding the post IDs from The Loop to ensure publishing a new post will invalidate the cache.
  *
@@ -77,7 +77,7 @@
 		);
 	}
 
-	// Vary URL metrics by whether the user is logged in since additional elements may be present.
+	// Vary URL Metrics by whether the user is logged in since additional elements may be present.
 	if ( is_user_logged_in() ) {
 		$normalized_query_vars['user_logged_in'] = true;
 	}
@@ -124,7 +124,7 @@
 }
 
 /**
- * Gets slug for URL metrics.
+ * Gets slug for URL Metrics.
  *
  * A slug is the hash of the normalized query vars.
  *
@@ -141,55 +141,57 @@
 }
 
 /**
- * Computes nonce for storing URL metrics for a specific slug.
+ * Computes HMAC for storing URL Metrics for a specific slug.
  *
- * This is used in the REST API to authenticate the storage of new URL metrics from a given URL.
+ * This is used in the REST API to authenticate the storage of new URL Metrics from a given URL.
  *
- * @since 0.1.0
+ * @since 0.8.0
  * @access private
  *
- * @see wp_create_nonce()
- * @see od_verify_url_metrics_storage_nonce()
+ * @see od_verify_url_metrics_storage_hmac()
  * @see od_get_url_metrics_slug()
+ * @todo This should also include an ETag as a parameter. See <https://github.com/WordPress/performance/issues/1466>.
  *
- * @param string $slug Slug (hash of normalized query vars).
- * @param string $url  URL.
- * @return string Nonce.
+ * @param string   $slug                Slug (hash of normalized query vars).
+ * @param string   $url                 URL.
+ * @param int|null $cache_purge_post_id Cache purge post ID.
+ * @return string HMAC.
  */
-function od_get_url_metrics_storage_nonce( string $slug, string $url ): string {
-	return wp_create_nonce( "store_url_metrics:$slug:$url" );
+function od_get_url_metrics_storage_hmac( string $slug, string $url, ?int $cache_purge_post_id = null ): string {
+	$action = "store_url_metric:$slug:$url:$cache_purge_post_id";
+	return wp_hash( $action, 'nonce' );
 }
 
 /**
- * Verifies nonce for storing URL metrics for a specific slug.
+ * Verifies HMAC for storing URL Metrics for a specific slug.
  *
- * @since 0.1.0
+ * @since 0.8.0
  * @access private
  *
- * @see wp_verify_nonce()
- * @see od_get_url_metrics_storage_nonce()
+ * @see od_get_url_metrics_storage_hmac()
  * @see od_get_url_metrics_slug()
  *
- * @param string $nonce Nonce.
- * @param string $slug  Slug (hash of normalized query vars).
- * @param String $url   URL.
- * @return bool Whether the nonce is valid.
+ * @param string   $hmac                HMAC.
+ * @param string   $slug                Slug (hash of normalized query vars).
+ * @param String   $url                 URL.
+ * @param int|null $cache_purge_post_id Cache purge post ID.
+ * @return bool Whether the HMAC is valid.
  */
-function od_verify_url_metrics_storage_nonce( string $nonce, string $slug, string $url ): bool {
-	return (bool) wp_verify_nonce( $nonce, "store_url_metrics:$slug:$url" );
+function od_verify_url_metrics_storage_hmac( string $hmac, string $slug, string $url, ?int $cache_purge_post_id = null ): bool {
+	return hash_equals( od_get_url_metrics_storage_hmac( $slug, $url, $cache_purge_post_id ), $hmac );
 }
 
 /**
- * Gets the minimum allowed viewport aspect ratio for URL metrics.
+ * Gets the minimum allowed viewport aspect ratio for URL Metrics.
  *
  * @since 0.6.0
  * @access private
  *
- * @return float Minimum viewport aspect ratio for URL metrics.
+ * @return float Minimum viewport aspect ratio for URL Metrics.
  */
 function od_get_minimum_viewport_aspect_ratio(): float {
 	/**
-	 * Filters the minimum allowed viewport aspect ratio for URL metrics.
+	 * Filters the minimum allowed viewport aspect ratio for URL Metrics.
 	 *
 	 * The 0.4 default value is intended to accommodate the phone with the greatest known aspect
 	 * ratio at 21:9 when rotated 90 degrees to 9:21 (0.429).
@@ -202,16 +204,16 @@
 }
 
 /**
- * Gets the maximum allowed viewport aspect ratio for URL metrics.
+ * Gets the maximum allowed viewport aspect ratio for URL Metrics.
  *
  * @since 0.6.0
  * @access private
  *
- * @return float Maximum viewport aspect ratio for URL metrics.
+ * @return float Maximum viewport aspect ratio for URL Metrics.
  */
 function od_get_maximum_viewport_aspect_ratio(): float {
 	/**
-	 * Filters the maximum allowed viewport aspect ratio for URL metrics.
+	 * Filters the maximum allowed viewport aspect ratio for URL Metrics.
 	 *
 	 * The 2.5 default value is intended to accommodate the phone with the greatest known aspect
 	 * ratio at 21:9 (2.333).
@@ -224,7 +226,7 @@
 }
 
 /**
- * Gets the breakpoint max widths to group URL metrics for various viewports.
+ * Gets the breakpoint max widths to group URL Metrics for various viewports.
  *
  * Each number represents the maximum width (inclusive) for a given breakpoint. So if there is one number, 480, then
  * this means there will be two viewport groupings, one for 0<=480, and another >480. If instead there were three
@@ -288,7 +290,7 @@
 			return $breakpoint;
 		},
 		/**
-		 * Filters the breakpoint max widths to group URL metrics for various viewports.
+		 * Filters the breakpoint max widths to group URL Metrics for various viewports.
 		 *
 		 * A breakpoint must be greater than zero and less than PHP_INT_MAX. This array may be empty in which case there
 		 * are no responsive breakpoints and all URL Metrics are collected in a single group.
@@ -306,11 +308,11 @@
 }
 
 /**
- * Gets the sample size for a breakpoint's URL metrics on a given URL.
+ * Gets the sample size for a breakpoint's URL Metrics on a given URL.
  *
- * A breakpoint divides URL metrics for viewports which are smaller and those which are larger. Given the default
+ * A breakpoint divides URL Metrics for viewports which are smaller and those which are larger. Given the default
  * sample size of 3 and there being just a single breakpoint (480) by default, for a given URL, there would be a maximum
- * total of 6 URL metrics stored for a given URL: 3 for mobile and 3 for desktop.
+ * total of 6 URL Metrics stored for a given URL: 3 for mobile and 3 for desktop.
  *
  * @since 0.1.0
  * @access private
@@ -319,7 +321,7 @@
  */
 function od_get_url_metrics_breakpoint_sample_size(): int {
 	/**
-	 * Filters the sample size for a breakpoint's URL metrics on a given URL.
+	 * Filters the sample size for a breakpoint's URL Metrics on a given URL.
 	 *
 	 * The sample size must be greater than zero.
 	 *
Index: storage/rest-api.php
===================================================================
--- storage/rest-api.php	(revision 3190458)
+++ storage/rest-api.php	(working copy)
@@ -18,7 +18,7 @@
 const OD_REST_API_NAMESPACE = 'optimization-detective/v1';
 
 /**
- * Route for storing a URL metric.
+ * Route for storing a URL Metric.
  *
  * Note the `:store` art of the endpoint follows Google's guidance in AIP-136 for the use of the POST method in a way
  * that does not strictly follow the standard usage. Namely, submitting a POST request to this endpoint will either
@@ -30,7 +30,7 @@
 const OD_URL_METRICS_ROUTE = '/url-metrics:store';
 
 /**
- * Registers endpoint for storage of URL metric.
+ * Registers endpoint for storage of URL Metric.
  *
  * @since 0.1.0
  * @access private
@@ -37,23 +37,29 @@
  */
 function od_register_endpoint(): void {
 
+	// The slug and cache_purge_post_id args are further validated via the validate_callback for the 'hmac' parameter,
+	// they are provided as input with the 'url' argument to create the HMAC by the server.
 	$args = array(
-		'slug'  => array(
+		'slug'                => array(
 			'type'        => 'string',
 			'description' => __( 'An MD5 hash of the query args.', 'optimization-detective' ),
 			'required'    => true,
 			'pattern'     => '^[0-9a-f]{32}$',
-			// This is further validated via the validate_callback for the nonce argument, as it is provided as input
-			// with the 'url' argument to create the nonce by the server. which then is verified to match in the REST API request.
 		),
-		'nonce' => array(
+		'cache_purge_post_id' => array(
+			'type'        => 'integer',
+			'description' => __( 'Cache purge post ID.', 'optimization-detective' ),
+			'required'    => false,
+			'minimum'     => 1,
+		),
+		'hmac'                => array(
 			'type'              => 'string',
-			'description'       => __( 'Nonce originally computed by server required to authorize the request.', 'optimization-detective' ),
+			'description'       => __( 'HMAC originally computed by server required to authorize the request.', 'optimization-detective' ),
 			'required'          => true,
 			'pattern'           => '^[0-9a-f]+$',
-			'validate_callback' => static function ( string $nonce, WP_REST_Request $request ) {
-				if ( ! od_verify_url_metrics_storage_nonce( $nonce, $request->get_param( 'slug' ), $request->get_param( 'url' ) ) ) {
-					return new WP_Error( 'invalid_nonce', __( 'URL metrics nonce verification failure.', 'optimization-detective' ) );
+			'validate_callback' => static function ( string $hmac, WP_REST_Request $request ) {
+				if ( ! od_verify_url_metrics_storage_hmac( $hmac, $request['slug'], $request['url'], $request['cache_purge_post_id'] ?? null ) ) {
+					return new WP_Error( 'invalid_hmac', __( 'URL Metrics HMAC verification failure.', 'optimization-detective' ) );
 				}
 				return true;
 			},
@@ -77,7 +83,7 @@
 				if ( OD_Storage_Lock::is_locked() ) {
 					return new WP_Error(
 						'url_metric_storage_locked',
-						__( 'URL metric storage is presently locked for the current IP.', 'optimization-detective' ),
+						__( 'URL Metric storage is presently locked for the current IP.', 'optimization-detective' ),
 						array( 'status' => 403 )
 					);
 				}
@@ -89,6 +95,27 @@
 add_action( 'rest_api_init', 'od_register_endpoint' );
 
 /**
+ * Determines if the HTTP origin is an authorized one.
+ *
+ * Note that `is_allowed_http_origin()` is not used directly because the underlying `get_allowed_http_origins()` does
+ * not account for the URL port (although there is a to-do comment committed in core to address this). Additionally,
+ * the `is_allowed_http_origin()` function in core for some reason returns a string rather than a boolean.
+ *
+ * @since 0.8.0
+ * @access private
+ *
+ * @see is_allowed_http_origin()
+ *
+ * @param string $origin Origin to check.
+ * @return bool Whether the origin is allowed.
+ */
+function od_is_allowed_http_origin( string $origin ): bool {
+	// Strip out the port number since core does not account for it yet as noted in get_allowed_http_origins().
+	$origin = preg_replace( '/:\d+$/', '', $origin );
+	return '' !== is_allowed_http_origin( $origin );
+}
+
+/**
  * Handles REST API request to store metrics.
  *
  * @since 0.1.0
@@ -100,6 +127,16 @@
  * @return WP_REST_Response|WP_Error Response.
  */
 function od_handle_rest_request( WP_REST_Request $request ) {
+	// Block cross-origin storage requests since by definition URL Metrics data can only be sourced from the frontend of the site.
+	$origin = $request->get_header( 'origin' );
+	if ( null === $origin || ! od_is_allowed_http_origin( $origin ) ) {
+		return new WP_Error(
+			'rest_cross_origin_forbidden',
+			__( 'Cross-origin requests are not allowed for this endpoint.', 'optimization-detective' ),
+			array( 'status' => 403 )
+		);
+	}
+
 	$post = OD_URL_Metrics_Post_Type::get_post( $request->get_param( 'slug' ) );
 
 	$url_metric_group_collection = new OD_URL_Metric_Group_Collection(
@@ -109,7 +146,7 @@
 		od_get_url_metric_freshness_ttl()
 	);
 
-	// Block the request if URL metrics aren't needed for the provided viewport width.
+	// Block the request if URL Metrics aren't needed for the provided viewport width.
 	try {
 		$url_metric_group = $url_metric_group_collection->get_group_for_viewport_width(
 			$request->get_param( 'viewport' )['width']
@@ -120,7 +157,7 @@
 	if ( $url_metric_group->is_complete() ) {
 		return new WP_Error(
 			'url_metric_group_complete',
-			__( 'The URL metric group for the provided viewport is already complete.', 'optimization-detective' ),
+			__( 'The URL Metric group for the provided viewport is already complete.', 'optimization-detective' ),
 			array( 'status' => 403 )
 		);
 	}
@@ -153,7 +190,7 @@
 			'rest_invalid_param',
 			sprintf(
 				/* translators: %s is exception name */
-				__( 'Failed to validate URL metric: %s', 'optimization-detective' ),
+				__( 'Failed to validate URL Metric: %s', 'optimization-detective' ),
 				$e->getMessage()
 			),
 			array( 'status' => 400 )
@@ -171,6 +208,16 @@
 	}
 	$post_id = $result;
 
+	// Schedule an event in 10 minutes to trigger an invalidation of the page cache (hopefully).
+	$cache_purge_post_id = $request->get_param( 'cache_purge_post_id' );
+	if ( is_int( $cache_purge_post_id ) && false === wp_next_scheduled( 'od_trigger_page_cache_invalidation', array( $cache_purge_post_id ) ) ) {
+		wp_schedule_single_event(
+			time() + 10 * MINUTE_IN_SECONDS,
+			'od_trigger_page_cache_invalidation',
+			array( $cache_purge_post_id )
+		);
+	}
+
 	/**
 	 * Fires whenever a URL Metric was successfully stored.
 	 *
@@ -195,3 +242,49 @@
 		)
 	);
 }
+
+/**
+ * Triggers 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 0.8.0
+ * @access private
+ *
+ * @param int $cache_purge_post_id Cache purge post ID.
+ */
+function od_trigger_page_cache_invalidation( int $cache_purge_post_id ): void {
+	$post = get_post( $cache_purge_post_id );
+	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 );
+}

webp-uploads

Important

Stable tag change: 2.2.0 → 2.3.0

svn status:

M       helper.php
M       hooks.php
M       picture-element.php
M       readme.txt
svn diff
Index: helper.php
===================================================================
--- helper.php	(revision 3190458)
+++ helper.php	(working copy)
@@ -468,3 +468,27 @@
 
 	return null;
 }
+
+/**
+ * Retrieves the MIME type of a file, checking the file directly if possible,
+ * and falling back to the attachment's MIME type if needed.
+ *
+ * The function attempts to determine the MIME type directly from the file.
+ * If that information is unavailable, it uses the MIME type from the attachment metadata.
+ * If neither is available, it defaults to an empty string.
+ *
+ * @since 2.3.0
+ *
+ * @param string $file          The path to the file.
+ * @param int    $attachment_id The attachment ID.
+ * @return string The MIME type of the file, or an empty string if not found.
+ */
+function webp_uploads_get_file_mime_type( string $file, int $attachment_id ): string {
+	/*
+	 * We need to get the MIME type ideally from the file, as WordPress Core may have already altered it.
+	 * The post MIME type is typically not updated during that process.
+	 */
+	$filetype  = wp_check_filetype( $file );
+	$mime_type = $filetype['type'] ?? get_post_mime_type( $attachment_id );
+	return is_string( $mime_type ) ? $mime_type : '';
+}
Index: hooks.php
===================================================================
--- hooks.php	(revision 3190458)
+++ hooks.php	(working copy)
@@ -52,18 +52,21 @@
  * } An array with the updated structure for the metadata before is stored in the database.
  */
 function webp_uploads_create_sources_property( array $metadata, int $attachment_id ): array {
-	// This should take place only on the JPEG image.
-	$valid_mime_transforms = webp_uploads_get_upload_image_mime_transforms();
+	$file = get_attached_file( $attachment_id, true );
+	// File does not exist.
+	if ( false === $file || ! file_exists( $file ) ) {
+		return $metadata;
+	}
 
-	// Not a supported mime type to create the sources property.
-	$mime_type = get_post_mime_type( $attachment_id );
-	if ( ! is_string( $mime_type ) || ! isset( $valid_mime_transforms[ $mime_type ] ) ) {
+	$mime_type = webp_uploads_get_file_mime_type( $file, $attachment_id );
+	if ( '' === $mime_type ) {
 		return $metadata;
 	}
 
-	$file = get_attached_file( $attachment_id, true );
-	// File does not exist.
-	if ( false === $file || ! file_exists( $file ) ) {
+	$valid_mime_transforms = webp_uploads_get_upload_image_mime_transforms();
+
+	// Not a supported mime type to create the sources property.
+	if ( ! isset( $valid_mime_transforms[ $mime_type ] ) ) {
 		return $metadata;
 	}
 
Index: picture-element.php
===================================================================
--- picture-element.php	(revision 3190458)
+++ picture-element.php	(working copy)
@@ -22,12 +22,24 @@
 	if ( 'the_content' !== $context ) {
 		return $image;
 	}
-	$image_meta              = wp_get_attachment_metadata( $attachment_id );
-	$original_file_mime_type = get_post_mime_type( $attachment_id );
-	if ( false === $original_file_mime_type || ! isset( $image_meta['sizes'] ) ) {
+
+	$file = get_attached_file( $attachment_id, true );
+	// File does not exist.
+	if ( false === $file || ! file_exists( $file ) ) {
 		return $image;
 	}
 
+	$original_file_mime_type = webp_uploads_get_file_mime_type( $file, $attachment_id );
+	if ( '' === $original_file_mime_type ) {
+		return $image;
+	}
+
+	$image_meta = wp_get_attachment_metadata( $attachment_id );
+
+	if ( ! isset( $image_meta['sizes'] ) ) {
+		return $image;
+	}
+
 	$image_sizes = $image_meta['sizes'];
 
 	// Append missing full size image in $image_sizes array for srcset.
Index: readme.txt
===================================================================
--- readme.txt	(revision 3190458)
+++ readme.txt	(working copy)
@@ -1,8 +1,8 @@
 === Modern Image Formats ===
 
 Contributors: wordpressdotorg
-Tested up to: 6.6
-Stable tag:   2.2.0
+Tested up to: 6.7
+Stable tag:   2.3.0
 License:      GPLv2 or later
 License URI:  https://www.gnu.org/licenses/gpl-2.0.html
 Tags:         performance, images, webp, avif, modern image formats
@@ -60,6 +60,12 @@
 
 == Changelog ==
 
+= 2.3.0 =
+
+**Bug Fixes**
+
+* Fix bug that would prevent uploaded images from being converted to the intended output format when having fallback formats enabled. ([1635](https://github.com/WordPress/performance/pull/1635))
+
 = 2.2.0 =
 
 **Enhancements**

@westonruter westonruter marked this pull request as ready for review November 16, 2024 17:37
Copy link

github-actions bot commented Nov 16, 2024

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

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

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

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

@westonruter westonruter mentioned this pull request Nov 16, 2024
4 tasks
@westonruter
Copy link
Member Author

IMPORTANT: There is a critical (yet trivial) bug in Optimization Detective that must be merged prior to this release: #1659

@mukeshpanchal27
Copy link
Member

Found bug and reported to #1660 while doing smoke testing.

@mukeshpanchal27
Copy link
Member

Another bug after 6.7 release #1661

Copy link
Member

@mukeshpanchal27 mukeshpanchal27 left a comment

Choose a reason for hiding this comment

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

I’ve reported issue #1660 for Performance Lab and issue #1661 for Modern Image Formats. Additionally, PR #1662 is ready for review. Including these changes in current release of the Modern Image Formats plugin would be ideal.

@felixarntz
Copy link
Member

@mukeshpanchal27 #1662 is now included in this and the readme has been updated accordingly.

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

Successfully merging this pull request may close these issues.

4 participants