Skip to content

Prepare 2026-06-30 release - #2554

Merged
westonruter merged 14 commits into
release/2026-06-30from
publish/2026-06-30
Jul 1, 2026
Merged

Prepare 2026-06-30 release#2554
westonruter merged 14 commits into
release/2026-06-30from
publish/2026-06-30

Conversation

@westonruter

Copy link
Copy Markdown
Member
  • Add bump-versions command to automate release version bumping
  • Limit since to plugins with a release milestone
  • Replace "n.e.x.t" in @deprecated tags too in since
  • Replace ??= in bump-versions with an explicit guard for readability
  • Resolve static-analysis warnings in release CLI commands
  • Enable noUnusedParameters in tsconfig
  • Break up description into a list
  • Use Markdown as proper noun
  • Bump versions via npm run bump-versions
  • Supply version in Optimization Detective deprecated tag which was missed in 1.0.0-beta3
  • Run npm run since
  • Gate npm run readme by release milestones and make it idempotent
  • Populate changelogs
  • Exclude minified assets from pending release diffs

westonruter and others added 14 commits June 30, 2026 17:11
Introduce `npm run bump-versions`, which sets each plugin's version based on
its release milestone instead of editing the files by hand.

A release milestone is an open GitHub milestone whose title is
"<plugin-slug> <version>" (i.e. it does not contain "n.e.x.t") and which has a
due date set; the due date distinguishes a release milestone from a
backlog/planning milestone. For each matching milestone the target version is
applied to:

- the plugin bootstrap PHP file "Version" header,
- the PHP version constant,
- the "Stable tag" in readme.txt, and
- a changelog entry in readme.txt.

The changelog handling relabels an existing "= n.e.x.t =" entry to the version
(preserving any notes accrued during development) rather than inserting a
duplicate, is idempotent when an entry for the version already exists, and
otherwise inserts a new empty entry so the readme stays version-consistent and
is primed for `npm run readme`.

The command warns (rather than skips) when a matched milestone still has open
issues/PRs, errors if two dated milestones target the same plugin, and supports
`--plugin`, `--token`, and `--dry-run`. A reusable `getMilestones()` helper is
added to the milestone lib.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
By default `npm run since` now only resolves "n.e.x.t" tags for plugins that
are actually being released, i.e. those with an open GitHub milestone that has
a due date and whose title does not contain "n.e.x.t". This prevents resolving
"@SInCE n.e.x.t" tags in plugins that are still mid-development (e.g. betas with
no scheduled release).

The release-milestone detection is shared with the `bump-versions` command:
`getReleaseMilestones()` is exported from that command and reused here, mirroring
how `readme` reuses `getChangelog` from `changelog`.

A `--all` flag restores the previous behavior of updating every plugin while
ignoring milestones; combined with `--plugin` it updates that one plugin without
requiring a milestone. A `--token` option is added for the GitHub API request.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The `since` command only resolved the "n.e.x.t" version placeholder in "@SInCE"
tags, leaving "@deprecated n.e.x.t" tags untouched. WordPress uses the same
placeholder in both, so broaden the match to "@SInCE" and "@deprecated".

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The logical assignment expression `( byPlugin[ slug ] ??= [] ).push( … )`
was overly clever; expand it into a plain existence check before pushing.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WgTbsDpCqxVBrH3yDH65jS
Clear the inspections that PhpStorm surfaces (but the project's tsc config does
not) across the bin/plugin release commands:

- Prefix intentionally-unused regex-replacer callback parameters with an
  underscore so they are recognized as deliberate (TS6133).
- Drop the unused "milestone" parameter from changelog's formatChangelog().
- Remove redundant "\." escapes inside character classes in the version-parsing
  regular expressions.

No behavior change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Turn on `noUnusedParameters` so `tsc` flags unused function parameters the same
way PhpStorm's inspections already do, keeping the two consistent. The codebase
is already clean under this option, so `npm run tsc` continues to pass with no
new errors.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Limit the readme command to plugins with an open, dated release milestone
(matching bump-versions and since), and add an --all flag to force the
previous "update every plugin" behavior.

Rework the changelog update so re-running merges only new pull requests into
the existing entry, deduped by PR number, instead of wrapping prior content in
an "Other" section. Also handle the empty changelog entry left by bump-versions,
which previously fell through to inserting a duplicate section.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add *.min.js and *.min.css to the rsync excludes in
generate-pending-release-diffs.sh so the generated svn status/diff overview
omits build-derived minified files. Because rsync runs with --delete, the
excludes also protect the stable copies from removal, keeping them out of the
diff entirely.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@westonruter westonruter added [Type] Documentation Documentation to be added or enhanced Infrastructure Issues for the overall performance plugin infrastructure no milestone PRs that do not have a defined milestone for release skip changelog PRs that should not be mentioned in changelogs labels Jul 1, 2026
@westonruter

Copy link
Copy Markdown
Member Author

Pending Release Diffs

auto-sizes

Warning

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

svn status:

M       auto-sizes.php
?       bindtest.txt
M       hooks.php
M       includes/improve-calculate-sizes.php
svn diff
Index: auto-sizes.php
===================================================================
--- auto-sizes.php	(revision 3592034)
+++ auto-sizes.php	(working copy)
@@ -3,8 +3,8 @@
  * Plugin Name: Enhanced Responsive Images
  * Plugin URI: https://github.com/WordPress/performance/tree/trunk/plugins/auto-sizes
  * Description: Improves responsive images with better sizes calculations and auto-sizes for lazy-loaded images.
- * Requires at least: 6.8
- * Requires PHP: 7.2
+ * Requires at least: 6.9
+ * Requires PHP: 7.4
  * Version: 1.7.0
  * Author: WordPress Performance Team
  * Author URI: https://make.wordpress.org/performance/
@@ -15,6 +15,8 @@
  * @package auto-sizes
  */
 
+declare( strict_types = 1 );
+
 // @codeCoverageIgnoreStart
 if ( ! defined( 'ABSPATH' ) ) {
 	exit; // Exit if accessed directly.
Index: hooks.php
===================================================================
--- hooks.php	(revision 3592034)
+++ hooks.php	(working copy)
@@ -6,6 +6,8 @@
  * @since 1.0.0
  */
 
+declare( strict_types = 1 );
+
 // @codeCoverageIgnoreStart
 if ( ! defined( 'ABSPATH' ) ) {
 	exit; // Exit if accessed directly.
Index: includes/improve-calculate-sizes.php
===================================================================
--- includes/improve-calculate-sizes.php	(revision 3592034)
+++ includes/improve-calculate-sizes.php	(working copy)
@@ -6,6 +6,14 @@
  * @since 1.4.0
  */
 
+declare( strict_types = 1 );
+
+// @codeCoverageIgnoreStart
+if ( ! defined( 'ABSPATH' ) ) {
+	exit; // Exit if accessed directly.
+}
+// @codeCoverageIgnoreEnd
+
 /*
  * Map alignment values to a weighting value so they can be compared.
  * Note that 'left' and 'right' alignments are only constrained by max alignment.

dominant-color-images

Warning

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

svn status:

M       class-dominant-color-image-editor-gd.php
M       class-dominant-color-image-editor-imagick.php
M       helper.php
M       hooks.php
M       load.php
svn diff
Index: class-dominant-color-image-editor-gd.php
===================================================================
--- class-dominant-color-image-editor-gd.php	(revision 3592034)
+++ class-dominant-color-image-editor-gd.php	(working copy)
@@ -8,6 +8,8 @@
  * @since 1.0.0
  */
 
+declare( strict_types = 1 );
+
 /**
  * WordPress Image Editor Class for Image Manipulation through GD
  * with dominant color detection.
Index: class-dominant-color-image-editor-imagick.php
===================================================================
--- class-dominant-color-image-editor-imagick.php	(revision 3592034)
+++ class-dominant-color-image-editor-imagick.php	(working copy)
@@ -8,6 +8,8 @@
  * @since 1.0.0
  */
 
+declare( strict_types = 1 );
+
 /**
  * WordPress Image Editor Class for Image Manipulation through Imagick
  * with dominant color detection.
Index: helper.php
===================================================================
--- helper.php	(revision 3592034)
+++ helper.php	(working copy)
@@ -7,6 +7,8 @@
  * @since 1.0.0
  */
 
+declare( strict_types = 1 );
+
 // @codeCoverageIgnoreStart
 if ( ! defined( 'ABSPATH' ) ) {
 	exit; // Exit if accessed directly.
Index: hooks.php
===================================================================
--- hooks.php	(revision 3592034)
+++ hooks.php	(working copy)
@@ -7,6 +7,8 @@
  * @since 1.0.0
  */
 
+declare( strict_types = 1 );
+
 // @codeCoverageIgnoreStart
 if ( ! defined( 'ABSPATH' ) ) {
 	exit; // Exit if accessed directly.
@@ -18,9 +20,9 @@
  *
  * @since 1.0.0
  *
- * @param array|mixed $metadata      The attachment metadata.
- * @param int         $attachment_id The attachment ID.
- * @return array{ has_transparency?: bool, dominant_color?: string } $metadata The attachment metadata.
+ * @param array<string, mixed>|mixed $metadata      The attachment metadata.
+ * @param int                        $attachment_id The attachment ID.
+ * @return array{ has_transparency?: bool, dominant_color?: string, ... } The attachment metadata.
  */
 function dominant_color_metadata( $metadata, int $attachment_id ): array {
 	if ( ! is_array( $metadata ) ) {
@@ -47,9 +49,9 @@
  *
  * @since 1.0.0
  *
- * @param array|mixed $attr       Attributes for the image markup.
- * @param WP_Post     $attachment Image attachment post.
- * @return array{ 'data-has-transparency'?: string, class?: string, 'data-dominant-color'?: string, style?: string } Attributes for the image markup.
+ * @param array<string, mixed>|mixed $attr       Attributes for the image markup.
+ * @param WP_Post                    $attachment Image attachment post.
+ * @return array{ 'data-has-transparency'?: string, class?: string, 'data-dominant-color'?: string, style?: string, ... } Attributes for the image markup.
  */
 function dominant_color_update_attachment_image_attributes( $attr, WP_Post $attachment ): array {
 	if ( ! is_array( $attr ) ) {
Index: load.php
===================================================================
--- load.php	(revision 3592034)
+++ load.php	(working copy)
@@ -3,8 +3,8 @@
  * Plugin Name: Image Placeholders
  * Plugin URI: https://github.com/WordPress/performance/tree/trunk/plugins/dominant-color-images
  * Description: Displays placeholders based on an image's dominant color while the image is loading.
- * Requires at least: 6.6
- * Requires PHP: 7.2
+ * Requires at least: 6.9
+ * Requires PHP: 7.4
  * Version: 1.2.1
  * Author: WordPress Performance Team
  * Author URI: https://make.wordpress.org/performance/
@@ -15,6 +15,8 @@
  * @package dominant-color-images
  */
 
+declare( strict_types = 1 );
+
 // @codeCoverageIgnoreStart
 if ( ! defined( 'ABSPATH' ) ) {
 	exit; // Exit if accessed directly.

embed-optimizer

Warning

Stable tag is unchanged at 1.0.0-beta5, so no plugin release will occur.

svn status:

M       class-embed-optimizer-tag-visitor.php
M       detect.js
M       helper.php
M       hooks.php
M       load.php
svn diff
Index: class-embed-optimizer-tag-visitor.php
===================================================================
--- class-embed-optimizer-tag-visitor.php	(revision 3592034)
+++ class-embed-optimizer-tag-visitor.php	(working copy)
@@ -6,6 +6,8 @@
  * @since 0.2.0
  */
 
+declare( strict_types = 1 );
+
 // @codeCoverageIgnoreStart
 if ( ! defined( 'ABSPATH' ) ) {
 	exit; // Exit if accessed directly.
@@ -27,7 +29,7 @@
 	 *
 	 * @var bool
 	 */
-	private $added_lazy_script = false;
+	private bool $added_lazy_script = false;
 
 	/**
 	 * Determines whether the processor is currently at a figure.wp-block-embed tag.
@@ -143,7 +145,7 @@
 		/**
 		 * Collection of the minimum heights for the element with each group keyed by the minimum viewport width.
 		 *
-		 * @var array<int, array{group: OD_URL_Metric_Group, height: int}> $minimums
+		 * @var array<int, array{group: OD_URL_Metric_Group, height: float}> $minimums
 		 */
 		$minimums = array();
 
@@ -189,7 +191,7 @@
 				$style_rule = sprintf(
 					'#%s { min-height: %dpx; }',
 					$this->escape_css( $element_id ),
-					$minimum['height']
+					(int) $minimum['height']
 				);
 
 				$media_feature = od_generate_media_query( $minimum['group']->get_minimum_viewport_width(), $minimum['group']->get_maximum_viewport_width() );
@@ -254,7 +256,7 @@
 				1 === $length &&
 				0x002D === $code_unit
 			) {
-				$result .= '\\' . $ident[ $i ];
+				$result .= '\\-';
 				continue;
 			}
 
Index: detect.js
===================================================================
--- detect.js	(revision 3592034)
+++ detect.js	(working copy)
@@ -63,10 +63,10 @@
 	log,
 	error
 ) {
-	if ( ! ( 'odXpath' in embedWrapper.dataset ) ) {
+	const xpath = embedWrapper.dataset.odXpath;
+	if ( ! xpath ) {
 		throw new Error( 'Embed wrapper missing data-od-xpath attribute.' );
 	}
-	const xpath = embedWrapper.dataset.odXpath;
 	const observer = new ResizeObserver( ( entries ) => {
 		const [ entry ] = entries;
 
@@ -75,12 +75,14 @@
 				resizedBoundingClientRect: entry.contentRect,
 			} );
 			const elementData = getElementData( xpath );
-			log(
-				`Resized element ${ xpath }:`,
-				elementData.boundingClientRect,
-				'=>',
-				entry.contentRect
-			);
+			if ( elementData ) {
+				log(
+					`Resized element ${ xpath }:`,
+					elementData.boundingClientRect,
+					'=>',
+					entry.contentRect
+				);
+			}
 		} catch ( err ) {
 			error(
 				`Failed to extend element data for ${ xpath } with resizedBoundingClientRect:`,
Index: helper.php
===================================================================
--- helper.php	(revision 3592034)
+++ helper.php	(working copy)
@@ -6,6 +6,8 @@
  * @package embed-optimizer
  */
 
+declare( strict_types = 1 );
+
 // @codeCoverageIgnoreStart
 if ( ! defined( 'ABSPATH' ) ) {
 	exit; // Exit if accessed directly.
@@ -91,8 +93,9 @@
  * @since 0.3.0
  * @access private
  *
- * @param array<string, array{type: string}> $additional_properties Additional properties.
- * @return array<string, array{type: string}> Additional properties.
+ * @param array<string, array<string, mixed>> $additional_properties Additional properties.
+ * @phpstan-param array<string, array{type: string, ...}> $additional_properties
+ * @return array<string, array{type: string, ...}> Additional properties.
  */
 function embed_optimizer_add_element_item_schema_properties( array $additional_properties ): array {
 	$additional_properties['resizedBoundingClientRect'] = array(
Index: hooks.php
===================================================================
--- hooks.php	(revision 3592034)
+++ hooks.php	(working copy)
@@ -6,6 +6,8 @@
  * @package embed-optimizer
  */
 
+declare( strict_types = 1 );
+
 // @codeCoverageIgnoreStart
 if ( ! defined( 'ABSPATH' ) ) {
 	exit; // Exit if accessed directly.
Index: load.php
===================================================================
--- load.php	(revision 3592034)
+++ load.php	(working copy)
@@ -3,8 +3,8 @@
  * Plugin Name: Embed Optimizer
  * Plugin URI: https://github.com/WordPress/performance/tree/trunk/plugins/embed-optimizer
  * Description: Optimizes the performance of embeds through lazy-loading, adding dns-prefetch links, and reserving space to reduce layout shifts.
- * Requires at least: 6.6
- * Requires PHP: 7.2
+ * Requires at least: 6.9
+ * Requires PHP: 7.4
  * Version: 1.0.0-beta5
  * Author: WordPress Performance Team
  * Author URI: https://make.wordpress.org/performance/
@@ -15,6 +15,8 @@
  * @package embed-optimizer
  */
 
+declare( strict_types = 1 );
+
 // @codeCoverageIgnoreStart
 if ( ! defined( 'ABSPATH' ) ) {
 	exit; // Exit if accessed directly.

image-prioritizer

Warning

Stable tag is unchanged at 1.0.0-beta3, so no plugin release will occur.

svn status:

M       class-image-prioritizer-background-image-styled-tag-visitor.php
M       class-image-prioritizer-img-tag-visitor.php
M       class-image-prioritizer-tag-visitor.php
M       class-image-prioritizer-video-tag-visitor.php
M       helper.php
M       hooks.php
M       lazy-load-video.js
M       load.php
svn diff
Index: class-image-prioritizer-background-image-styled-tag-visitor.php
===================================================================
--- class-image-prioritizer-background-image-styled-tag-visitor.php	(revision 3592034)
+++ class-image-prioritizer-background-image-styled-tag-visitor.php	(working copy)
@@ -6,6 +6,8 @@
  * @since 0.1.0
  */
 
+declare( strict_types = 1 );
+
 // @codeCoverageIgnoreStart
 if ( ! defined( 'ABSPATH' ) ) {
 	exit; // Exit if accessed directly.
@@ -41,15 +43,17 @@
 	 * @since 0.3.0
 	 * @var bool
 	 */
-	private $added_lazy_assets = false;
+	private bool $added_lazy_assets = false;
 
 	/**
 	 * Tuples of URL Metric group and the common LCP element external background image.
 	 *
+	 * Lazily populated in {@see self::maybe_preload_external_lcp_background_image()}.
+	 *
 	 * @since 0.3.0
-	 * @var array<array{OD_URL_Metric_Group, LcpElementExternalBackgroundImage}>
+	 * @var array<array{OD_URL_Metric_Group, LcpElementExternalBackgroundImage}>|null
 	 */
-	private $group_common_lcp_element_external_background_images;
+	private ?array $group_common_lcp_element_external_background_images = null;
 
 	/**
 	 * Visits a tag.
@@ -144,7 +148,7 @@
 		// Gather the tuples of URL Metric group and the common LCP element external background image.
 		// Note the groups of URL Metrics do not change across invocations, we just need to compute this once for all.
 		// TODO: Instead of populating this here, it could be done once per invocation during the od_start_template_optimization action since the page's OD_URL_Metric_Group_Collection is available there.
-		if ( ! is_array( $this->group_common_lcp_element_external_background_images ) ) {
+		if ( null === $this->group_common_lcp_element_external_background_images ) {
 			$this->group_common_lcp_element_external_background_images = array();
 			foreach ( $context->url_metric_group_collection as $group ) {
 				$common = $this->get_common_lcp_element_external_background_image( $group );
@@ -154,15 +158,17 @@
 			}
 		}
 
+		$tuples = $this->group_common_lcp_element_external_background_images;
+
 		// There are no common LCP background images, so abort.
-		if ( count( $this->group_common_lcp_element_external_background_images ) === 0 ) {
+		if ( count( $tuples ) === 0 ) {
 			return;
 		}
 
 		$processor = $context->processor;
 		$tag_name  = strtoupper( (string) $processor->get_tag() );
-		foreach ( array_keys( $this->group_common_lcp_element_external_background_images ) as $i ) {
-			list( $group, $common ) = $this->group_common_lcp_element_external_background_images[ $i ];
+		foreach ( array_keys( $tuples ) as $i ) {
+			list( $group, $common ) = $tuples[ $i ];
 			if (
 				// Note that the browser may send a lower-case tag name in the case of XHTML or embedded SVG/MathML, but
 				// the HTML Tag Processor is currently normalizing to all upper-case. The HTML Processor on the other
Index: class-image-prioritizer-img-tag-visitor.php
===================================================================
--- class-image-prioritizer-img-tag-visitor.php	(revision 3592034)
+++ class-image-prioritizer-img-tag-visitor.php	(working copy)
@@ -6,6 +6,8 @@
  * @since 0.1.0
  */
 
+declare( strict_types = 1 );
+
 // @codeCoverageIgnoreStart
 if ( ! defined( 'ABSPATH' ) ) {
 	exit; // Exit if accessed directly.
@@ -199,7 +201,7 @@
 	 *
 	 * @param OD_HTML_Tag_Processor  $processor HTML tag processor.
 	 * @param OD_Tag_Visitor_Context $context   Tag visitor context.
-	 * @return bool Whether the tag should be tracked in URL Metrics.
+	 * @return false Whether the tag should be tracked in URL Metrics; always false since PICTURE elements are not themselves tracked.
 	 */
 	private function process_picture( OD_HTML_Tag_Processor $processor, OD_Tag_Visitor_Context $context ): bool {
 		/**
@@ -329,9 +331,7 @@
 	private function add_image_preload_link_for_lcp_element_groups( OD_Tag_Visitor_Context $context, string $xpath, array $attributes ): void {
 		$attributes = array_filter(
 			$attributes,
-			static function ( $attribute_value ) {
-				return is_string( $attribute_value ) && '' !== $attribute_value;
-			}
+			static fn ( $attribute_value ) => is_string( $attribute_value ) && '' !== $attribute_value
 		);
 
 		/**
Index: class-image-prioritizer-tag-visitor.php
===================================================================
--- class-image-prioritizer-tag-visitor.php	(revision 3592034)
+++ class-image-prioritizer-tag-visitor.php	(working copy)
@@ -6,6 +6,8 @@
  * @since 0.1.0
  */
 
+declare( strict_types = 1 );
+
 // @codeCoverageIgnoreStart
 if ( ! defined( 'ABSPATH' ) ) {
 	exit; // Exit if accessed directly.
Index: class-image-prioritizer-video-tag-visitor.php
===================================================================
--- class-image-prioritizer-video-tag-visitor.php	(revision 3592034)
+++ class-image-prioritizer-video-tag-visitor.php	(working copy)
@@ -8,6 +8,8 @@
  * @since 0.2.0
  */
 
+declare( strict_types = 1 );
+
 // @codeCoverageIgnoreStart
 if ( ! defined( 'ABSPATH' ) ) {
 	exit; // Exit if accessed directly.
@@ -36,7 +38,7 @@
 	 * @since 0.2.0
 	 * @var bool
 	 */
-	protected $added_lazy_script = false;
+	protected bool $added_lazy_script = false;
 
 	/**
 	 * Visits a tag.
Index: helper.php
===================================================================
--- helper.php	(revision 3592034)
+++ helper.php	(working copy)
@@ -6,6 +6,8 @@
  * @since 0.1.0
  */
 
+declare( strict_types = 1 );
+
 // @codeCoverageIgnoreStart
 if ( ! defined( 'ABSPATH' ) ) {
 	exit; // Exit if accessed directly.
@@ -110,8 +112,9 @@
  * @since 0.3.0
  * @access private
  *
- * @param array<string, array{type: string}>|mixed $additional_properties Additional properties.
- * @return array<string, array{type: string}> Additional properties.
+ * @param array<string, mixed>|mixed $additional_properties Additional properties.
+ * @phpstan-param array<string, array{type: string, ...}>|mixed $additional_properties
+ * @return array<string, array{type: string, ...}> Additional properties.
  */
 function image_prioritizer_add_root_schema_properties( $additional_properties ): array {
 	if ( ! is_array( $additional_properties ) ) {
@@ -172,9 +175,7 @@
 	}
 
 	$allowed_hosts = array_map(
-		static function ( $host ) {
-			return wp_parse_url( $host, PHP_URL_HOST );
-		},
+		static fn ( $host ) => wp_parse_url( $host, PHP_URL_HOST ),
 		get_allowed_http_origins()
 	);
 
Index: hooks.php
===================================================================
--- hooks.php	(revision 3592034)
+++ hooks.php	(working copy)
@@ -6,6 +6,8 @@
  * @since 0.1.0
  */
 
+declare( strict_types = 1 );
+
 // @codeCoverageIgnoreStart
 if ( ! defined( 'ABSPATH' ) ) {
 	exit; // Exit if accessed directly.
Index: lazy-load-video.js
===================================================================
--- lazy-load-video.js	(revision 3592034)
+++ lazy-load-video.js	(working copy)
@@ -4,11 +4,9 @@
 			if ( entry.isIntersecting ) {
 				const video = /** @type {HTMLVideoElement} */ entry.target;
 
-				if ( video.hasAttribute( 'data-original-poster' ) ) {
-					video.setAttribute(
-						'poster',
-						video.getAttribute( 'data-original-poster' )
-					);
+				const poster = video.getAttribute( 'data-original-poster' );
+				if ( poster ) {
+					video.setAttribute( 'poster', poster );
 				}
 
 				if ( video.hasAttribute( 'data-original-autoplay' ) ) {
@@ -15,10 +13,8 @@
 					video.setAttribute( 'autoplay', 'autoplay' );
 				}
 
-				if ( video.hasAttribute( 'data-original-preload' ) ) {
-					const preload = video.getAttribute(
-						'data-original-preload'
-					);
+				const preload = video.getAttribute( 'data-original-preload' );
+				if ( preload ) {
 					if ( 'default' === preload ) {
 						video.removeAttribute( 'preload' );
 					} else {
Index: load.php
===================================================================
--- load.php	(revision 3592034)
+++ load.php	(working copy)
@@ -3,8 +3,8 @@
  * Plugin Name: Image Prioritizer
  * Plugin URI: https://github.com/WordPress/performance/tree/trunk/plugins/image-prioritizer
  * Description: Prioritizes the loading of images and videos based on how visible they are to actual visitors; adds <code>fetchpriority</code> and applies lazy-loading.
- * Requires at least: 6.6
- * Requires PHP: 7.2
+ * Requires at least: 6.9
+ * Requires PHP: 7.4
  * Requires Plugins: optimization-detective
  * Version: 1.0.0-beta3
  * Author: WordPress Performance Team
@@ -16,6 +16,8 @@
  * @package image-prioritizer
  */
 
+declare( strict_types = 1 );
+
 // @codeCoverageIgnoreStart
 if ( ! defined( 'ABSPATH' ) ) {
 	exit; // Exit if accessed directly.

optimization-detective

Warning

Stable tag is unchanged at 1.0.0-beta5, so no plugin release will occur.

svn status:

M       build/web-vitals-attribution.js
M       build/web-vitals.asset.php
M       build/web-vitals.js
M       class-od-data-validation-exception.php
M       class-od-element.php
M       class-od-html-tag-processor.php
M       class-od-link-collection.php
M       class-od-strict-url-metric.php
M       class-od-tag-visitor-context.php
M       class-od-tag-visitor-registry.php
M       class-od-template-optimization-context.php
M       class-od-url-metric-group-collection.php
M       class-od-url-metric-group.php
M       class-od-url-metric.php
M       class-od-visited-tag-state.php
!       deprecated.php
M       detect-loader.js
M       detect.js
M       detection.php
M       helper.php
M       hooks.php
M       load.php
M       optimization.php
M       site-health.php
M       storage/class-od-rest-url-metrics-store-endpoint.php
M       storage/class-od-storage-lock.php
M       storage/class-od-url-metric-store-request-context.php
M       storage/class-od-url-metrics-post-type.php
M       storage/data.php
M       uninstall.php
svn diff
Index: build/web-vitals-attribution.js
===================================================================
--- build/web-vitals-attribution.js	(revision 3592034)
+++ build/web-vitals-attribution.js	(working copy)
@@ -1 +1 @@
-class t{t;o=0;i=[];u(t){if(t.hadRecentInput)return;const e=this.i[0],n=this.i.at(-1);this.o&&e&&n&&t.startTime-n.startTime<1e3&&t.startTime-e.startTime<5e3?(this.o+=t.value,this.i.push(t)):(this.o=t.value,this.i=[t]),this.t?.(t)}}const e=()=>{const t=performance.getEntriesByType("navigation")[0];if(t&&t.responseStart>0&&t.responseStart<performance.now())return t},n=t=>{if("loading"===document.readyState)return"loading";{const n=e();if(n){if(t<n.domInteractive)return"loading";if(0===n.domContentLoadedEventStart||t<n.domContentLoadedEventStart)return"dom-interactive";if(0===n.domComplete||t<n.domComplete)return"dom-content-loaded"}}return"complete"},o=t=>{const e=t.nodeName;return 1===t.nodeType?e.toLowerCase():e.toUpperCase().replace(/^#/,"")},i=t=>{let e="";try{for(;9!==t?.nodeType;){const n=t,i=n.id?"#"+n.id:[o(n),...Array.from(n.classList).sort()].join(".");if(e.length+i.length>99)return e||i;if(e=e?i+">"+e:i,n.id)break;t=n.parentNode}}catch{}return e},r=new WeakMap;function s(t,e){return r.get(t)||r.set(t,new e),r.get(t)}let a=-1;const c=()=>a,f=t=>{addEventListener("pageshow",(e=>{e.persisted&&(a=e.timeStamp,t(e))}),!0)},u=(t,e,n,o)=>{let i,r;return s=>{e.value>=0&&(s||o)&&(r=e.value-(i??0),(r||void 0===i)&&(i=e.value,e.delta=r,e.rating=((t,e)=>t>e[1]?"poor":t>e[0]?"needs-improvement":"good")(e.value,n),t(e)))}},d=t=>{requestAnimationFrame((()=>requestAnimationFrame((()=>t()))))},l=()=>{const t=e();return t?.activationStart??0},h=(t,n=-1)=>{const o=e();let i="navigate";c()>=0?i="back-forward-cache":o&&(document.prerendering||l()>0?i="prerender":document.wasDiscarded?i="restore":o.type&&(i=o.type.replace(/_/g,"-")));return{name:t,value:n,rating:"good",delta:0,entries:[],id:`v5-${Date.now()}-${Math.floor(8999999999999*Math.random())+1e12}`,navigationType:i}},m=(t,e,n={})=>{try{if(PerformanceObserver.supportedEntryTypes.includes(t)){const o=new PerformanceObserver((t=>{Promise.resolve().then((()=>{e(t.getEntries())}))}));return o.observe({type:t,buffered:!0,...n}),o}}catch{}},p=t=>{let e=!1;return()=>{e||(t(),e=!0)}};let g=-1;const y=new Set,v=()=>"hidden"!==document.visibilityState||document.prerendering?1/0:0,b=t=>{if("hidden"===document.visibilityState){if("visibilitychange"===t.type)for(const t of y)t();isFinite(g)||(g="visibilitychange"===t.type?t.timeStamp:0,removeEventListener("prerenderingchange",b,!0))}},M=()=>{if(g<0){const t=l(),e=document.prerendering?void 0:globalThis.performance.getEntriesByType("visibility-state").filter((e=>"hidden"===e.name&&e.startTime>t))[0]?.startTime;g=e??v(),addEventListener("visibilitychange",b,!0),addEventListener("prerenderingchange",b,!0),f((()=>{setTimeout((()=>{g=v()}))}))}return{get firstHiddenTime(){return g},onHidden(t){y.add(t)}}},T=t=>{document.prerendering?addEventListener("prerenderingchange",(()=>t()),!0):t()},E=[1800,3e3],D=(t,e={})=>{T((()=>{const n=M();let o,i=h("FCP");const r=m("paint",(t=>{for(const e of t)"first-contentful-paint"===e.name&&(r.disconnect(),e.startTime<n.firstHiddenTime&&(i.value=Math.max(e.startTime-l(),0),i.entries.push(e),o(!0)))}));r&&(o=u(t,i,E,e.reportAllChanges),f((n=>{i=h("FCP"),o=u(t,i,E,e.reportAllChanges),d((()=>{i.value=performance.now()-n.timeStamp,o(!0)}))})))}))},L=[.1,.25],P=t=>t.find((t=>1===t.node?.nodeType))||t[0],S=(e,o={})=>{const r=s(o=Object.assign({},o),t),a=new WeakMap;r.t=t=>{if(t?.sources?.length){const e=P(t.sources),n=e?.node;if(n){const t=o.generateTarget?.(n)??i(n);a.set(e,t)}}};((e,n={})=>{const o=M();D(p((()=>{let i,r=h("CLS",0);const a=s(n,t),c=t=>{for(const e of t)a.u(e);a.o>r.value&&(r.value=a.o,r.entries=a.i,i())},l=m("layout-shift",c);l&&(i=u(e,r,L,n.reportAllChanges),o.onHidden((()=>{c(l.takeRecords()),i(!0)})),f((()=>{a.o=0,r=h("CLS",0),i=u(e,r,L,n.reportAllChanges),d((()=>i()))})),setTimeout(i))})))})((t=>{const o=(t=>{let e={};if(t.entries.length){const o=t.entries.reduce(((t,e)=>t.value>e.value?t:e));if(o?.sources?.length){const t=P(o.sources);t&&(e={largestShiftTarget:a.get(t),largestShiftTime:o.startTime,largestShiftValue:o.value,largestShiftSource:t,largestShiftEntry:o,loadState:n(o.startTime)})}}return Object.assign(t,{attribution:e})})(t);e(o)}),o)},w=(t,o={})=>{D((o=>{const i=(t=>{let o={timeToFirstByte:0,firstByteToFCP:t.value,loadState:n(c())};if(t.entries.length){const i=e(),r=t.entries.at(-1);if(i){const e=i.activationStart||0,s=Math.max(0,i.responseStart-e);o={timeToFirstByte:s,firstByteToFCP:t.value-s,loadState:n(t.entries[0].startTime),navigationEntry:i,fcpEntry:r}}}return Object.assign(t,{attribution:o})})(o);t(i)}),o)};let _=0,F=1/0,k=0;const B=t=>{for(const e of t)e.interactionId&&(F=Math.min(F,e.interactionId),k=Math.max(k,e.interactionId),_=k?(k-F)/7+1:0)};let C;const O=()=>C?_:performance.interactionCount??0,j=()=>{"interactionCount"in performance||C||(C=m("event",B,{type:"event",buffered:!0,durationThreshold:0}))};let I=0;class A{l=[];h=new Map;m;p;v(){I=O(),this.l.length=0,this.h.clear()}M(){const t=Math.min(this.l.length-1,Math.floor((O()-I)/50));return this.l[t]}u(t){if(this.m?.(t),!t.interactionId&&"first-input"!==t.entryType)return;const e=this.l.at(-1);let n=this.h.get(t.interactionId);if(n||this.l.length<10||t.duration>e.T){if(n?t.duration>n.T?(n.entries=[t],n.T=t.duration):t.duration===n.T&&t.startTime===n.entries[0].startTime&&n.entries.push(t):(n={id:t.interactionId,entries:[t],T:t.duration},this.h.set(n.id,n),this.l.push(n)),this.l.sort(((t,e)=>e.T-t.T)),this.l.length>10){const t=this.l.splice(10);for(const e of t)this.h.delete(e.id)}this.p?.(n)}}}const W=t=>{const e=globalThis.requestIdleCallback||setTimeout;"hidden"===document.visibilityState?t():(t=p(t),addEventListener("visibilitychange",t,{once:!0,capture:!0}),e((()=>{t(),removeEventListener("visibilitychange",t,{capture:!0})})))},q=[200,500],x=(t,e={})=>{const o=s(e=Object.assign({},e),A);let r=[],a=[],c=0;const d=new WeakMap,l=new WeakMap;let p=!1;const g=()=>{p||(W(y),p=!0)},y=()=>{const t=o.l.map((t=>d.get(t.entries[0]))),e=a.length-50;a=a.filter(((n,o)=>o>=e||t.includes(n)));const n=new Set;for(const t of a){const e=v(t.startTime,t.processingEnd);for(const t of e)n.add(t)}const i=r.length-1-50;r=r.filter(((t,e)=>t.startTime>c&&e>i||n.has(t))),p=!1};o.m=t=>{const e=t.startTime+t.duration;let n;c=Math.max(c,t.processingEnd);for(let o=a.length-1;o>=0;o--){const i=a[o];if(Math.abs(e-i.renderTime)<=8){n=i,n.startTime=Math.min(t.startTime,n.startTime),n.processingStart=Math.min(t.processingStart,n.processingStart),n.processingEnd=Math.max(t.processingEnd,n.processingEnd),n.entries.push(t);break}}n||(n={startTime:t.startTime,processingStart:t.processingStart,processingEnd:t.processingEnd,renderTime:e,entries:[t]},a.push(n)),(t.interactionId||"first-input"===t.entryType)&&d.set(t,n),g()},o.p=t=>{if(!l.get(t)){const n=t.entries[0].target;if(n){const o=e.generateTarget?.(n)??i(n);l.set(t,o)}}};const v=(t,e)=>{const n=[];for(const o of r)if(!(o.startTime+o.duration<t)){if(o.startTime>e)break;n.push(o)}return n},b=t=>{const e=t.entries[0],i=d.get(e),r=e.processingStart,s=Math.max(e.startTime+e.duration,r),a=Math.min(i.processingEnd,s),c=i.entries.sort(((t,e)=>t.processingStart-e.processingStart)),f=v(e.startTime,a),u=o.h.get(e.interactionId),h={interactionTarget:l.get(u),interactionType:e.name.startsWith("key")?"keyboard":"pointer",interactionTime:e.startTime,nextPaintTime:s,processedEventEntries:c,longAnimationFrameEntries:f,inputDelay:r-e.startTime,processingDuration:a-r,presentationDelay:s-a,loadState:n(e.startTime),longestScript:void 0,totalScriptDuration:void 0,totalStyleAndLayoutDuration:void 0,totalPaintDuration:void 0,totalUnattributedDuration:void 0};(t=>{if(!t.longAnimationFrameEntries?.length)return;const e=t.interactionTime,n=t.inputDelay,o=t.processingDuration;let i,r,s=0,a=0,c=0,f=0;for(const c of t.longAnimationFrameEntries){a=a+c.startTime+c.duration-c.styleAndLayoutStart;for(const t of c.scripts){const c=t.startTime+t.duration;if(c<e)continue;const u=c-Math.max(e,t.startTime),d=t.duration?u/t.duration*t.forcedStyleAndLayoutDuration:0;s+=u-d,a+=d,u>f&&(r=t.startTime<e+n?"input-delay":t.startTime>=e+n+o?"presentation-delay":"processing-duration",i=t,f=u)}}const u=t.longAnimationFrameEntries.at(-1),d=u?u.startTime+u.duration:0;d>=e+n+o&&(c=t.nextPaintTime-d),i&&r&&(t.longestScript={entry:i,subpart:r,intersectingDuration:f}),t.totalScriptDuration=s,t.totalStyleAndLayoutDuration=a,t.totalPaintDuration=c,t.totalUnattributedDuration=t.nextPaintTime-e-s-a-c})(h);return Object.assign(t,{attribution:h})};m("long-animation-frame",(t=>{r=r.concat(t),g()})),((t,e={})=>{if(!globalThis.PerformanceEventTiming||!("interactionId"in PerformanceEventTiming.prototype))return;const n=M();T((()=>{j();let o,i=h("INP");const r=s(e,A),a=t=>{W((()=>{for(const e of t)r.u(e);const e=r.M();e&&e.T!==i.value&&(i.value=e.T,i.entries=e.entries,o())}))},c=m("event",a,{durationThreshold:e.durationThreshold??40});o=u(t,i,q,e.reportAllChanges),c&&(c.observe({type:"first-input",buffered:!0}),n.onHidden((()=>{a(c.takeRecords()),o(!0)})),f((()=>{r.v(),i=h("INP"),o=u(t,i,q,e.reportAllChanges)})))}))})((e=>{const n=b(e);t(n)}),e)};class N{m;u(t){this.m?.(t)}}const H=[2500,4e3],R=(t,n={})=>{const o=s(n=Object.assign({},n),N),r=new WeakMap;o.m=t=>{const e=t.element;if(e){const o=n.generateTarget?.(e)??i(e);r.set(t,o)}};((t,e={})=>{T((()=>{const n=M();let o,i=h("LCP");const r=s(e,N),a=t=>{e.reportAllChanges||(t=t.slice(-1));for(const e of t)r.u(e),e.startTime<n.firstHiddenTime&&(i.value=Math.max(e.startTime-l(),0),i.entries=[e],o())},c=m("largest-contentful-paint",a);if(c){o=u(t,i,H,e.reportAllChanges);const n=p((()=>{a(c.takeRecords()),c.disconnect(),o(!0)})),r=t=>{t.isTrusted&&(W(n),removeEventListener(t.type,r,{capture:!0}))};for(const t of["keydown","click","visibilitychange"])addEventListener(t,r,{capture:!0});f((n=>{i=h("LCP"),o=u(t,i,H,e.reportAllChanges),d((()=>{i.value=performance.now()-n.timeStamp,o(!0)}))}))}}))})((n=>{const o=(t=>{let n={timeToFirstByte:0,resourceLoadDelay:0,resourceLoadDuration:0,elementRenderDelay:t.value};if(t.entries.length){const o=e();if(o){const e=o.activationStart||0,i=t.entries.at(-1),s=i.url&&performance.getEntriesByType("resource").filter((t=>t.name===i.url))[0],a=Math.max(0,o.responseStart-e),c=Math.max(a,s?(s.requestStart||s.startTime)-e:0),f=Math.min(t.value,Math.max(c,s?s.responseEnd-e:0));n={target:r.get(i),timeToFirstByte:a,resourceLoadDelay:c-a,resourceLoadDuration:f-c,elementRenderDelay:t.value-f,navigationEntry:o,lcpEntry:i},i.url&&(n.url=i.url),s&&(n.lcpResourceEntry=s)}}return Object.assign(t,{attribution:n})})(n);t(o)}),n)},U=[800,1800],V=t=>{document.prerendering?T((()=>V(t))):"complete"!==document.readyState?addEventListener("load",(()=>V(t)),!0):setTimeout(t)},$=(t,n={})=>{((t,n={})=>{let o=h("TTFB"),i=u(t,o,U,n.reportAllChanges);V((()=>{const r=e();r&&(o.value=Math.max(r.responseStart-l(),0),o.entries=[r],i(!0),f((()=>{o=h("TTFB",0),i=u(t,o,U,n.reportAllChanges),i(!0)})))}))})((e=>{const n=(t=>{let e={waitingDuration:0,cacheDuration:0,dnsDuration:0,connectionDuration:0,requestDuration:0};if(t.entries.length){const n=t.entries[0],o=n.activationStart||0,i=Math.max((n.workerStart||n.fetchStart)-o,0),r=Math.max(n.domainLookupStart-o,0),s=Math.max(n.connectStart-o,0),a=Math.max(n.connectEnd-o,0);e={waitingDuration:i,cacheDuration:r-i,dnsDuration:s-r,connectionDuration:a-s,requestDuration:t.value-a,navigationEntry:n}}return Object.assign(t,{attribution:e})})(e);t(n)}),n)};export{L as CLSThresholds,E as FCPThresholds,q as INPThresholds,H as LCPThresholds,U as TTFBThresholds,S as onCLS,w as onFCP,x as onINP,R as onLCP,$ as onTTFB};
+class t{t;o=0;i=[];u(t){if(t.hadRecentInput)return;const e=this.i[0],n=this.i.at(-1);this.o&&e&&n&&t.startTime-n.startTime<1e3&&t.startTime-e.startTime<5e3?(this.o+=t.value,this.i.push(t)):(this.o=t.value,this.i=[t]),this.t?.(t)}}const e=()=>{const t=performance.getEntriesByType("navigation")[0];if(t&&t.responseStart>0&&t.responseStart<performance.now())return t},n=t=>{if("loading"===document.readyState)return"loading";const n=e();if(n){if(t<n.domInteractive)return"loading";if(0===n.domContentLoadedEventStart||t<n.domContentLoadedEventStart)return"dom-interactive";if(0===n.domComplete||t<n.domComplete)return"dom-content-loaded"}return"complete"},o=t=>{const e=t.nodeName;return 1===t.nodeType?e.toLowerCase():e.toUpperCase().replace(/^#/,"")},i=t=>{let e="";try{for(;9!==t?.nodeType;){const n=t,i=n.id?"#"+n.id:[o(n),...Array.from(n.classList).sort()].join(".");if(e.length+i.length>99)return e||i;if(e=e?i+">"+e:i,n.id)break;t=n.parentNode}}catch{}return e},r=new WeakMap;function s(t,e){let n=r.get(e);return n||(n=new WeakMap,r.set(e,n)),n.get(t)||n.set(t,new e),n.get(t)}let a=-1;const c=()=>a,u=t=>{addEventListener("pageshow",e=>{e.persisted&&(a=e.timeStamp,t(e))},!0)},f=(t,e,n,o)=>{let i,r;return s=>{e.value>=0&&(s||o)&&(r=e.value-(i??0),(r||void 0===i)&&(i=e.value,e.delta=r,e.rating=((t,e)=>t>e[1]?"poor":t>e[0]?"needs-improvement":"good")(e.value,n),t(e)))}},l=t=>{requestAnimationFrame(()=>requestAnimationFrame(t))},d=()=>e()?.activationStart??0,h=(t,n=-1)=>{const o=e();let i="navigate";c()>=0?i="back-forward-cache":o&&(document.prerendering||d()>0?i="prerender":document.wasDiscarded?i="restore":o.type&&(i=o.type.replace(/_/g,"-")));return{name:t,value:n,rating:"good",delta:0,entries:[],id:`v5-${Date.now()}-${Math.floor(8999999999999*Math.random())+1e12}`,navigationType:i}},m=(t,e,n={})=>{try{if(PerformanceObserver.supportedEntryTypes.includes(t)){const o=new PerformanceObserver(t=>{queueMicrotask(()=>{e(t.getEntries())})});return o.observe({type:t,buffered:!0,...n}),o}}catch{}},g=t=>{let e=!1;return()=>{e||(t(),e=!0)}};let p=-1;const y=new Set,v=()=>"hidden"!==document.visibilityState||document.prerendering?1/0:0,b=t=>{if("hidden"===document.visibilityState){if("visibilitychange"===t.type)for(const t of y)t();isFinite(p)||(p="visibilitychange"===t.type?t.timeStamp:0,removeEventListener("prerenderingchange",b,!0))}},M=()=>{if(p<0){const t=d(),e=document.prerendering?void 0:globalThis.performance.getEntriesByType("visibility-state").find(e=>"hidden"===e.name&&e.startTime>=t)?.startTime;p=e??v(),addEventListener("visibilitychange",b,!0),addEventListener("prerenderingchange",b,!0),u(()=>{setTimeout(()=>{p=v()})})}return{get firstHiddenTime(){return p},onHidden(t){y.add(t)}}},T=t=>{document.prerendering?addEventListener("prerenderingchange",t,!0):t()},E=[1800,3e3],D=(t,e={})=>{T(()=>{const n=M();let o,i=h("FCP");const r=m("paint",t=>{for(const e of t)"first-contentful-paint"===e.name&&(r.disconnect(),e.startTime<n.firstHiddenTime&&(i.value=Math.max(e.startTime-d(),0),i.entries.push(e),o(!0)))});r&&(o=f(t,i,E,e.reportAllChanges),u(n=>{i=h("FCP"),o=f(t,i,E,e.reportAllChanges),l(()=>{i.value=performance.now()-n.timeStamp,o(!0)})}))})},L=[.1,.25],S=t=>t.find(t=>1===t.node?.nodeType)||t[0],P=(e,o={})=>{const r=s(o=Object.assign({},o),t),a=new WeakMap;r.t=t=>{if(t?.sources?.length){const e=S(t.sources),n=e?.node;if(n){const t=o.generateTarget?.(n)??i(n);a.set(e,t)}}};((e,n={})=>{const o=M();D(g(()=>{let i,r=h("CLS",0);const a=s(n,t),c=t=>{for(const e of t)a.u(e);a.o>r.value&&(r.value=a.o,r.entries=a.i,i())},d=m("layout-shift",c);d&&(i=f(e,r,L,n.reportAllChanges),o.onHidden(()=>{c(d.takeRecords()),i(!0)}),u(()=>{a.o=0,r=h("CLS",0),i=f(e,r,L,n.reportAllChanges),l(i)}),setTimeout(i))}))})(t=>{e((t=>{let e={};if(t.entries.length){const o=t.entries.reduce((t,e)=>t.value>e.value?t:e);if(o?.sources?.length){const t=S(o.sources);t&&(e={largestShiftTarget:a.get(t),largestShiftTime:o.startTime,largestShiftValue:o.value,largestShiftSource:t,largestShiftEntry:o,loadState:n(o.startTime)})}}return Object.assign(t,{attribution:e})})(t))},o)},w=(t,o={})=>{D(o=>{t((t=>{let o={timeToFirstByte:0,firstByteToFCP:t.value,loadState:n(c())};if(t.entries.length){const i=e(),r=t.entries.at(-1);if(i){const e=i.activationStart||0,s=Math.max(0,i.responseStart-e);o={timeToFirstByte:s,firstByteToFCP:t.value-s,loadState:n(t.entries[0].startTime),navigationEntry:i,fcpEntry:r}}}return Object.assign(t,{attribution:o})})(o))},o)};let k=0,_=1/0,F=0;const B=t=>{for(const e of t)e.interactionId&&(_=Math.min(_,e.interactionId),F=Math.max(F,e.interactionId),k=F?(F-_)/7+1:0)};let C;const O=()=>C?k:performance.interactionCount??0,j=()=>{"interactionCount"in performance||C||(C=m("event",B,{durationThreshold:0}))};let I=0;class A{l=[];h=new Map;m;p;v(){I=O(),this.l.length=0,this.h.clear()}M(){const t=Math.min(this.l.length-1,Math.floor((O()-I)/50));return this.l[t]}u(t){if(this.m?.(t),!t.interactionId&&"first-input"!==t.entryType)return;const e=this.l.at(-1);let n=this.h.get(t.interactionId);if(n||this.l.length<10||t.duration>e.T){if(n?t.duration>n.T?(n.entries=[t],n.T=t.duration):t.duration===n.T&&t.startTime===n.entries[0].startTime&&n.entries.push(t):(n={id:t.interactionId,entries:[t],T:t.duration},this.h.set(n.id,n),this.l.push(n)),this.l.sort((t,e)=>e.T-t.T),this.l.length>10){const t=this.l.splice(10);for(const e of t)this.h.delete(e.id)}this.p?.(n)}}}const W=t=>{const e=globalThis.requestIdleCallback||setTimeout,n=globalThis.cancelIdleCallback||clearTimeout;if("hidden"===document.visibilityState)t();else{const o=g(t);let i=-1;const r=()=>{n(i),o()};addEventListener("visibilitychange",r,{once:!0,capture:!0}),i=e(()=>{removeEventListener("visibilitychange",r,{capture:!0}),o()})}},q=[200,500],x=(t,e={})=>{const o=s(e=Object.assign({},e),A);let r=[],a=[],c=0;const l=new WeakMap,d=new WeakMap;let g=!1;const p=()=>{g||(W(y),g=!0)},y=()=>{const t=new Set(o.l.map(t=>l.get(t.entries[0]))),e=a.length-10;a=a.filter((n,o)=>o>=e||t.has(n));const n=new Set;for(const t of a){const e=v(t.startTime,t.processingEnd);for(const t of e)n.add(t)}r=r.filter(t=>t.startTime>c||n.has(t)),g=!1};o.m=t=>{const n=t.startTime+t.duration;let o;c=Math.max(c,t.processingEnd);for(let i=a.length-1;i>=0;i--){const r=a[i];if(Math.abs(n-r.renderTime)<=8){o=r,o.startTime=Math.min(t.startTime,o.startTime),o.processingStart=Math.min(t.processingStart,o.processingStart),o.processingEnd=Math.max(t.processingEnd,o.processingEnd),!1!==e.includeProcessedEventEntries&&o.entries.push(t);break}}o||(o={startTime:t.startTime,processingStart:t.processingStart,processingEnd:t.processingEnd,renderTime:n,entries:!1!==e.includeProcessedEventEntries?[t]:[]},a.push(o)),(t.interactionId||"first-input"===t.entryType)&&l.set(t,o),p()},o.p=t=>{if(!d.get(t)){const n=t.entries.find(t=>t.target)?.target;if(n){const o=e.generateTarget?.(n)??i(n);d.set(t,o)}else{const e=t.entries.find(t=>t.targetSelector)?.targetSelector;e&&d.set(t,e)}}};const v=(t,e)=>{const n=[];for(const o of r)if(!(o.startTime+o.duration<t)){if(o.startTime>e)break;n.push(o)}return n},b=t=>{const e=t.entries[0],i=l.get(e),r=e.processingStart,s=Math.max(e.startTime+e.duration,r),a=Math.min(i.processingEnd,s),c=i.entries.sort((t,e)=>t.processingStart-e.processingStart),u=v(e.startTime,a),f=o.h.get(e.interactionId),h={interactionTarget:d.get(f),interactionType:e.name.startsWith("key")?"keyboard":"pointer",interactionTime:e.startTime,nextPaintTime:s,processedEventEntries:c,longAnimationFrameEntries:u,inputDelay:r-e.startTime,processingDuration:a-r,presentationDelay:s-a,loadState:n(e.startTime),longestScript:void 0,totalScriptDuration:void 0,totalStyleAndLayoutDuration:void 0,totalPaintDuration:void 0,totalUnattributedDuration:void 0};return(t=>{if(!t.longAnimationFrameEntries?.length)return;const e=t.interactionTime,n=t.inputDelay,o=t.processingDuration;let i,r,s=0,a=0,c=0,u=0;for(const c of t.longAnimationFrameEntries){a=a+c.startTime+c.duration-c.styleAndLayoutStart;for(const t of c.scripts){const c=t.startTime+t.duration;if(c<e)continue;const f=c-Math.max(e,t.startTime),l=t.duration?f/t.duration*t.forcedStyleAndLayoutDuration:0;s+=f-l,a+=l,f>u&&(r=t.startTime<e+n?"input-delay":t.startTime>=e+n+o?"presentation-delay":"processing-duration",i=t,u=f)}}const f=t.longAnimationFrameEntries.at(-1),l=f?f.startTime+f.duration:0;l>=e+n+o&&(c=t.nextPaintTime-l),i&&r&&(t.longestScript={entry:i,subpart:r,intersectingDuration:u}),t.totalScriptDuration=s,t.totalStyleAndLayoutDuration=a,t.totalPaintDuration=c,t.totalUnattributedDuration=t.nextPaintTime-e-s-a-c})(h),Object.assign(t,{attribution:h})};m("long-animation-frame",t=>{r=r.concat(t),p()}),((t,e={})=>{if(!globalThis.PerformanceEventTiming||!("interactionId"in PerformanceEventTiming.prototype))return;const n=M();T(()=>{j();let o,i=h("INP");const r=s(e,A),a=t=>{W(()=>{for(const e of t)r.u(e);const e=r.M();e&&e.T!==i.value&&(i.value=e.T,i.entries=e.entries,o())})},c=m("event",a,{durationThreshold:e.durationThreshold??40});o=f(t,i,q,e.reportAllChanges),c&&(c.observe({type:"first-input",buffered:!0}),n.onHidden(()=>{a(c.takeRecords()),o(!0)}),u(()=>{r.v(),i=h("INP"),o=f(t,i,q,e.reportAllChanges)}))})})(e=>{t(b(e))},e)};class N{m;u(t){this.m?.(t)}}const $=[2500,4e3],H=(t,n={})=>{const o=s(n=Object.assign({},n),N),r=new WeakMap;o.m=t=>{const e=t.element;if(e){const o=n.generateTarget?.(e)??i(e);r.set(t,o)}else t.id&&r.set(t,`#${t.id}`)};((t,e={})=>{T(()=>{const n=M();let o,i=h("LCP");const r=s(e,N),a=t=>{e.reportAllChanges||(t=t.slice(-1));for(const e of t)r.u(e),e.startTime<n.firstHiddenTime&&(i.value=Math.max(e.startTime-d(),0),i.entries=[e],o())},c=m("largest-contentful-paint",a);if(c){o=f(t,i,$,e.reportAllChanges);const n=g(()=>{a(c.takeRecords()),c.disconnect(),o(!0)}),r=t=>{t.isTrusted&&(W(n),removeEventListener(t.type,r,{capture:!0}))};for(const t of["keydown","click","visibilitychange"])addEventListener(t,r,{capture:!0});u(n=>{i=h("LCP"),o=f(t,i,$,e.reportAllChanges),l(()=>{i.value=performance.now()-n.timeStamp,o(!0)})})}})})(n=>{t((t=>{let n={timeToFirstByte:0,resourceLoadDelay:0,resourceLoadDuration:0,elementRenderDelay:t.value};if(t.entries.length){const o=t.entries.at(-1),i=o.url&&performance.getEntriesByType("resource").find(t=>t.name===o.url);n.target=r.get(o),n.lcpEntry=o,o.url&&(n.url=o.url),i&&(n.lcpResourceEntry=i);const s=e();if(s){const e=s.activationStart||0,o=Math.max(0,s.responseStart-e),r=Math.max(o,i?(i.requestStart||i.startTime)-e:0),a=Math.min(t.value,Math.max(r,i?i.responseEnd-e:0));n={...n,timeToFirstByte:o,resourceLoadDelay:r-o,resourceLoadDuration:a-r,elementRenderDelay:t.value-a,navigationEntry:s}}}return Object.assign(t,{attribution:n})})(n))},n)},R=[800,1800],U=t=>{document.prerendering?T(()=>U(t)):"complete"!==document.readyState?addEventListener("load",()=>U(t),!0):setTimeout(t)},V=(t,n={})=>{((t,n={})=>{let o=h("TTFB"),i=f(t,o,R,n.reportAllChanges);U(()=>{const r=e();r&&(o.value=Math.max(r.responseStart-d(),0),o.entries=[r],i(!0),u(()=>{o=h("TTFB",0),i=f(t,o,R,n.reportAllChanges),i(!0)}))})})(e=>{t((t=>{let e={waitingDuration:0,cacheDuration:0,dnsDuration:0,connectionDuration:0,requestDuration:0};if(t.entries.length){const n=t.entries[0],o=n.activationStart||0,i=Math.max((n.workerStart||n.fetchStart)-o,0),r=Math.max(n.domainLookupStart-o,0),s=Math.max(n.connectStart-o,0),a=Math.max(n.connectEnd-o,0);e={waitingDuration:i,cacheDuration:r-i,dnsDuration:s-r,connectionDuration:a-s,requestDuration:t.value-a,navigationEntry:n}}return Object.assign(t,{attribution:e})})(e))},n)};export{L as CLSThresholds,E as FCPThresholds,q as INPThresholds,$ as LCPThresholds,R as TTFBThresholds,P as onCLS,w as onFCP,x as onINP,H as onLCP,V as onTTFB};
Index: build/web-vitals.asset.php
===================================================================
--- build/web-vitals.asset.php	(revision 3592034)
+++ build/web-vitals.asset.php	(working copy)
@@ -1 +1 @@
-<?php return array('dependencies' => array(), 'version' => '5.1.0');
\ No newline at end of file
+<?php return array('dependencies' => array(), 'version' => '5.3.0');
\ No newline at end of file
Index: build/web-vitals.js
===================================================================
--- build/web-vitals.js	(revision 3592034)
+++ build/web-vitals.js	(working copy)
@@ -1 +1 @@
-let e=-1;const t=t=>{addEventListener("pageshow",(n=>{n.persisted&&(e=n.timeStamp,t(n))}),!0)},n=(e,t,n,i)=>{let s,o;return r=>{t.value>=0&&(r||i)&&(o=t.value-(s??0),(o||void 0===s)&&(s=t.value,t.delta=o,t.rating=((e,t)=>e>t[1]?"poor":e>t[0]?"needs-improvement":"good")(t.value,n),e(t)))}},i=e=>{requestAnimationFrame((()=>requestAnimationFrame((()=>e()))))},s=()=>{const e=performance.getEntriesByType("navigation")[0];if(e&&e.responseStart>0&&e.responseStart<performance.now())return e},o=()=>{const e=s();return e?.activationStart??0},r=(t,n=-1)=>{const i=s();let r="navigate";e>=0?r="back-forward-cache":i&&(document.prerendering||o()>0?r="prerender":document.wasDiscarded?r="restore":i.type&&(r=i.type.replace(/_/g,"-")));return{name:t,value:n,rating:"good",delta:0,entries:[],id:`v5-${Date.now()}-${Math.floor(8999999999999*Math.random())+1e12}`,navigationType:r}},c=new WeakMap;function a(e,t){return c.get(e)||c.set(e,new t),c.get(e)}class d{t;i=0;o=[];h(e){if(e.hadRecentInput)return;const t=this.o[0],n=this.o.at(-1);this.i&&t&&n&&e.startTime-n.startTime<1e3&&e.startTime-t.startTime<5e3?(this.i+=e.value,this.o.push(e)):(this.i=e.value,this.o=[e]),this.t?.(e)}}const h=(e,t,n={})=>{try{if(PerformanceObserver.supportedEntryTypes.includes(e)){const i=new PerformanceObserver((e=>{Promise.resolve().then((()=>{t(e.getEntries())}))}));return i.observe({type:e,buffered:!0,...n}),i}}catch{}},f=e=>{let t=!1;return()=>{t||(e(),t=!0)}};let u=-1;const l=new Set,m=()=>"hidden"!==document.visibilityState||document.prerendering?1/0:0,p=e=>{if("hidden"===document.visibilityState){if("visibilitychange"===e.type)for(const e of l)e();isFinite(u)||(u="visibilitychange"===e.type?e.timeStamp:0,removeEventListener("prerenderingchange",p,!0))}},v=()=>{if(u<0){const e=o(),n=document.prerendering?void 0:globalThis.performance.getEntriesByType("visibility-state").filter((t=>"hidden"===t.name&&t.startTime>e))[0]?.startTime;u=n??m(),addEventListener("visibilitychange",p,!0),addEventListener("prerenderingchange",p,!0),t((()=>{setTimeout((()=>{u=m()}))}))}return{get firstHiddenTime(){return u},onHidden(e){l.add(e)}}},g=e=>{document.prerendering?addEventListener("prerenderingchange",(()=>e()),!0):e()},y=[1800,3e3],E=(e,s={})=>{g((()=>{const c=v();let a,d=r("FCP");const f=h("paint",(e=>{for(const t of e)"first-contentful-paint"===t.name&&(f.disconnect(),t.startTime<c.firstHiddenTime&&(d.value=Math.max(t.startTime-o(),0),d.entries.push(t),a(!0)))}));f&&(a=n(e,d,y,s.reportAllChanges),t((t=>{d=r("FCP"),a=n(e,d,y,s.reportAllChanges),i((()=>{d.value=performance.now()-t.timeStamp,a(!0)}))})))}))},b=[.1,.25],L=(e,s={})=>{const o=v();E(f((()=>{let c,f=r("CLS",0);const u=a(s,d),l=e=>{for(const t of e)u.h(t);u.i>f.value&&(f.value=u.i,f.entries=u.o,c())},m=h("layout-shift",l);m&&(c=n(e,f,b,s.reportAllChanges),o.onHidden((()=>{l(m.takeRecords()),c(!0)})),t((()=>{u.i=0,f=r("CLS",0),c=n(e,f,b,s.reportAllChanges),i((()=>c()))})),setTimeout(c))})))};let P=0,T=1/0,_=0;const M=e=>{for(const t of e)t.interactionId&&(T=Math.min(T,t.interactionId),_=Math.max(_,t.interactionId),P=_?(_-T)/7+1:0)};let w;const C=()=>w?P:performance.interactionCount??0,I=()=>{"interactionCount"in performance||w||(w=h("event",M,{type:"event",buffered:!0,durationThreshold:0}))};let F=0;class k{u=[];l=new Map;m;p;v(){F=C(),this.u.length=0,this.l.clear()}L(){const e=Math.min(this.u.length-1,Math.floor((C()-F)/50));return this.u[e]}h(e){if(this.m?.(e),!e.interactionId&&"first-input"!==e.entryType)return;const t=this.u.at(-1);let n=this.l.get(e.interactionId);if(n||this.u.length<10||e.duration>t.P){if(n?e.duration>n.P?(n.entries=[e],n.P=e.duration):e.duration===n.P&&e.startTime===n.entries[0].startTime&&n.entries.push(e):(n={id:e.interactionId,entries:[e],P:e.duration},this.l.set(n.id,n),this.u.push(n)),this.u.sort(((e,t)=>t.P-e.P)),this.u.length>10){const e=this.u.splice(10);for(const t of e)this.l.delete(t.id)}this.p?.(n)}}}const A=e=>{const t=globalThis.requestIdleCallback||setTimeout;"hidden"===document.visibilityState?e():(e=f(e),addEventListener("visibilitychange",e,{once:!0,capture:!0}),t((()=>{e(),removeEventListener("visibilitychange",e,{capture:!0})})))},B=[200,500],S=(e,i={})=>{if(!globalThis.PerformanceEventTiming||!("interactionId"in PerformanceEventTiming.prototype))return;const s=v();g((()=>{I();let o,c=r("INP");const d=a(i,k),f=e=>{A((()=>{for(const t of e)d.h(t);const t=d.L();t&&t.P!==c.value&&(c.value=t.P,c.entries=t.entries,o())}))},u=h("event",f,{durationThreshold:i.durationThreshold??40});o=n(e,c,B,i.reportAllChanges),u&&(u.observe({type:"first-input",buffered:!0}),s.onHidden((()=>{f(u.takeRecords()),o(!0)})),t((()=>{d.v(),c=r("INP"),o=n(e,c,B,i.reportAllChanges)})))}))};class N{m;h(e){this.m?.(e)}}const q=[2500,4e3],x=(e,s={})=>{g((()=>{const c=v();let d,u=r("LCP");const l=a(s,N),m=e=>{s.reportAllChanges||(e=e.slice(-1));for(const t of e)l.h(t),t.startTime<c.firstHiddenTime&&(u.value=Math.max(t.startTime-o(),0),u.entries=[t],d())},p=h("largest-contentful-paint",m);if(p){d=n(e,u,q,s.reportAllChanges);const o=f((()=>{m(p.takeRecords()),p.disconnect(),d(!0)})),c=e=>{e.isTrusted&&(A(o),removeEventListener(e.type,c,{capture:!0}))};for(const e of["keydown","click","visibilitychange"])addEventListener(e,c,{capture:!0});t((t=>{u=r("LCP"),d=n(e,u,q,s.reportAllChanges),i((()=>{u.value=performance.now()-t.timeStamp,d(!0)}))}))}}))},H=[800,1800],O=e=>{document.prerendering?g((()=>O(e))):"complete"!==document.readyState?addEventListener("load",(()=>O(e)),!0):setTimeout(e)},$=(e,i={})=>{let c=r("TTFB"),a=n(e,c,H,i.reportAllChanges);O((()=>{const d=s();d&&(c.value=Math.max(d.responseStart-o(),0),c.entries=[d],a(!0),t((()=>{c=r("TTFB",0),a=n(e,c,H,i.reportAllChanges),a(!0)})))}))};export{b as CLSThresholds,y as FCPThresholds,B as INPThresholds,q as LCPThresholds,H as TTFBThresholds,L as onCLS,E as onFCP,S as onINP,x as onLCP,$ as onTTFB};
+let e=-1;const t=t=>{addEventListener("pageshow",n=>{n.persisted&&(e=n.timeStamp,t(n))},!0)},n=(e,t,n,i)=>{let s,o;return r=>{t.value>=0&&(r||i)&&(o=t.value-(s??0),(o||void 0===s)&&(s=t.value,t.delta=o,t.rating=((e,t)=>e>t[1]?"poor":e>t[0]?"needs-improvement":"good")(t.value,n),e(t)))}},i=e=>{requestAnimationFrame(()=>requestAnimationFrame(e))},s=()=>{const e=performance.getEntriesByType("navigation")[0];if(e&&e.responseStart>0&&e.responseStart<performance.now())return e},o=()=>s()?.activationStart??0,r=(t,n=-1)=>{const i=s();let r="navigate";e>=0?r="back-forward-cache":i&&(document.prerendering||o()>0?r="prerender":document.wasDiscarded?r="restore":i.type&&(r=i.type.replace(/_/g,"-")));return{name:t,value:n,rating:"good",delta:0,entries:[],id:`v5-${Date.now()}-${Math.floor(8999999999999*Math.random())+1e12}`,navigationType:r}},c=new WeakMap;function a(e,t){let n=c.get(t);return n||(n=new WeakMap,c.set(t,n)),n.get(e)||n.set(e,new t),n.get(e)}class d{t;i=0;o=[];h(e){if(e.hadRecentInput)return;const t=this.o[0],n=this.o.at(-1);this.i&&t&&n&&e.startTime-n.startTime<1e3&&e.startTime-t.startTime<5e3?(this.i+=e.value,this.o.push(e)):(this.i=e.value,this.o=[e]),this.t?.(e)}}const h=(e,t,n={})=>{try{if(PerformanceObserver.supportedEntryTypes.includes(e)){const i=new PerformanceObserver(e=>{queueMicrotask(()=>{t(e.getEntries())})});return i.observe({type:e,buffered:!0,...n}),i}}catch{}},f=e=>{let t=!1;return()=>{t||(e(),t=!0)}};let l=-1;const u=new Set,m=()=>"hidden"!==document.visibilityState||document.prerendering?1/0:0,p=e=>{if("hidden"===document.visibilityState){if("visibilitychange"===e.type)for(const e of u)e();isFinite(l)||(l="visibilitychange"===e.type?e.timeStamp:0,removeEventListener("prerenderingchange",p,!0))}},g=()=>{if(l<0){const e=o(),n=document.prerendering?void 0:globalThis.performance.getEntriesByType("visibility-state").find(t=>"hidden"===t.name&&t.startTime>=e)?.startTime;l=n??m(),addEventListener("visibilitychange",p,!0),addEventListener("prerenderingchange",p,!0),t(()=>{setTimeout(()=>{l=m()})})}return{get firstHiddenTime(){return l},onHidden(e){u.add(e)}}},v=e=>{document.prerendering?addEventListener("prerenderingchange",e,!0):e()},y=[1800,3e3],T=(e,s={})=>{v(()=>{const c=g();let a,d=r("FCP");const f=h("paint",e=>{for(const t of e)"first-contentful-paint"===t.name&&(f.disconnect(),t.startTime<c.firstHiddenTime&&(d.value=Math.max(t.startTime-o(),0),d.entries.push(t),a(!0)))});f&&(a=n(e,d,y,s.reportAllChanges),t(t=>{d=r("FCP"),a=n(e,d,y,s.reportAllChanges),i(()=>{d.value=performance.now()-t.timeStamp,a(!0)})}))})},E=[.1,.25],b=(e,s={})=>{const o=g();T(f(()=>{let c,f=r("CLS",0);const l=a(s,d),u=e=>{for(const t of e)l.h(t);l.i>f.value&&(f.value=l.i,f.entries=l.o,c())},m=h("layout-shift",u);m&&(c=n(e,f,E,s.reportAllChanges),o.onHidden(()=>{u(m.takeRecords()),c(!0)}),t(()=>{l.i=0,f=r("CLS",0),c=n(e,f,E,s.reportAllChanges),i(c)}),setTimeout(c))}))};let L=0,P=1/0,_=0;const M=e=>{for(const t of e)t.interactionId&&(P=Math.min(P,t.interactionId),_=Math.max(_,t.interactionId),L=_?(_-P)/7+1:0)};let w;const C=()=>w?L:performance.interactionCount??0,I=()=>{"interactionCount"in performance||w||(w=h("event",M,{durationThreshold:0}))};let F=0;class k{l=[];u=new Map;m;p;v(){F=C(),this.l.length=0,this.u.clear()}T(){const e=Math.min(this.l.length-1,Math.floor((C()-F)/50));return this.l[e]}h(e){if(this.m?.(e),!e.interactionId&&"first-input"!==e.entryType)return;const t=this.l.at(-1);let n=this.u.get(e.interactionId);if(n||this.l.length<10||e.duration>t.L){if(n?e.duration>n.L?(n.entries=[e],n.L=e.duration):e.duration===n.L&&e.startTime===n.entries[0].startTime&&n.entries.push(e):(n={id:e.interactionId,entries:[e],L:e.duration},this.u.set(n.id,n),this.l.push(n)),this.l.sort((e,t)=>t.L-e.L),this.l.length>10){const e=this.l.splice(10);for(const t of e)this.u.delete(t.id)}this.p?.(n)}}}const A=e=>{const t=globalThis.requestIdleCallback||setTimeout,n=globalThis.cancelIdleCallback||clearTimeout;if("hidden"===document.visibilityState)e();else{const i=f(e);let s=-1;const o=()=>{n(s),i()};addEventListener("visibilitychange",o,{once:!0,capture:!0}),s=t(()=>{removeEventListener("visibilitychange",o,{capture:!0}),i()})}},B=[200,500],S=(e,i={})=>{if(!globalThis.PerformanceEventTiming||!("interactionId"in PerformanceEventTiming.prototype))return;const s=g();v(()=>{I();let o,c=r("INP");const d=a(i,k),f=e=>{A(()=>{for(const t of e)d.h(t);const t=d.T();t&&t.L!==c.value&&(c.value=t.L,c.entries=t.entries,o())})},l=h("event",f,{durationThreshold:i.durationThreshold??40});o=n(e,c,B,i.reportAllChanges),l&&(l.observe({type:"first-input",buffered:!0}),s.onHidden(()=>{f(l.takeRecords()),o(!0)}),t(()=>{d.v(),c=r("INP"),o=n(e,c,B,i.reportAllChanges)}))})};class q{m;h(e){this.m?.(e)}}const N=[2500,4e3],x=(e,s={})=>{v(()=>{const c=g();let d,l=r("LCP");const u=a(s,q),m=e=>{s.reportAllChanges||(e=e.slice(-1));for(const t of e)u.h(t),t.startTime<c.firstHiddenTime&&(l.value=Math.max(t.startTime-o(),0),l.entries=[t],d())},p=h("largest-contentful-paint",m);if(p){d=n(e,l,N,s.reportAllChanges);const o=f(()=>{m(p.takeRecords()),p.disconnect(),d(!0)}),c=e=>{e.isTrusted&&(A(o),removeEventListener(e.type,c,{capture:!0}))};for(const e of["keydown","click","visibilitychange"])addEventListener(e,c,{capture:!0});t(t=>{l=r("LCP"),d=n(e,l,N,s.reportAllChanges),i(()=>{l.value=performance.now()-t.timeStamp,d(!0)})})}})},H=[800,1800],O=e=>{document.prerendering?v(()=>O(e)):"complete"!==document.readyState?addEventListener("load",()=>O(e),!0):setTimeout(e)},W=(e,i={})=>{let c=r("TTFB"),a=n(e,c,H,i.reportAllChanges);O(()=>{const d=s();d&&(c.value=Math.max(d.responseStart-o(),0),c.entries=[d],a(!0),t(()=>{c=r("TTFB",0),a=n(e,c,H,i.reportAllChanges),a(!0)}))})};export{E as CLSThresholds,y as FCPThresholds,B as INPThresholds,N as LCPThresholds,H as TTFBThresholds,b as onCLS,T as onFCP,S as onINP,x as onLCP,W as onTTFB};
Index: class-od-data-validation-exception.php
===================================================================
--- class-od-data-validation-exception.php	(revision 3592034)
+++ class-od-data-validation-exception.php	(working copy)
@@ -6,6 +6,8 @@
  * @since 0.1.0
  */
 
+declare( strict_types = 1 );
+
 // @codeCoverageIgnoreStart
 if ( ! defined( 'ABSPATH' ) ) {
 	exit; // Exit if accessed directly.
Index: class-od-element.php
===================================================================
--- class-od-element.php	(revision 3592034)
+++ class-od-element.php	(working copy)
@@ -6,6 +6,8 @@
  * @since 0.7.0
  */
 
+declare( strict_types = 1 );
+
 // @codeCoverageIgnoreStart
 if ( ! defined( 'ABSPATH' ) ) {
 	exit; // Exit if accessed directly.
@@ -30,7 +32,7 @@
 	 * @since 0.7.0
 	 * @var ElementData
 	 */
-	protected $data;
+	protected array $data;
 
 	/**
 	 * URL Metric that this element belongs to.
@@ -38,7 +40,7 @@
 	 * @since 0.7.0
 	 * @var OD_URL_Metric
 	 */
-	protected $url_metric;
+	protected OD_URL_Metric $url_metric;
 
 	/**
 	 * Constructor.
Index: class-od-html-tag-processor.php
===================================================================
--- class-od-html-tag-processor.php	(revision 3592034)
+++ class-od-html-tag-processor.php	(working copy)
@@ -6,6 +6,8 @@
  * @since 0.1.1
  */
 
+declare( strict_types = 1 );
+
 // @codeCoverageIgnoreStart
 if ( ! defined( 'ABSPATH' ) ) {
 	exit; // Exit if accessed directly.
@@ -150,7 +152,7 @@
 	 * @since 0.4.0
 	 * @var non-empty-string[]
 	 */
-	private $open_stack_tags = array();
+	private array $open_stack_tags = array();
 
 	/**
 	 * Stack of the attributes for open tags.
@@ -161,7 +163,7 @@
 	 * @since 1.0.0
 	 * @var array<array<non-empty-string, string>>
 	 */
-	private $open_stack_attributes = array();
+	private array $open_stack_attributes = array();
 
 	/**
 	 * Open stack indices.
@@ -169,7 +171,7 @@
 	 * @since 0.4.0
 	 * @var non-negative-int[]
 	 */
-	private $open_stack_indices = array();
+	private array $open_stack_indices = array();
 
 	/**
 	 * Bookmarked open stacks.
@@ -182,7 +184,7 @@
 	 * @since 0.4.0
 	 * @var array<string, array{tags: non-empty-string[], attributes: array<array<non-empty-string, string>>, indices: non-negative-int[]}>
 	 */
-	private $bookmarked_open_stacks = array();
+	private array $bookmarked_open_stacks = array();
 
 	/**
 	 * (Transitional) XPath for the current tag.
@@ -193,7 +195,7 @@
 	 * @since 1.0.0
 	 * @var string|null
 	 */
-	private $current_xpath = null;
+	private ?string $current_xpath = null;
 
 	/**
 	 * Whether the previous tag does not expect a closer.
@@ -201,7 +203,7 @@
 	 * @since 0.4.0
 	 * @var bool
 	 */
-	private $previous_tag_without_closer = false;
+	private bool $previous_tag_without_closer = false;
 
 	/**
 	 * Mapping of bookmark name to a list of HTML strings which will be inserted at the time get_updated_html() is called.
@@ -209,7 +211,7 @@
 	 * @since 0.4.0
 	 * @var array<non-empty-string, string[]>
 	 */
-	private $buffered_text_replacements = array();
+	private array $buffered_text_replacements = array();
 
 	/**
 	 * Whether the end of the document was reached.
@@ -218,7 +220,7 @@
 	 * @see self::next_token()
 	 * @var bool
 	 */
-	private $reached_end_of_document = false;
+	private bool $reached_end_of_document = false;
 
 	/**
 	 * Count for the number of times that the cursor was moved.
@@ -228,7 +230,7 @@
 	 * @see self::next_token()
 	 * @see self::seek()
 	 */
-	private $cursor_move_count = 0;
+	private int $cursor_move_count = 0;
 
 	/**
 	 * Finds the next tag.
@@ -261,7 +263,7 @@
 	 * This method will soon be equivalent to calling {@see self::next_tag()} without passing any `$query`.
 	 *
 	 * @since 0.4.0
-	 * @deprecated n.e.x.t Use {@see self::next_tag()} instead.
+	 * @deprecated 1.0.0 Use {@see self::next_tag()} instead.
 	 *
 	 * @return bool Whether a tag was matched.
 	 */
@@ -770,6 +772,7 @@
 			} else {
 				$start = $this->bookmarks[ $bookmark ]->start;
 
+				// @phpstan-ignore no.private.class
 				$this->lexical_updates[] = new WP_HTML_Text_Replacement(
 					$start,
 					0,
Index: class-od-link-collection.php
===================================================================
--- class-od-link-collection.php	(revision 3592034)
+++ class-od-link-collection.php	(working copy)
@@ -6,6 +6,8 @@
  * @since 0.3.0
  */
 
+declare( strict_types = 1 );
+
 // @codeCoverageIgnoreStart
 if ( ! defined( 'ABSPATH' ) ) {
 	exit; // Exit if accessed directly.
@@ -48,7 +50,7 @@
 	 *
 	 * @var array<string, Link[]>
 	 */
-	private $links_by_rel = array();
+	private array $links_by_rel = array();
 
 	/**
 	 * Adds link.
@@ -126,18 +128,10 @@
 	 * @return LinkAttributes[] Prepared links with adjacent-duplicates merged together and media attributes added.
 	 */
 	private function get_prepared_links(): array {
-		$links_by_rel = array_values( $this->links_by_rel );
-		if ( count( $links_by_rel ) === 0 ) {
-			// This condition is needed for PHP 7.2 and PHP 7.3 in which array_merge() fails if passed a spread empty array: 'array_merge() expects at least 1 parameter, 0 given'.
-			return array();
-		}
-
 		return array_merge(
 			...array_map(
-				function ( array $links ): array {
-					return $this->merge_consecutive_links( $links );
-				},
-				$links_by_rel
+				fn ( array $links ): array => $this->merge_consecutive_links( $links ),
+				array_values( $this->links_by_rel )
 			)
 		);
 	}
Index: class-od-strict-url-metric.php
===================================================================
--- class-od-strict-url-metric.php	(revision 3592034)
+++ class-od-strict-url-metric.php	(working copy)
@@ -6,6 +6,8 @@
  * @since 0.6.0
  */
 
+declare( strict_types = 1 );
+
 // @codeCoverageIgnoreStart
 if ( ! defined( 'ABSPATH' ) ) {
 	exit; // Exit if accessed directly.
Index: class-od-tag-visitor-context.php
===================================================================
--- class-od-tag-visitor-context.php	(revision 3592034)
+++ class-od-tag-visitor-context.php	(working copy)
@@ -6,6 +6,8 @@
  * @since 0.4.0
  */
 
+declare( strict_types = 1 );
+
 // @codeCoverageIgnoreStart
 if ( ! defined( 'ABSPATH' ) ) {
 	exit; // Exit if accessed directly.
@@ -31,7 +33,7 @@
 	 * @since 0.4.0
 	 * @var OD_HTML_Tag_Processor
 	 */
-	private $processor;
+	private OD_HTML_Tag_Processor $processor;
 
 	/**
 	 * URL Metric group collection.
@@ -39,7 +41,7 @@
 	 * @since 0.4.0
 	 * @var OD_URL_Metric_Group_Collection
 	 */
-	private $url_metric_group_collection;
+	private OD_URL_Metric_Group_Collection $url_metric_group_collection;
 
 	/**
 	 * Link collection.
@@ -47,7 +49,7 @@
 	 * @since 0.4.0
 	 * @var OD_Link_Collection
 	 */
-	private $link_collection;
+	private OD_Link_Collection $link_collection;
 
 	/**
 	 * ID for the od_url_metrics post which provided the URL Metrics in the collection.
@@ -57,7 +59,7 @@
 	 * @since 1.0.0
 	 * @var positive-int|null
 	 */
-	private $url_metrics_id;
+	private ?int $url_metrics_id;
 
 	/**
 	 * Visited tag state.
@@ -67,7 +69,7 @@
 	 * @since 1.0.0
 	 * @var OD_Visited_Tag_State
 	 */
-	private $visited_tag_state;
+	private OD_Visited_Tag_State $visited_tag_state;
 
 	/**
 	 * Constructor.
Index: class-od-tag-visitor-registry.php
===================================================================
--- class-od-tag-visitor-registry.php	(revision 3592034)
+++ class-od-tag-visitor-registry.php	(working copy)
@@ -6,6 +6,8 @@
  * @since 0.3.0
  */
 
+declare( strict_types = 1 );
+
 // @codeCoverageIgnoreStart
 if ( ! defined( 'ABSPATH' ) ) {
 	exit; // Exit if accessed directly.
@@ -30,7 +32,7 @@
 	 *
 	 * @var array<non-empty-string, TagVisitorCallback>
 	 */
-	private $visitors = array();
+	private array $visitors = array();
 
 	/**
 	 * Registers a tag visitor.
Index: class-od-template-optimization-context.php
===================================================================
--- class-od-template-optimization-context.php	(revision 3592034)
+++ class-od-template-optimization-context.php	(working copy)
@@ -6,6 +6,8 @@
  * @since 1.0.0
  */
 
+declare( strict_types = 1 );
+
 // @codeCoverageIgnoreStart
 if ( ! defined( 'ABSPATH' ) ) {
 	exit; // Exit if accessed directly.
@@ -31,7 +33,7 @@
 	 * @since 1.0.0
 	 * @var OD_URL_Metric_Group_Collection
 	 */
-	private $url_metric_group_collection;
+	private OD_URL_Metric_Group_Collection $url_metric_group_collection;
 
 	/**
 	 * ID for the od_url_metrics post which provided the URL Metrics in the collection.
@@ -41,7 +43,7 @@
 	 * @since 1.0.0
 	 * @var positive-int|null
 	 */
-	private $url_metrics_id;
+	private ?int $url_metrics_id;
 
 	/**
 	 * Normalized query vars.
@@ -49,7 +51,7 @@
 	 * @since 1.0.0
 	 * @var array<string, mixed>
 	 */
-	private $normalized_query_vars;
+	private array $normalized_query_vars;
 
 	/**
 	 * Slug for the od_url_metrics post.
@@ -57,7 +59,7 @@
 	 * @since 1.0.0
 	 * @var non-empty-string
 	 */
-	private $url_metrics_slug;
+	private string $url_metrics_slug;
 
 	/**
 	 * Link collection.
@@ -65,7 +67,7 @@
 	 * @since 1.0.0
 	 * @var OD_Link_Collection
 	 */
-	private $link_collection;
+	private OD_Link_Collection $link_collection;
 
 	/**
 	 * Constructor.
Index: class-od-url-metric-group-collection.php
===================================================================
--- class-od-url-metric-group-collection.php	(revision 3592034)
+++ class-od-url-metric-group-collection.php	(working copy)
@@ -6,6 +6,8 @@
  * @since 0.1.0
  */
 
+declare( strict_types = 1 );
+
 // @codeCoverageIgnoreStart
 if ( ! defined( 'ABSPATH' ) ) {
 	exit; // Exit if accessed directly.
@@ -34,7 +36,7 @@
 	 * @var OD_URL_Metric_Group[]
 	 * @phpstan-var non-empty-array<OD_URL_Metric_Group>
 	 */
-	private $groups;
+	private array $groups;
 
 	/**
 	 * The current ETag.
@@ -42,7 +44,7 @@
 	 * @since 0.9.0
 	 * @var non-empty-string
 	 */
-	private $current_etag;
+	private string $current_etag;
 
 	/**
 	 * Breakpoints in max widths.
@@ -57,7 +59,7 @@
 	 * @since 0.1.0
 	 * @var positive-int[]
 	 */
-	private $breakpoints;
+	private array $breakpoints;
 
 	/**
 	 * Sample size for URL Metrics for a given breakpoint.
@@ -65,7 +67,7 @@
 	 * @since 0.1.0
 	 * @var int<1, max>
 	 */
-	private $sample_size;
+	private int $sample_size;
 
 	/**
 	 * Freshness age (TTL) for a given URL Metric.
@@ -75,7 +77,7 @@
 	 * @since 0.1.0
 	 * @var int<-1, max>
 	 */
-	private $freshness_ttl;
+	private int $freshness_ttl;
 
 	/**
 	 * Result cache.
@@ -93,7 +95,7 @@
 	 *          get_all_elements_positioned_in_any_initial_viewport?: array<string, bool>,
 	 *      }
 	 */
-	private $result_cache = array();
+	private array $result_cache = array();
 
 	/**
 	 * Constructor.
Index: class-od-url-metric-group.php
===================================================================
--- class-od-url-metric-group.php	(revision 3592034)
+++ class-od-url-metric-group.php	(working copy)
@@ -6,6 +6,8 @@
  * @since 0.1.0
  */
 
+declare( strict_types = 1 );
+
 // @codeCoverageIgnoreStart
 if ( ! defined( 'ABSPATH' ) ) {
 	exit; // Exit if accessed directly.
@@ -28,7 +30,7 @@
 	 *
 	 * @var OD_URL_Metric[]
 	 */
-	private $url_metrics;
+	private array $url_metrics;
 
 	/**
 	 * Minimum possible viewport width for the group (exclusive).
@@ -37,7 +39,7 @@
 	 *
 	 * @var int<0, max>
 	 */
-	private $minimum_viewport_width;
+	private int $minimum_viewport_width;
 
 	/**
 	 * Maximum possible viewport width for the group (inclusive), where null means it is unbounded.
@@ -46,7 +48,7 @@
 	 *
 	 * @var int<1, max>|null
 	 */
-	private $maximum_viewport_width;
+	private ?int $maximum_viewport_width;
 
 	/**
 	 * Sample size for URL Metrics for a given breakpoint.
@@ -55,7 +57,7 @@
 	 *
 	 * @var int<1, max>
 	 */
-	private $sample_size;
+	private int $sample_size;
 
 	/**
 	 * Freshness age (TTL) for a given URL Metric.
@@ -64,7 +66,7 @@
 	 *
 	 * @var int<-1, max>
 	 */
-	private $freshness_ttl;
+	private int $freshness_ttl;
 
 	/**
 	 * Collection that this instance belongs to.
@@ -73,7 +75,7 @@
 	 *
 	 * @var OD_URL_Metric_Group_Collection
 	 */
-	private $collection;
+	private OD_URL_Metric_Group_Collection $collection;
 
 	/**
 	 * Result cache.
@@ -87,7 +89,7 @@
 	 *          get_all_element_max_intersection_ratios?: array<string, float>,
 	 *      }
 	 */
-	private $result_cache = array();
+	private array $result_cache = array();
 
 	/**
 	 * Constructor.
@@ -255,9 +257,7 @@
 			// Sort URL Metrics in descending order by timestamp.
 			usort(
 				$this->url_metrics,
-				static function ( OD_URL_Metric $a, OD_URL_Metric $b ): int {
-					return $b->get_timestamp() <=> $a->get_timestamp();
-				}
+				static fn ( OD_URL_Metric $a, OD_URL_Metric $b ): int => $b->get_timestamp() <=> $a->get_timestamp()
 			);
 
 			// Only keep the sample size of the newest URL Metrics.
@@ -327,35 +327,17 @@
 				return null;
 			}
 
-			// The following arrays all share array indices.
-
 			/**
-			 * Seen breadcrumb counts.
+			 * Breadcrumbs keyed by element XPath: how often each is the LCP element, and the latest matching element.
 			 *
-			 * @var array<int, non-empty-string> $seen_breadcrumbs
+			 * @var array<non-empty-string, array{count: int<1, max>, element: OD_Element}> $breadcrumbs
 			 */
-			$seen_breadcrumbs = array();
+			$breadcrumbs = array();
 
-			/**
-			 * Breadcrumb counts.
-			 *
-			 * @var array<int, non-negative-int> $breadcrumb_counts
-			 */
-			$breadcrumb_counts = array();
-
-			/**
-			 * Breadcrumb element.
-			 *
-			 * @var array<int, OD_Element> $breadcrumb_element
-			 */
-			$breadcrumb_element = array();
-
 			// Prefer to use URL Metrics, which have a current ETag.
 			$url_metrics = array_filter(
 				$this->url_metrics,
-				function ( OD_URL_Metric $url_metric ): bool {
-					return $url_metric->get_etag() === $this->get_collection()->get_current_etag();
-				}
+				fn ( OD_URL_Metric $url_metric ): bool => $url_metric->get_etag() === $this->get_collection()->get_current_etag()
 			);
 
 			// Otherwise, if no URL Metrics have a current ETag, fall back to using all the stale ones.
@@ -369,25 +351,28 @@
 						continue;
 					}
 
-					$i = array_search( $element->get_xpath(), $seen_breadcrumbs, true );
-					if ( false === $i ) {
-						$i                       = count( $seen_breadcrumbs );
-						$seen_breadcrumbs[ $i ]  = $element->get_xpath();
-						$breadcrumb_counts[ $i ] = 0;
+					$xpath = $element->get_xpath();
+					if ( ! isset( $breadcrumbs[ $xpath ] ) ) {
+						$breadcrumbs[ $xpath ] = array(
+							'count'   => 1,
+							'element' => $element,
+						);
+					} else {
+						++$breadcrumbs[ $xpath ]['count'];
+						$breadcrumbs[ $xpath ]['element'] = $element;
 					}
-
-					$breadcrumb_counts[ $i ] += 1;
-					$breadcrumb_element[ $i ] = $element;
 					break; // We found the LCP element for the URL Metric, go to the next URL Metric.
 				}
 			}
 
-			// Now sort by the breadcrumb counts in descending order, so the remaining first key is the most common breadcrumb.
-			if ( count( $seen_breadcrumbs ) > 0 ) {
-				arsort( $breadcrumb_counts );
-				$most_common_breadcrumb_index = key( $breadcrumb_counts );
-
-				$lcp_element = $breadcrumb_element[ $most_common_breadcrumb_index ];
+			// Sort by count in descending order so the most common breadcrumb's element is first.
+			if ( count( $breadcrumbs ) > 0 ) {
+				uasort(
+					$breadcrumbs,
+					static fn ( array $a, array $b ): int => $b['count'] <=> $a['count']
+				);
+				$most_common = reset( $breadcrumbs );
+				$lcp_element = $most_common['element'];
 			} else {
 				$lcp_element = null;
 			}
Index: class-od-url-metric.php
===================================================================
--- class-od-url-metric.php	(revision 3592034)
+++ class-od-url-metric.php	(working copy)
@@ -6,6 +6,8 @@
  * @since 0.1.0
  */
 
+declare( strict_types = 1 );
+
 // @codeCoverageIgnoreStart
 if ( ! defined( 'ABSPATH' ) ) {
 	exit; // Exit if accessed directly.
@@ -57,6 +59,7 @@
  *                                additionalProperties?: bool,
  *                                format?: non-empty-string,
  *                                readonly?: bool,
+ *                                ...
  *                            }
  *
  * @since 0.1.0
@@ -69,15 +72,17 @@
 	 * @since 0.1.0
 	 * @var Data
 	 */
-	protected $data;
+	protected array $data;
 
 	/**
 	 * Elements.
 	 *
+	 * Lazily initialized in {@see self::get_elements()} from data['elements'].
+	 *
 	 * @since 0.7.0
-	 * @var OD_Element[]
+	 * @var OD_Element[]|null
 	 */
-	protected $elements;
+	protected ?array $elements = null;
 
 	/**
 	 * Group.
@@ -85,7 +90,7 @@
 	 * @since 0.7.0
 	 * @var OD_URL_Metric_Group|null
 	 */
-	protected $group = null;
+	protected ?OD_URL_Metric_Group $group = null;
 
 	/**
 	 * Constructor.
@@ -515,11 +520,9 @@
 	 * @return OD_Element[] Elements.
 	 */
 	public function get_elements(): array {
-		if ( ! is_array( $this->elements ) ) {
+		if ( null === $this->elements ) {
 			$this->elements = array_map(
-				function ( array $element ): OD_Element {
-					return new OD_Element( $element, $this );
-				},
+				fn ( array $element ): OD_Element => new OD_Element( $element, $this ),
 				$this->data['elements']
 			);
 		}
@@ -537,9 +540,7 @@
 		$data = $this->data;
 
 		$data['elements'] = array_map(
-			static function ( OD_Element $element ): array {
-				return $element->jsonSerialize();
-			},
+			static fn ( OD_Element $element ): array => $element->jsonSerialize(),
 			$this->get_elements()
 		);
 
Index: class-od-visited-tag-state.php
===================================================================
--- class-od-visited-tag-state.php	(revision 3592034)
+++ class-od-visited-tag-state.php	(working copy)
@@ -6,6 +6,8 @@
  * @since 1.0.0
  */
 
+declare( strict_types = 1 );
+
 // @codeCoverageIgnoreStart
 if ( ! defined( 'ABSPATH' ) ) {
 	exit; // Exit if accessed directly.
@@ -26,7 +28,7 @@
 	 * @since 1.0.0
 	 * @var bool
 	 */
-	private $should_track_tag;
+	private bool $should_track_tag;
 
 	/**
 	 * Constructor.
Index: detect-loader.js
===================================================================
--- detect-loader.js	(revision 3592034)
+++ detect-loader.js	(working copy)
@@ -7,13 +7,17 @@
  */
 async function load() {
 	// Wait until the resources on the page have fully loaded.
-	await new Promise( ( resolve ) => {
-		if ( document.readyState === 'complete' ) {
-			resolve();
-		} else {
-			window.addEventListener( 'load', resolve, { once: true } );
-		}
-	} );
+	await /** @type {Promise<void>} */ (
+		new Promise( ( resolve ) => {
+			if ( document.readyState === 'complete' ) {
+				resolve();
+			} else {
+				window.addEventListener( 'load', () => resolve(), {
+					once: true,
+				} );
+			}
+		} )
+	);
 
 	// Wait yet further until idle.
 	if ( typeof requestIdleCallback === 'function' ) {
@@ -22,12 +26,25 @@
 		} );
 	}
 
-	const data = JSON.parse(
-		document.getElementById( 'optimization-detective-detect-args' )
-			.textContent
+	const argsScript = document.getElementById(
+		'optimization-detective-detect-args'
 	);
+	if ( ! ( argsScript instanceof HTMLScriptElement ) ) {
+		throw new Error( 'Missing: SCRIPT#optimization-detective-detect-args' );
+	}
+	const data = JSON.parse( argsScript.textContent );
+	if (
+		! Array.isArray( data ) ||
+		data.length !== 2 ||
+		'string' !== typeof data[ 0 ] ||
+		'object' !== typeof data[ 1 ]
+	) {
+		throw new Error(
+			'SCRIPT#optimization-detective-detect-args is not [ string, object ]'
+		);
+	}
 
-	const detectSrc = /** @type {string} */ data[ 0 ];
+	const detectSrc = data[ 0 ];
 	const detectArgs =
 		/** @type {import("./detect.js").DetectFunctionArgs} */ data[ 1 ];
 	const detect = /** @type {import("./detect.js").DetectFunction} */ (
Index: detect.js
===================================================================
--- detect.js	(revision 3592034)
+++ detect.js	(working copy)
@@ -79,13 +79,14 @@
 
 	try {
 		const storageLockTime = parseInt(
-			sessionStorage.getItem( storageLockTimeSessionKey )
+			sessionStorage.getItem( storageLockTimeSessionKey ) || '',
+			10
 		);
 		return (
 			! isNaN( storageLockTime ) &&
 			currentTime < storageLockTime + storageLockTTL * 1000
 		);
-	} catch ( e ) {
+	} catch {
 		return false;
 	}
 }
@@ -101,7 +102,7 @@
 			storageLockTimeSessionKey,
 			String( currentTime )
 		);
-	} catch ( e ) {}
+	} catch {}
 }
 
 /**
@@ -122,9 +123,9 @@
 	/**
 	 * Constructs the args to pass to the logging function.
 	 *
-	 * @param {Array}   message       - The message(s) to log.
-	 * @param {boolean} includeSource - Whether to include the source. This should be true for warnings or errors.
-	 * @return {Array} Amended message.
+	 * @param {Array<any>} message       - The message(s) to log.
+	 * @param {boolean}    includeSource - Whether to include the source. This should be true for warnings or errors.
+	 * @return {Array<any>} Amended message.
 	 */
 	const constructLogArgs = ( message, includeSource = false ) => {
 		return [ prefix, ...message, includeSource ? logSource : null ].filter(
@@ -199,7 +200,7 @@
 			return matches[ 1 ];
 		}
 		return url.pathname;
-	} catch ( err ) {
+	} catch {
 		return scriptModuleUrl;
 	}
 }
@@ -297,7 +298,7 @@
  *
  * @param {Object} obj - Object to recursively freeze.
  */
-function recursiveFreeze( obj ) {
+function recursiveFreeze( /** @type {Record<string, any>} */ obj ) {
 	for ( const prop of Object.getOwnPropertyNames( obj ) ) {
 		const value = obj[ prop ];
 		if ( null !== value && typeof value === 'object' ) {
@@ -410,7 +411,9 @@
 		}
 	}
 	const elementData = elementsByXPath.get( xpath );
-	Object.assign( elementData, properties );
+	if ( elementData ) {
+		Object.assign( elementData, properties );
+	}
 	debounceCompressUrlMetric();
 }
 
@@ -626,7 +629,7 @@
 		alreadySubmittedSessionStorageKey in sessionStorage
 	) {
 		const previousVisitTime = parseInt(
-			sessionStorage.getItem( alreadySubmittedSessionStorageKey ),
+			sessionStorage.getItem( alreadySubmittedSessionStorageKey ) || '',
 			10
 		);
 		if (
@@ -700,7 +703,12 @@
 			 * @param {Element} element
 			 * @return {[Element, string]} Tuple of an element and its XPath.
 			 */
-			( element ) => [ element, element.getAttribute( 'data-od-xpath' ) ]
+			( element ) => [
+				element,
+				/** @type {string} */ (
+					element.getAttribute( 'data-od-xpath' )
+				),
+			]
 		)
 	);
 
@@ -720,25 +728,27 @@
 	// 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 );
+		await /** @type {Promise<void>} */ (
+			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.
 					}
-					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 );
 				}
-			);
+			} )
+		);
 
-			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,
@@ -750,25 +760,27 @@
 	const lcpMetricCandidates = [];
 
 	// Get at least one LCP candidate. More may be reported before the page finishes loading.
-	await new Promise( ( resolve ) => {
-		onLCP(
-			/**
-			 * Handles an LCP metric being reported.
-			 *
-			 * @param {LCPMetric|LCPMetricWithAttribution} metric
-			 */
-			( metric ) => {
-				lcpMetricCandidates.push( metric );
-				resolve();
-			},
-			{
-				// This avoids needing to click to finalize the 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,
-			}
-		);
-	} );
+	await /** @type {Promise<void>} */ (
+		new Promise( ( resolve ) => {
+			onLCP(
+				/**
+				 * Handles an LCP metric being reported.
+				 *
+				 * @param {LCPMetric|LCPMetricWithAttribution} metric
+				 */
+				( metric ) => {
+					lcpMetricCandidates.push( metric );
+					resolve();
+				},
+				{
+					// This avoids needing to click to finalize the 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 the initial viewport.
 	disconnectIntersectionObserver();
@@ -829,7 +841,7 @@
 	/** @type {boolean} */
 	let extensionHasFinalize = false;
 
-	/** @type {Promise[]} */
+	/** @type {Promise<void>[]} */
 	const extensionInitializePromises = [];
 
 	/** @type {string[]} */
@@ -921,20 +933,22 @@
 	debounceCompressUrlMetric();
 
 	// 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 ( doc.visibilityState === 'hidden' ) {
-					// TODO: This will fire even when switching tabs.
-					resolve();
-				}
-			},
-			{ once: true }
-		);
-	} );
+	await /** @type {Promise<void>} */ (
+		new Promise( ( resolve ) => {
+			win.addEventListener( 'pagehide', () => resolve(), { once: true } );
+			win.addEventListener( 'pageswap', () => resolve(), { once: true } );
+			doc.addEventListener(
+				'visibilitychange',
+				() => {
+					if ( doc.visibilityState === 'hidden' ) {
+						// TODO: This will fire even when switching tabs.
+						resolve();
+					}
+				},
+				{ once: true }
+			);
+		} )
+	);
 
 	// Only proceed with submitting the URL Metric if the viewport stayed the same size. Changing the viewport size (e.g. due
 	// to resizing a window or changing the orientation of a device) will result in unexpected metrics being collected.
@@ -945,7 +959,7 @@
 
 	// Finalize extensions.
 	if ( extensions.size > 0 ) {
-		/** @type {Promise[]} */
+		/** @type {Promise<void>[]} */
 		const extensionFinalizePromises = [];
 
 		/** @type {string[]} */
@@ -1024,9 +1038,10 @@
 		return;
 	}
 	compressionEnabled = compressionEnabled && null !== compressedPayload;
-	const payloadBlob = compressionEnabled
-		? compressedPayload
-		: new Blob( [ jsonBody ], { type: 'application/json' } );
+	const payloadBlob =
+		compressionEnabled && compressedPayload
+			? compressedPayload
+			: new Blob( [ jsonBody ], { type: 'application/json' } );
 	const percentOfBudget =
 		( payloadBlob.size / ( maxBodyLengthKiB * 1000 ) ) * 100;
 
@@ -1093,6 +1108,7 @@
 	}
 	url.searchParams.set( 'hmac', urlMetricHMAC );
 
+	/** @type {Record<string, string>} */
 	const headers = {
 		'Content-Type': 'application/json',
 	};
Index: detection.php
===================================================================
--- detection.php	(revision 3592034)
+++ detection.php	(working copy)
@@ -6,6 +6,8 @@
  * @since 0.1.0
  */
 
+declare( strict_types = 1 );
+
 // @codeCoverageIgnoreStart
 if ( ! defined( 'ABSPATH' ) ) {
 	exit; // Exit if accessed directly.
@@ -133,13 +135,11 @@
 		'cachePurgePostId'       => od_get_cache_purge_post_id(),
 		'urlMetricHMAC'          => od_get_url_metrics_storage_hmac( $slug, $current_etag, $current_url, $cache_purge_post_id ),
 		'urlMetricGroupStatuses' => array_map(
-			static function ( OD_URL_Metric_Group $group ): array {
-				return array(
-					'minimumViewportWidth' => $group->get_minimum_viewport_width(), // Exclusive.
-					'maximumViewportWidth' => $group->get_maximum_viewport_width(), // Inclusive.
-					'complete'             => $group->is_complete(),
-				);
-			},
+			static fn ( OD_URL_Metric_Group $group ) => array(
+				'minimumViewportWidth' => $group->get_minimum_viewport_width(), // Exclusive.
+				'maximumViewportWidth' => $group->get_maximum_viewport_width(), // Inclusive.
+				'complete'             => $group->is_complete(),
+			),
 			iterator_to_array( $group_collection )
 		),
 		'storageLockTTL'         => OD_Storage_Lock::get_ttl(),
Index: helper.php
===================================================================
--- helper.php	(revision 3592034)
+++ helper.php	(working copy)
@@ -6,6 +6,8 @@
  * @since 0.1.0
  */
 
+declare( strict_types = 1 );
+
 // @codeCoverageIgnoreStart
 if ( ! defined( 'ABSPATH' ) ) {
 	exit; // Exit if accessed directly.
@@ -156,7 +158,7 @@
 			'no_cache_purge_post_id' => __( 'Page is not optimized because there is no post ID available for cache purging.', 'optimization-detective' ),
 		);
 
-		$reasons = wp_array_slice_assoc( $reason_messages, array_keys( array_filter( $disabled_flags ) ) );
+		$reasons = array_intersect_key( $reason_messages, array_filter( $disabled_flags ) );
 
 		// If no technical reasons but optimization still disabled, it's because of the filter.
 		if ( 0 === count( $reasons ) ) {
Index: hooks.php
===================================================================
--- hooks.php	(revision 3592034)
+++ hooks.php	(working copy)
@@ -6,6 +6,8 @@
  * @since 0.1.0
  */
 
+declare( strict_types = 1 );
+
 // @codeCoverageIgnoreStart
 if ( ! defined( 'ABSPATH' ) ) {
 	exit; // Exit if accessed directly.
Index: load.php
===================================================================
--- load.php	(revision 3592034)
+++ load.php	(working copy)
@@ -3,8 +3,8 @@
  * Plugin Name: Optimization Detective
  * Plugin URI: https://github.com/WordPress/performance/tree/trunk/plugins/optimization-detective
  * Description: Provides a framework for leveraging real user metrics to detect optimizations for improving page performance.
- * Requires at least: 6.6
- * Requires PHP: 7.2
+ * Requires at least: 6.9
+ * Requires PHP: 7.4
  * Version: 1.0.0-beta5
  * Author: WordPress Performance Team
  * Author URI: https://make.wordpress.org/performance/
@@ -15,6 +15,8 @@
  * @package optimization-detective
  */
 
+declare( strict_types = 1 );
+
 // @codeCoverageIgnoreStart
 if ( ! defined( 'ABSPATH' ) ) {
 	exit; // Exit if accessed directly.
@@ -99,9 +101,6 @@
 
 		require_once __DIR__ . '/helper.php';
 
-		// Deprecations.
-		require_once __DIR__ . '/deprecated.php';
-
 		// Core infrastructure classes.
 		require_once __DIR__ . '/class-od-data-validation-exception.php';
 		require_once __DIR__ . '/class-od-html-tag-processor.php';
Index: optimization.php
===================================================================
--- optimization.php	(revision 3592034)
+++ optimization.php	(working copy)
@@ -6,6 +6,8 @@
  * @since 0.1.0
  */
 
+declare( strict_types = 1 );
+
 // @codeCoverageIgnoreStart
 if ( ! defined( 'ABSPATH' ) ) {
 	exit; // Exit if accessed directly.
@@ -122,7 +124,7 @@
 		wp_print_inline_script_tag(
 			sprintf(
 				"console.info( %s );\n//# sourceURL=od-print-disabled-reasons-%d",
-				wp_json_encode( '[Optimization Detective] ' . $reason, JSON_HEX_TAG | JSON_UNESCAPED_SLASHES ),
+				(string) wp_json_encode( '[Optimization Detective] ' . $reason, JSON_HEX_TAG | JSON_UNESCAPED_SLASHES ),
 				$i + 1
 			),
 			array( 'type' => 'module' )
Index: site-health.php
===================================================================
--- site-health.php	(revision 3592034)
+++ site-health.php	(working copy)
@@ -6,6 +6,8 @@
  * @since 1.0.0
  */
 
+declare( strict_types = 1 );
+
 // @codeCoverageIgnoreStart
 if ( ! defined( 'ABSPATH' ) ) {
 	exit; // Exit if accessed directly.
@@ -18,8 +20,9 @@
  * @since 1.0.0
  * @access private
  *
- * @param array{direct: array<string, array{label: string, test: string}>}|mixed $tests Site Health Tests.
- * @return array{direct: array<string, array{label: string, test: string}>} Amended tests.
+ * @param array<string, mixed>|mixed $tests Site Health Tests.
+ * @phpstan-param array{direct: array<string, array{label: string, test: callable}>, ...}|mixed $tests
+ * @return array{direct: array<string, array{label: string, test: callable}>, ...} Amended tests.
  */
 function od_add_rest_api_availability_test( $tests ): array {
 	if ( ! is_array( $tests ) ) {
@@ -184,7 +187,7 @@
  * @access private
  *
  * @param bool $use_cached Whether to use a previous response cached in a transient.
- * @return array{ response: array{ code: int, message: string }, body: string }|WP_Error Response.
+ * @return array{ response: array{ code: int, message: string }, body: string, ... }|WP_Error Response.
  */
 function od_get_rest_api_health_check_response( bool $use_cached ) {
 	$transient_key = 'od_rest_api_health_check_response';
Index: storage/class-od-rest-url-metrics-store-endpoint.php
===================================================================
--- storage/class-od-rest-url-metrics-store-endpoint.php	(revision 3592034)
+++ storage/class-od-rest-url-metrics-store-endpoint.php	(working copy)
@@ -6,6 +6,8 @@
  * @since 0.1.0
  */
 
+declare( strict_types = 1 );
+
 // @codeCoverageIgnoreStart
 if ( ! defined( 'ABSPATH' ) ) {
 	exit; // Exit if accessed directly.
@@ -54,8 +56,8 @@
 	 * }
 	 */
 	public function get_registration_args(): array {
-		// 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.
+		// The slug and cache_purge_post_id args are further validated in the callback by checking against the 'hmac'
+		// parameter. They are provided as input with the 'url' argument to create the HMAC by the server.
 		$args = array(
 			'slug'                => array(
 				'type'        => 'string',
@@ -80,16 +82,10 @@
 				'minimum'     => 1,
 			),
 			'hmac'                => array(
-				'type'              => 'string',
-				'description'       => __( 'HMAC originally computed by server required to authorize the request.', 'optimization-detective' ),
-				'required'          => true,
-				'pattern'           => '^[0-9a-f]+\z',
-				'validate_callback' => static function ( string $hmac, WP_REST_Request $request ) {
-					if ( '' === $hmac || ! od_verify_url_metrics_storage_hmac( $hmac, $request['slug'], $request['current_etag'], $request['url'], $request['cache_purge_post_id'] ?? null ) ) {
-						return new WP_Error( 'invalid_hmac', __( 'URL Metrics HMAC verification failure.', 'optimization-detective' ) );
-					}
-					return true;
-				},
+				'type'        => 'string',
+				'description' => __( 'HMAC originally computed by server required to authorize the request.', 'optimization-detective' ),
+				'required'    => true,
+				'pattern'     => '^[0-9a-f]+\z',
 			),
 		);
 
@@ -167,6 +163,15 @@
 			);
 		}
 
+		// This validation is done here as opposed to a validate_callback for the arg since here the params have all been validated and sanitized.
+		if ( ! od_verify_url_metrics_storage_hmac( $request['hmac'], $request['slug'], $request['current_etag'], $request['url'], $request['cache_purge_post_id'] ) ) {
+			return new WP_Error(
+				'rest_invalid_param',
+				__( 'URL Metrics HMAC verification failure.', 'optimization-detective' ),
+				array( 'status' => 400 )
+			);
+		}
+
 		$post = OD_URL_Metrics_Post_Type::get_post( $request->get_param( 'slug' ) );
 
 		$url_metric_group_collection = new OD_URL_Metric_Group_Collection(
Index: storage/class-od-storage-lock.php
===================================================================
--- storage/class-od-storage-lock.php	(revision 3592034)
+++ storage/class-od-storage-lock.php	(working copy)
@@ -6,6 +6,8 @@
  * @since 0.1.0
  */
 
+declare( strict_types = 1 );
+
 // @codeCoverageIgnoreStart
 if ( ! defined( 'ABSPATH' ) ) {
 	exit; // Exit if accessed directly.
Index: storage/class-od-url-metric-store-request-context.php
===================================================================
--- storage/class-od-url-metric-store-request-context.php	(revision 3592034)
+++ storage/class-od-url-metric-store-request-context.php	(working copy)
@@ -6,6 +6,8 @@
  * @since 0.7.0
  */
 
+declare( strict_types = 1 );
+
 // @codeCoverageIgnoreStart
 if ( ! defined( 'ABSPATH' ) ) {
 	exit; // Exit if accessed directly.
@@ -32,7 +34,7 @@
 	 * @since 0.7.0
 	 * @var WP_REST_Request<array<string, mixed>>
 	 */
-	private $request;
+	private WP_REST_Request $request;
 
 	/**
 	 * ID for the od_url_metrics post.
@@ -42,7 +44,7 @@
 	 * @since 1.0.0
 	 * @var positive-int
 	 */
-	private $url_metrics_id;
+	private int $url_metrics_id;
 
 	/**
 	 * URL Metric group collection.
@@ -50,7 +52,7 @@
 	 * @since 0.7.0
 	 * @var OD_URL_Metric_Group_Collection
 	 */
-	private $url_metric_group_collection;
+	private OD_URL_Metric_Group_Collection $url_metric_group_collection;
 
 	/**
 	 * URL Metric group.
@@ -58,7 +60,7 @@
 	 * @since 0.7.0
 	 * @var OD_URL_Metric_Group
 	 */
-	private $url_metric_group;
+	private OD_URL_Metric_Group $url_metric_group;
 
 	/**
 	 * URL Metric.
@@ -66,7 +68,7 @@
 	 * @since 0.7.0
 	 * @var OD_URL_Metric
 	 */
-	private $url_metric;
+	private OD_URL_Metric $url_metric;
 
 	/**
 	 * Constructor.
Index: storage/class-od-url-metrics-post-type.php
===================================================================
--- storage/class-od-url-metrics-post-type.php	(revision 3592034)
+++ storage/class-od-url-metrics-post-type.php	(working copy)
@@ -6,6 +6,8 @@
  * @since 0.1.0
  */
 
+declare( strict_types = 1 );
+
 // @codeCoverageIgnoreStart
 if ( ! defined( 'ABSPATH' ) ) {
 	exit; // Exit if accessed directly.
Index: storage/data.php
===================================================================
--- storage/data.php	(revision 3592034)
+++ storage/data.php	(working copy)
@@ -6,6 +6,8 @@
  * @since 0.1.0
  */
 
+declare( strict_types = 1 );
+
 // @codeCoverageIgnoreStart
 if ( ! defined( 'ABSPATH' ) ) {
 	exit; // Exit if accessed directly.
Index: uninstall.php
===================================================================
--- uninstall.php	(revision 3592034)
+++ uninstall.php	(working copy)
@@ -6,6 +6,8 @@
  * @since 0.1.0
  */
 
+declare( strict_types = 1 );
+
 // If uninstall.php is not called by WordPress, bail.
 if ( ! defined( 'WP_UNINSTALL_PLUGIN' ) ) {
 	exit; // @codeCoverageIgnore
@@ -40,7 +42,7 @@
 	);
 
 	// Skip iterating over self.
-	$od_site_ids = array_diff(
+	$od_site_ids = array_diff( // @phpstan-ignore argument.type (get_sites( 'fields' => 'ids' ) returns int[], but php-stubs/wordpress-stubs uses a sealed array shape in its conditional return type so the narrowing is lost when extra args are passed. TODO: Fix upstream in php-stubs/wordpress-stubs and remove.)
 		$od_site_ids,
 		array( get_current_blog_id() )
 	);
@@ -47,7 +49,7 @@
 
 	// Delete all other blogs' URL Metrics posts.
 	foreach ( $od_site_ids as $od_site_id ) {
-		switch_to_blog( $od_site_id );
+		switch_to_blog( $od_site_id ); // @phpstan-ignore argument.type (get_sites( 'fields' => 'ids' ) returns int[], but php-stubs/wordpress-stubs uses a sealed array shape in its conditional return type so the narrowing is lost when extra args are passed. TODO: Fix upstream in php-stubs/wordpress-stubs and remove.)
 		$od_delete_site_data();
 		restore_current_blog();
 	}

performance-lab

Important

Stable tag change: 4.1.0 → 4.2.0

svn status:

?       hooks.php
M       includes/admin/load.php
M       includes/admin/plugin-activate-ajax.js
M       includes/admin/plugins.php
M       includes/admin/rest-api.php
M       includes/admin/server-timing.php
M       includes/server-timing/class-perflab-server-timing-metric.php
M       includes/server-timing/class-perflab-server-timing.php
M       includes/server-timing/defaults.php
M       includes/server-timing/hooks.php
M       includes/server-timing/load.php
M       includes/server-timing/object-cache.copy.php
M       includes/site-health/audit-autoloaded-options/helper.php
M       includes/site-health/audit-autoloaded-options/hooks.php
M       includes/site-health/audit-enqueued-assets/helper.php
M       includes/site-health/audit-enqueued-assets/hooks.php
M       includes/site-health/avif-headers/helper.php
M       includes/site-health/avif-headers/hooks.php
M       includes/site-health/avif-support/helper.php
M       includes/site-health/avif-support/hooks.php
M       includes/site-health/bfcache-compatibility-headers/helper.php
M       includes/site-health/bfcache-compatibility-headers/hooks.php
M       includes/site-health/effective-asset-cache-headers/helper.php
M       includes/site-health/effective-asset-cache-headers/hooks.php
M       includes/site-health/load.php
M       includes/site-health/webp-support/helper.php
M       includes/site-health/webp-support/hooks.php
M       load.php
M       readme.txt
svn diff
Index: includes/admin/load.php
===================================================================
--- includes/admin/load.php	(revision 3592034)
+++ includes/admin/load.php	(working copy)
@@ -5,6 +5,8 @@
  * @package performance-lab
  */
 
+declare( strict_types = 1 );
+
 // @codeCoverageIgnoreStart
 if ( ! defined( 'ABSPATH' ) ) {
 	exit; // Exit if accessed directly.
Index: includes/admin/plugin-activate-ajax.js
===================================================================
--- includes/admin/plugin-activate-ajax.js	(revision 3592034)
+++ includes/admin/plugin-activate-ajax.js	(working copy)
@@ -8,6 +8,7 @@
 	const { __ } = i18n;
 
 	// Queue to hold pending activation requests.
+	/** @type {{target: HTMLElement, pluginSlug: string}[]} */
 	const activationQueue = [];
 	let isProcessingActivation = false;
 
@@ -32,8 +33,7 @@
 		target.classList.add( 'updating-message' );
 		target.textContent = __( 'Waiting…', 'performance-lab' );
 
-		const pluginSlug = target.dataset.pluginSlug;
-
+		const pluginSlug = /** @type {string} */ ( target.dataset.pluginSlug );
 		activationQueue.push( { target, pluginSlug } );
 
 		// Start processing the queue if not already doing so.
@@ -48,7 +48,8 @@
 	 * @return {Promise<void>} The asynchronous function returns a promise that resolves to void.
 	 */
 	async function handlePluginActivation() {
-		if ( 0 === activationQueue.length ) {
+		const activationItem = activationQueue.shift();
+		if ( ! activationItem ) {
 			isProcessingActivation = false;
 			return;
 		}
@@ -55,7 +56,7 @@
 
 		isProcessingActivation = true;
 
-		const { target, pluginSlug } = activationQueue.shift();
+		const { target, pluginSlug } = activationItem;
 
 		target.textContent = __( 'Activating…', 'performance-lab' );
 
@@ -87,7 +88,7 @@
 				anchor.textContent = __( 'Settings', 'performance-lab' );
 
 				listItem.appendChild( anchor );
-				actionButtonList.appendChild( listItem );
+				actionButtonList?.appendChild( listItem );
 			}
 
 			a11y.speak( __( 'Plugin activated.', 'performance-lab' ) );
@@ -95,7 +96,7 @@
 			target.textContent = __( 'Active', 'performance-lab' );
 			target.classList.remove( 'updating-message' );
 			target.classList.add( 'disabled' );
-		} catch ( error ) {
+		} catch {
 			a11y.speak( __( 'Plugin failed to activate.', 'performance-lab' ) );
 
 			target.classList.remove( 'updating-message' );
@@ -107,8 +108,10 @@
 
 	// Attach the event listeners.
 	document
-		.querySelectorAll( '.perflab-install-active-plugin' )
+		.querySelectorAll( '.perflab-install-active-plugin[data-plugin-slug]' )
 		.forEach( ( item ) => {
-			item.addEventListener( 'click', enqueuePluginActivation );
+			item.addEventListener( 'click', ( event ) =>
+				enqueuePluginActivation( /** @type {MouseEvent} */ ( event ) )
+			);
 		} );
 } )();
Index: includes/admin/plugins.php
===================================================================
--- includes/admin/plugins.php	(revision 3592034)
+++ includes/admin/plugins.php	(working copy)
@@ -6,6 +6,8 @@
  * @noinspection PhpRedundantOptionalArgumentInspection
  */
 
+declare( strict_types = 1 );
+
 // @codeCoverageIgnoreStart
 if ( ! defined( 'ABSPATH' ) ) {
 	exit; // Exit if accessed directly.
@@ -48,7 +50,7 @@
 	// Proceed with API request since no cache hit.
 	$response = plugins_api(
 		'query_plugins',
-		array(
+		array( // @phpstan-ignore argument.type (plugins_api()'s $args is typed too narrowly in php-stubs/wordpress-stubs to include the 'fields' shape passed here. TODO: Fix upstream in php-stubs/wordpress-stubs and remove.)
 			'author'   => 'wordpressdotorg',
 			'tag'      => 'performance',
 			'per_page' => 100,
@@ -327,8 +329,8 @@
  * @since 3.1.0
  * @see perflab_install_and_activate_plugin()
  *
- * @param array{name: string, slug: string, short_description: string, requires_php: string|false, requires: string|false, requires_plugins: string[], version: string} $plugin_data                     Plugin data from the WordPress.org API.
- * @param array<string, array{compatible_php: bool, compatible_wp: bool, can_install: bool, can_activate: bool, activated: bool, installed: bool}>                      $processed_plugin_availabilities Plugin availabilities already processed. This param is only used by recursive calls.
+ * @param array{name: string, slug: string, short_description: string, requires_php: string|false, requires: string|false, requires_plugins: string[], version: string, experimental?: bool} $plugin_data                     Plugin data from the WordPress.org API.
+ * @param array<string, array{compatible_php: bool, compatible_wp: bool, can_install: bool, can_activate: bool, activated: bool, installed: bool}>                                           $processed_plugin_availabilities Plugin availabilities already processed. This param is only used by recursive calls.
  * @return array{compatible_php: bool, compatible_wp: bool, can_install: bool, can_activate: bool, activated: bool, installed: bool} Availability.
  */
 function perflab_get_plugin_availability( array $plugin_data, array &$processed_plugin_availabilities = array() ): array {
@@ -408,7 +410,7 @@
 	// Get the freshest data (including the most recent download_link) as opposed what is cached by perflab_query_plugin_info().
 	$plugin_data = plugins_api(
 		'plugin_information',
-		array(
+		array( // @phpstan-ignore argument.type (plugins_api()'s $args is typed too narrowly in php-stubs/wordpress-stubs to include the 'fields' shape passed here. TODO: Fix upstream in php-stubs/wordpress-stubs and remove.)
 			'slug'   => $plugin_slug,
 			'fields' => array(
 				'download_link'    => true,
Index: includes/admin/rest-api.php
===================================================================
--- includes/admin/rest-api.php	(revision 3592034)
+++ includes/admin/rest-api.php	(working copy)
@@ -6,6 +6,8 @@
  * @since 3.6.0
  */
 
+declare( strict_types = 1 );
+
 // @codeCoverageIgnoreStart
 if ( ! defined( 'ABSPATH' ) ) {
 	exit; // Exit if accessed directly.
Index: includes/admin/server-timing.php
===================================================================
--- includes/admin/server-timing.php	(revision 3592034)
+++ includes/admin/server-timing.php	(working copy)
@@ -5,6 +5,8 @@
  * @package performance-lab
  */
 
+declare( strict_types = 1 );
+
 // @codeCoverageIgnoreStart
 if ( ! defined( 'ABSPATH' ) ) {
 	exit; // Exit if accessed directly.
Index: includes/server-timing/class-perflab-server-timing-metric.php
===================================================================
--- includes/server-timing/class-perflab-server-timing-metric.php	(revision 3592034)
+++ includes/server-timing/class-perflab-server-timing-metric.php	(working copy)
@@ -6,6 +6,8 @@
  * @since 1.8.0
  */
 
+declare( strict_types = 1 );
+
 /**
  * Class representing a single Server-Timing metric.
  *
@@ -19,7 +21,7 @@
 	 * @since 1.8.0
 	 * @var string
 	 */
-	private $slug;
+	private string $slug;
 
 	/**
 	 * The metric value in milliseconds.
@@ -27,7 +29,7 @@
 	 * @since 1.8.0
 	 * @var int|float|null
 	 */
-	private $value;
+	private $value; // phpcs:ignore SlevomatCodingStandard.TypeHints.PropertyTypeHint.MissingNativeTypeHint -- TODO: Use native int|float|null union type when PHP 8.0 is the minimum.
 
 	/**
 	 * The value measured before relevant execution logic in seconds, if used.
@@ -35,9 +37,17 @@
 	 * @since 1.8.0
 	 * @var float|null
 	 */
-	private $before_value;
+	private ?float $before_value = null;
 
 	/**
+	 * The metric description.
+	 *
+	 * @since 4.2.0
+	 * @var non-empty-string|null
+	 */
+	private ?string $description = null;
+
+	/**
 	 * Constructor.
 	 *
 	 * @since 1.8.0
@@ -142,4 +152,39 @@
 
 		$this->set_value( ( microtime( true ) - $this->before_value ) * 1000.0 );
 	}
+
+	/**
+	 * Sets the metric description.
+	 *
+	 * An empty string is treated as no description, in which case the metric is
+	 * emitted without a `desc` parameter.
+	 *
+	 * @since 4.2.0
+	 *
+	 * @param string $description The metric description. Pass an empty string to unset it.
+	 */
+	public function set_description( string $description ): void {
+		if ( 0 !== did_action( 'perflab_server_timing_send_header' ) && ! doing_action( 'perflab_server_timing_send_header' ) ) {
+			_doing_it_wrong(
+				__METHOD__,
+				/* translators: %s: WordPress action name */
+				sprintf( esc_html__( 'The method must be called before or during the %s action.', 'performance-lab' ), 'perflab_server_timing_send_header' ),
+				''
+			);
+			return;
+		}
+
+		$this->description = '' !== $description ? $description : null;
+	}
+
+	/**
+	 * Gets the metric description.
+	 *
+	 * @since 4.2.0
+	 *
+	 * @return non-empty-string|null The metric description, or null if none set.
+	 */
+	public function get_description(): ?string {
+		return $this->description;
+	}
 }
Index: includes/server-timing/class-perflab-server-timing.php
===================================================================
--- includes/server-timing/class-perflab-server-timing.php	(revision 3592034)
+++ includes/server-timing/class-perflab-server-timing.php	(working copy)
@@ -6,6 +6,8 @@
  * @since 1.8.0
  */
 
+declare( strict_types = 1 );
+
 // @codeCoverageIgnoreStart
 if ( ! defined( 'ABSPATH' ) ) {
 	exit; // Exit if accessed directly.
@@ -30,7 +32,7 @@
 	 * @since 1.8.0
 	 * @var array<string, Perflab_Server_Timing_Metric>
 	 */
-	private $registered_metrics = array();
+	private array $registered_metrics = array();
 
 	/**
 	 * Map of registered metric slugs and their registered data.
@@ -39,7 +41,7 @@
 	 * @phpstan-var array<string, MetricArguments>
 	 * @var array<string, array>
 	 */
-	private $registered_metrics_data = array();
+	private array $registered_metrics_data = array();
 
 	/**
 	 * Registers a metric to calculate for the Server-Timing header.
@@ -194,9 +196,7 @@
 				},
 				$this->registered_metrics
 			),
-			static function ( $value ) {
-				return null !== $value;
-			}
+			static fn ( $value ) => null !== $value
 		);
 
 		return implode( ', ', $metric_header_values );
@@ -290,23 +290,35 @@
 	 * @since 1.8.0
 	 *
 	 * @param Perflab_Server_Timing_Metric $metric The metric to format.
-	 * @return string|null Segment for the Server-Timing header, or null if no value set.
+	 * @return string Segment for the Server-Timing header.
 	 */
-	private function format_metric_header_value( Perflab_Server_Timing_Metric $metric ): ?string {
-		$value = $metric->get_value();
+	private function format_metric_header_value( Perflab_Server_Timing_Metric $metric ): string {
+		$value       = $metric->get_value();
+		$description = $metric->get_description();
 
-		// If no value is set, make sure it's just passed through.
-		if ( null === $value ) {
-			return null;
+		// See https://github.com/WordPress/performance/issues/955.
+		$name = preg_replace( '/[^!#$%&\'*+\-.^_`|~0-9a-zA-Z]/', '-', $metric->get_slug() );
+
+		$parts = array( sprintf( 'wp-%s', $name ) );
+
+		if ( null !== $value ) {
+			if ( is_float( $value ) ) {
+				$value = round( $value, 2 );
+			}
+			$parts[] = sprintf( 'dur=%s', $value );
 		}
 
-		if ( is_float( $value ) ) {
-			$value = round( $value, 2 );
+		if ( null !== $description ) {
+			// Sanitize the description for the HTTP header quoted-string format (RFC 7230).
+			// Strip control characters (incl. CR/LF), which are not representable in a quoted-string,
+			// then escape the backslash and double-quote characters that require a quoted-pair.
+			$sanitized_description = (string) preg_replace( '/[\x00-\x1F\x7F]/', '', $description );
+			if ( '' !== $sanitized_description ) {
+				$sanitized_description = addcslashes( $sanitized_description, '\\"' );
+				$parts[]               = sprintf( 'desc="%s"', $sanitized_description );
+			}
 		}
 
-		// See https://github.com/WordPress/performance/issues/955.
-		$name = preg_replace( '/[^!#$%&\'*+\-.^_`|~0-9a-zA-Z]/', '-', $metric->get_slug() );
-
-		return sprintf( 'wp-%1$s;dur=%2$s', $name, $value );
+		return implode( ';', $parts );
 	}
 }
Index: includes/server-timing/defaults.php
===================================================================
--- includes/server-timing/defaults.php	(revision 3592034)
+++ includes/server-timing/defaults.php	(working copy)
@@ -6,6 +6,8 @@
  * @since 1.8.0
  */
 
+declare( strict_types = 1 );
+
 // @codeCoverageIgnoreStart
 if ( ! defined( 'ABSPATH' ) ) {
 	exit; // Exit if accessed directly.
@@ -93,9 +95,7 @@
 						// Store this value in a global to later subtract it from total query time after template.
 						$GLOBALS['perflab_query_time_before_template'] = array_reduce(
 							$query_times,
-							static function ( float $acc, float $query_time ): float {
-								return $acc + $query_time;
-							},
+							static fn ( float $acc, float $query_time ): float => $acc + $query_time,
 							0.0
 						);
 						$metric->set_value( $GLOBALS['perflab_query_time_before_template'] * 1000.0 );
Index: includes/server-timing/hooks.php
===================================================================
--- includes/server-timing/hooks.php	(revision 3592034)
+++ includes/server-timing/hooks.php	(working copy)
@@ -7,6 +7,8 @@
  * @since 3.1.0
  */
 
+declare( strict_types = 1 );
+
 // @codeCoverageIgnoreStart
 if ( ! defined( 'ABSPATH' ) ) {
 	exit; // Exit if accessed directly.
Index: includes/server-timing/load.php
===================================================================
--- includes/server-timing/load.php	(revision 3592034)
+++ includes/server-timing/load.php	(working copy)
@@ -6,6 +6,8 @@
  * @since 1.8.0
  */
 
+declare( strict_types = 1 );
+
 // @codeCoverageIgnoreStart
 if ( ! defined( 'ABSPATH' ) ) {
 	exit; // Exit if accessed directly.
Index: includes/server-timing/object-cache.copy.php
===================================================================
--- includes/server-timing/object-cache.copy.php	(revision 3592034)
+++ includes/server-timing/object-cache.copy.php	(working copy)
@@ -28,6 +28,8 @@
  * @since 1.8.0
  */
 
+declare( strict_types = 1 );
+
 // @codeCoverageIgnoreStart
 if ( ! defined( 'ABSPATH' ) ) {
 	exit; // Exit if accessed directly.
Index: includes/site-health/audit-autoloaded-options/helper.php
===================================================================
--- includes/site-health/audit-autoloaded-options/helper.php	(revision 3592034)
+++ includes/site-health/audit-autoloaded-options/helper.php	(working copy)
@@ -6,6 +6,8 @@
  * @since 2.1.0
  */
 
+declare( strict_types = 1 );
+
 // @codeCoverageIgnoreStart
 if ( ! defined( 'ABSPATH' ) ) {
 	exit; // Exit if accessed directly.
@@ -147,9 +149,10 @@
 	$large_options = array();
 
 	foreach ( $all_options as $option_name => $option_value ) {
-		if ( is_array( $option_value ) || is_object( $option_value ) ) {
+		if ( ! is_string( $option_value ) ) {
 			$option_value = serialize( $option_value ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.serialize_serialize
 		}
+
 		if ( strlen( $option_value ) > $option_threshold ) {
 			$large_options[] = (object) array(
 				'option_name'         => $option_name,
@@ -199,7 +202,7 @@
 			)
 		);
 		$disable_button = sprintf( '<a class="button" href="%s">%s</a>', esc_url( $url ), esc_html__( 'Disable Autoload', 'performance-lab' ) );
-		$html_table    .= sprintf( '<tr><td>%s</td><td>%s</td><td>%s</td></tr>', esc_html( $value->option_name ), size_format( $value->option_value_length, 2 ), $disable_button );
+		$html_table    .= sprintf( '<tr><td>%s</td><td>%s</td><td>%s</td></tr>', esc_html( $value->option_name ), (string) size_format( $value->option_value_length, 2 ), $disable_button );
 	}
 	$html_table .= '</tbody></table>';
 
Index: includes/site-health/audit-autoloaded-options/hooks.php
===================================================================
--- includes/site-health/audit-autoloaded-options/hooks.php	(revision 3592034)
+++ includes/site-health/audit-autoloaded-options/hooks.php	(working copy)
@@ -6,6 +6,8 @@
  * @since 2.1.0
  */
 
+declare( strict_types = 1 );
+
 // @codeCoverageIgnoreStart
 if ( ! defined( 'ABSPATH' ) ) {
 	exit; // Exit if accessed directly.
Index: includes/site-health/audit-enqueued-assets/helper.php
===================================================================
--- includes/site-health/audit-enqueued-assets/helper.php	(revision 3592034)
+++ includes/site-health/audit-enqueued-assets/helper.php	(working copy)
@@ -6,6 +6,8 @@
  * @since 1.0.0
  */
 
+declare( strict_types = 1 );
+
 // @codeCoverageIgnoreStart
 if ( ! defined( 'ABSPATH' ) ) {
 	exit; // Exit if accessed directly.
@@ -25,6 +27,7 @@
  *                     code: int|false,
  *                     message: string|false,
  *                 },
+ *                 ...
  *             },
  *             assets: array{
  *                 scripts: array<array{ src: string, size: int|null, error: WP_Error|null }>,
@@ -419,10 +422,11 @@
  *                        code: int|false,
  *                        message: string|false,
  *                    },
+ *                    ...
  *                } $response
  *
  * @param WP_Error|array<string, mixed> $response The response from the home page retrieval.
- * @return array{status: 'recommended', description: string}|null Result, or null if there was no failure.
+ * @return array{label: string, status: 'recommended', description: string}|null Result, or null if there was no failure.
  */
 function perflab_aea_blocking_assets_retrieval_failure( $response ): ?array {
 	$result = array(
@@ -522,9 +526,83 @@
 		);
 	}
 
-	// TODO: A non-cacheable response should also be considered an error.
-	// TODO: A size of zero could be considered an error too.
-	return strlen( wp_remote_retrieve_body( $response ) );
+	$body = wp_remote_retrieve_body( $response );
+
+	if ( strlen( $body ) === 0 ) {
+		return new WP_Error(
+			'zero_size',
+			esc_html__( 'The asset returned an empty response body.', 'performance-lab' )
+		);
+	}
+
+	$cache_control = wp_remote_retrieve_header( $response, 'cache-control' );
+	if ( is_array( $cache_control ) ) {
+		$cache_control = implode( ', ', $cache_control );
+	}
+	if ( '' !== $cache_control ) {
+		// Parse the directives into a name => value map so values like max-age can be compared numerically.
+		$directives = array();
+		foreach ( explode( ',', strtolower( $cache_control ) ) as $directive ) {
+			$parts               = explode( '=', $directive, 2 );
+			$name                = trim( $parts[0] );
+			$directives[ $name ] = isset( $parts[1] ) ? trim( $parts[1], " \t\n\r\0\x0B\"'" ) : '';
+		}
+		if ( array_key_exists( 'no-store', $directives ) ) {
+			// no-store forbids browsers from caching the asset at all.
+			return new WP_Error(
+				'not_cacheable',
+				esc_html__( 'The asset response cannot be cached by browsers.', 'performance-lab' )
+			);
+		}
+		if (
+			array_key_exists( 'no-cache', $directives ) ||
+			( array_key_exists( 'max-age', $directives ) && 0 === (int) $directives['max-age'] )
+		) {
+			// no-cache and max-age=0 permit caching but force revalidation on every request.
+			return new WP_Error(
+				'not_cacheable',
+				esc_html__( 'The asset response may not be fully cacheable by browsers.', 'performance-lab' )
+			);
+		}
+	} else {
+		// No Cache-Control header: fall back to Expires.
+		$expires = wp_remote_retrieve_header( $response, 'expires' );
+		if ( is_array( $expires ) ) {
+			$expires = end( $expires );
+		}
+		if ( '' !== $expires ) {
+			// An invalid Expires value is treated as already expired per RFC 7234.
+			$expires_timestamp = strtotime( $expires );
+			if ( false === $expires_timestamp || $expires_timestamp <= time() ) {
+				return new WP_Error(
+					'not_cacheable',
+					esc_html__( 'The asset response may not be fully cacheable by browsers.', 'performance-lab' )
+				);
+			}
+		} else {
+			// No Cache-Control and no Expires: heuristic caching is only viable when a validator
+			// (Last-Modified or ETag) is present. Without one, the asset effectively cannot be cached.
+			$has_validator = false;
+			foreach ( array( 'last-modified', 'etag' ) as $validator_header ) {
+				$value = wp_remote_retrieve_header( $response, $validator_header );
+				if ( is_array( $value ) ) {
+					$value = implode( '', $value );
+				}
+				if ( '' !== $value ) {
+					$has_validator = true;
+					break;
+				}
+			}
+			if ( ! $has_validator ) {
+				return new WP_Error(
+					'not_cacheable',
+					esc_html__( 'The asset response may not be fully cacheable by browsers.', 'performance-lab' )
+				);
+			}
+		}
+	}
+
+	return strlen( $body );
 }
 
 /**
Index: includes/site-health/audit-enqueued-assets/hooks.php
===================================================================
--- includes/site-health/audit-enqueued-assets/hooks.php	(revision 3592034)
+++ includes/site-health/audit-enqueued-assets/hooks.php	(working copy)
@@ -6,6 +6,8 @@
  * @since 2.1.0
  */
 
+declare( strict_types = 1 );
+
 // @codeCoverageIgnoreStart
 if ( ! defined( 'ABSPATH' ) ) {
 	exit; // Exit if accessed directly.
@@ -17,8 +19,9 @@
  *
  * @since 1.0.0
  *
- * @param array{direct: array<string, array{label: string, test: string}>} $tests Site Health Tests.
- * @return array{direct: array<string, array{label: string, test: string}>} Amended tests.
+ * @param array<string, mixed> $tests Site Health Tests.
+ * @phpstan-param array{direct: array<string, array{label: string, test: string}>, ...} $tests
+ * @return array{direct: array<string, array{label: string, test: string}>, async: array<string, array{label: string, test: string, ...}>, ...} Amended tests.
  */
 function perflab_aea_add_enqueued_assets_test( array $tests ): array {
 	$tests['async']['enqueued_blocking_assets'] = array(
Index: includes/site-health/avif-headers/helper.php
===================================================================
--- includes/site-health/avif-headers/helper.php	(revision 3592034)
+++ includes/site-health/avif-headers/helper.php	(working copy)
@@ -6,6 +6,8 @@
  * @since 3.6.0
  */
 
+declare( strict_types = 1 );
+
 // @codeCoverageIgnoreStart
 if ( ! defined( 'ABSPATH' ) ) {
 	exit; // Exit if accessed directly.
Index: includes/site-health/avif-headers/hooks.php
===================================================================
--- includes/site-health/avif-headers/hooks.php	(revision 3592034)
+++ includes/site-health/avif-headers/hooks.php	(working copy)
@@ -6,6 +6,8 @@
  * @since 3.6.0
  */
 
+declare( strict_types = 1 );
+
 // @codeCoverageIgnoreStart
 if ( ! defined( 'ABSPATH' ) ) {
 	exit; // Exit if accessed directly.
Index: includes/site-health/avif-support/helper.php
===================================================================
--- includes/site-health/avif-support/helper.php	(revision 3592034)
+++ includes/site-health/avif-support/helper.php	(working copy)
@@ -6,6 +6,8 @@
  * @since 3.1.0
  */
 
+declare( strict_types = 1 );
+
 // @codeCoverageIgnoreStart
 if ( ! defined( 'ABSPATH' ) ) {
 	exit; // Exit if accessed directly.
Index: includes/site-health/avif-support/hooks.php
===================================================================
--- includes/site-health/avif-support/hooks.php	(revision 3592034)
+++ includes/site-health/avif-support/hooks.php	(working copy)
@@ -6,6 +6,8 @@
  * @since 3.1.0
  */
 
+declare( strict_types = 1 );
+
 // @codeCoverageIgnoreStart
 if ( ! defined( 'ABSPATH' ) ) {
 	exit; // Exit if accessed directly.
Index: includes/site-health/bfcache-compatibility-headers/helper.php
===================================================================
--- includes/site-health/bfcache-compatibility-headers/helper.php	(revision 3592034)
+++ includes/site-health/bfcache-compatibility-headers/helper.php	(working copy)
@@ -6,6 +6,8 @@
  * @since 3.8.0
  */
 
+declare( strict_types = 1 );
+
 // @codeCoverageIgnoreStart
 if ( ! defined( 'ABSPATH' ) ) {
 	exit; // Exit if accessed directly.
Index: includes/site-health/bfcache-compatibility-headers/hooks.php
===================================================================
--- includes/site-health/bfcache-compatibility-headers/hooks.php	(revision 3592034)
+++ includes/site-health/bfcache-compatibility-headers/hooks.php	(working copy)
@@ -6,6 +6,8 @@
  * @since 3.8.0
  */
 
+declare( strict_types = 1 );
+
 // @codeCoverageIgnoreStart
 if ( ! defined( 'ABSPATH' ) ) {
 	exit; // Exit if accessed directly.
Index: includes/site-health/effective-asset-cache-headers/helper.php
===================================================================
--- includes/site-health/effective-asset-cache-headers/helper.php	(revision 3592034)
+++ includes/site-health/effective-asset-cache-headers/helper.php	(working copy)
@@ -6,6 +6,8 @@
  * @since 3.8.0
  */
 
+declare( strict_types = 1 );
+
 // @codeCoverageIgnoreStart
 if ( ! defined( 'ABSPATH' ) ) {
 	exit; // Exit if accessed directly.
Index: includes/site-health/effective-asset-cache-headers/hooks.php
===================================================================
--- includes/site-health/effective-asset-cache-headers/hooks.php	(revision 3592034)
+++ includes/site-health/effective-asset-cache-headers/hooks.php	(working copy)
@@ -6,6 +6,8 @@
  * @since 3.8.0
  */
 
+declare( strict_types = 1 );
+
 // @codeCoverageIgnoreStart
 if ( ! defined( 'ABSPATH' ) ) {
 	exit; // Exit if accessed directly.
Index: includes/site-health/load.php
===================================================================
--- includes/site-health/load.php	(revision 3592034)
+++ includes/site-health/load.php	(working copy)
@@ -6,6 +6,8 @@
  * @since 3.0.0
  */
 
+declare( strict_types = 1 );
+
 // @codeCoverageIgnoreStart
 if ( ! defined( 'ABSPATH' ) ) {
 	exit; // Exit if accessed directly.
Index: includes/site-health/webp-support/helper.php
===================================================================
--- includes/site-health/webp-support/helper.php	(revision 3592034)
+++ includes/site-health/webp-support/helper.php	(working copy)
@@ -6,6 +6,8 @@
  * @since 2.1.0
  */
 
+declare( strict_types = 1 );
+
 // @codeCoverageIgnoreStart
 if ( ! defined( 'ABSPATH' ) ) {
 	exit; // Exit if accessed directly.
Index: includes/site-health/webp-support/hooks.php
===================================================================
--- includes/site-health/webp-support/hooks.php	(revision 3592034)
+++ includes/site-health/webp-support/hooks.php	(working copy)
@@ -6,6 +6,8 @@
  * @since 2.1.0
  */
 
+declare( strict_types = 1 );
+
 // @codeCoverageIgnoreStart
 if ( ! defined( 'ABSPATH' ) ) {
 	exit; // Exit if accessed directly.
Index: load.php
===================================================================
--- load.php	(revision 3592034)
+++ load.php	(working copy)
@@ -3,9 +3,9 @@
  * Plugin Name: Performance Lab
  * Plugin URI: https://github.com/WordPress/performance
  * Description: Performance plugin from the WordPress Performance Team, which is a collection of standalone performance features.
- * Requires at least: 6.6
- * Requires PHP: 7.2
- * Version: 4.1.0
+ * Requires at least: 6.9
+ * Requires PHP: 7.4
+ * Version: 4.2.0
  * Author: WordPress Performance Team
  * Author URI: https://make.wordpress.org/performance/
  * License: GPLv2 or later
@@ -15,6 +15,8 @@
  * @package performance-lab
  */
 
+declare( strict_types = 1 );
+
 // @codeCoverageIgnoreStart
 if ( ! defined( 'ABSPATH' ) ) {
 	exit; // Exit if accessed directly.
@@ -21,7 +23,7 @@
 }
 // @codeCoverageIgnoreEnd
 
-define( 'PERFLAB_VERSION', '4.1.0' );
+define( 'PERFLAB_VERSION', '4.2.0' );
 define( 'PERFLAB_MAIN_FILE', __FILE__ );
 define( 'PERFLAB_PLUGIN_DIR_PATH', plugin_dir_path( PERFLAB_MAIN_FILE ) );
 define( 'PERFLAB_SCREEN', 'performance-lab' );
@@ -32,6 +34,8 @@
 }
 define( 'PERFLAB_OBJECT_CACHE_DROPIN_LATEST_VERSION', 3 );
 
+require_once __DIR__ . '/hooks.php';
+
 // Load server-timing API.
 require_once PERFLAB_PLUGIN_DIR_PATH . 'includes/server-timing/class-perflab-server-timing-metric.php';
 require_once PERFLAB_PLUGIN_DIR_PATH . 'includes/server-timing/class-perflab-server-timing.php';
@@ -41,312 +45,6 @@
 // Load site health checks.
 require_once PERFLAB_PLUGIN_DIR_PATH . 'includes/site-health/load.php';
 
-/**
- * Gets the content attribute for the generator tag for the Performance Lab plugin.
- *
- * This attribute is then used in {@see perflab_render_generator()}.
- *
- * @since 1.1.0
- * @since 2.9.0 The generator tag now includes the active standalone plugin slugs.
- * @since 3.0.0 The generator tag no longer includes module slugs.
- */
-function perflab_get_generator_content(): string {
-	$active_plugins = array();
-	foreach ( perflab_get_standalone_plugin_version_constants() as $plugin_slug => $constant_name ) {
-		if ( defined( $constant_name ) && ! str_starts_with( constant( $constant_name ), 'Performance Lab ' ) ) {
-			$active_plugins[] = $plugin_slug;
-		}
-	}
-
-	return sprintf(
-		// Use the plugin slug as it is immutable.
-		'performance-lab %1$s; plugins: %2$s',
-		PERFLAB_VERSION,
-		implode( ', ', $active_plugins )
-	);
-}
-
-/**
- * Displays the HTML generator tag for the Performance Lab plugin.
- *
- * See {@see 'wp_head'}.
- *
- * @since 1.1.0
- */
-function perflab_render_generator(): void {
-	$content = perflab_get_generator_content();
-
-	echo '<meta name="generator" content="' . esc_attr( $content ) . '">' . "\n";
-}
-add_action( 'wp_head', 'perflab_render_generator' );
-
-/**
- * Gets the standalone plugins and their data.
- *
- * @since 3.0.0
- *
- * @return array<string, array{'constant': string, 'experimental'?: bool}> Associative array of $plugin_slug => $plugin_data pairs.
- */
-function perflab_get_standalone_plugin_data(): array {
-	/*
-	 * Alphabetically sorted list of plugin slugs and their data.
-	 * Supported keys per plugin are:
-	 * - 'constant' (string, required)
-	 * - 'experimental' (boolean, optional)
-	 */
-	return array(
-		'auto-sizes'              => array(
-			'constant'     => 'IMAGE_AUTO_SIZES_VERSION',
-			'experimental' => false,
-		),
-		'dominant-color-images'   => array(
-			'constant' => 'DOMINANT_COLOR_IMAGES_VERSION',
-		),
-		'embed-optimizer'         => array(
-			'constant'     => 'EMBED_OPTIMIZER_VERSION',
-			'experimental' => false,
-		),
-		'image-prioritizer'       => array(
-			'constant'     => 'IMAGE_PRIORITIZER_VERSION',
-			'experimental' => false,
-		),
-		'performant-translations' => array(
-			'constant' => 'PERFORMANT_TRANSLATIONS_VERSION',
-		),
-		'nocache-bfcache'         => array(
-			'constant' => 'WestonRuter\NocacheBFCache\VERSION',
-		),
-		'speculation-rules'       => array(
-			'constant' => 'SPECULATION_RULES_VERSION',
-		),
-		'view-transitions'        => array(
-			'constant'     => 'VIEW_TRANSITIONS_VERSION',
-			'experimental' => true,
-		),
-		'webp-uploads'            => array(
-			'constant' => 'WEBP_UPLOADS_VERSION',
-		),
-	);
-}
-
-/**
- * Gets the standalone plugin constants used for each available standalone plugin.
- *
- * @since 2.9.0
- * @since 3.0.0 The $source parameter was removed.
- *
- * @return array<string, string> Map of plugin slug and the version constant used.
- */
-function perflab_get_standalone_plugin_version_constants(): array {
-	return wp_list_pluck( perflab_get_standalone_plugin_data(), 'constant' );
-}
-
-/**
- * Places the Performance Lab's object cache drop-in in the drop-ins folder.
- *
- * This only runs in WP Admin to not have any potential performance impact on
- * the frontend.
- *
- * This function will short-circuit if at least one of the constants
- * 'PERFLAB_DISABLE_SERVER_TIMING' or
- * 'PERFLAB_DISABLE_OBJECT_CACHE_DROPIN' is set as true or if the
- * 'PERFLAB_PLACE_OBJECT_CACHE_DROPIN' constant is not set to a truthy value.
- *
- * @since 1.8.0
- * @since 2.1.0 No longer attempts to use two of the drop-ins together.
- * @since 4.0.0 No longer places the drop-in on new sites by default, unless the `PERFLAB_PLACE_OBJECT_CACHE_DROPIN` constant is set to true.
- *
- * @global WP_Filesystem_Base $wp_filesystem WordPress filesystem subclass.
- */
-function perflab_maybe_set_object_cache_dropin(): void {
-	global $wp_filesystem;
-
-	// Bail if Server-Timing is disabled entirely.
-	if ( defined( 'PERFLAB_DISABLE_SERVER_TIMING' ) && PERFLAB_DISABLE_SERVER_TIMING ) {
-		return;
-	}
-
-	// Bail if the drop-in is not enabled.
-	if ( ! defined( 'PERFLAB_PLACE_OBJECT_CACHE_DROPIN' ) || ! PERFLAB_PLACE_OBJECT_CACHE_DROPIN ) {
-		return;
-	}
-
-	// Bail if disabled via constant.
-	// This constant is maintained only for backward compatibility and should not be relied upon in new implementations.
-	// Use the 'PERFLAB_PLACE_OBJECT_CACHE_DROPIN' constant instead to control drop-in placement.
-	if ( defined( 'PERFLAB_DISABLE_OBJECT_CACHE_DROPIN' ) && PERFLAB_DISABLE_OBJECT_CACHE_DROPIN ) {
-		return;
-	}
-
-	/**
-	 * Filters whether the Perflab server timing drop-in should be set.
-	 *
-	 * @since 2.0.0
-	 *
-	 * @param bool $disabled Whether to disable the server timing drop-in. Default false.
-	 */
-	if ( apply_filters( 'perflab_disable_object_cache_dropin', false ) ) {
-		return;
-	}
-
-	/**
-	 * Filters the value of the `object-cache.php` drop-in constant.
-	 *
-	 * This filter should not be used outside of tests.
-	 *
-	 * @since 3.0.0
-	 * @internal
-	 *
-	 * @param int|bool $current_dropin_version The drop-in version as defined by the
-	 *                                         `PERFLAB_OBJECT_CACHE_DROPIN_VERSION` constant.
-	 */
-	$current_dropin_version = apply_filters( 'perflab_object_cache_dropin_version', PERFLAB_OBJECT_CACHE_DROPIN_VERSION );
-
-	// Bail if already placed in the latest version or newer.
-	if ( null !== $current_dropin_version && $current_dropin_version >= PERFLAB_OBJECT_CACHE_DROPIN_LATEST_VERSION ) {
-		return;
-	}
-
-	// Bail if already attempted before timeout has been completed.
-	// This is present in case placing the file fails for some reason, to avoid
-	// excessively retrying to place it on every request.
-	$timeout = get_transient( 'perflab_set_object_cache_dropin' );
-	if ( false !== $timeout ) {
-		return;
-	}
-
-	if ( $wp_filesystem instanceof WP_Filesystem_Base || true === WP_Filesystem() ) {
-		$dropin_path = WP_CONTENT_DIR . '/object-cache.php';
-
-		/*
-		 * If there is an actual object-cache.php file, it is most likely from
-		 * a third party, or it may be an older version of the Performance Lab
-		 * object-cache.php. If it's from a third party, do not replace it.
-		 *
-		 * Previous versions of the Performance Lab plugin were renaming the
-		 * original object-cache.php file and then loading both. However, due
-		 * to other plugins eagerly checking file headers, this caused too many
-		 * problems across sites, so it was decided to remove this layer.
-		 * Only placing the drop-in file if no other one exists yet is the
-		 * safest solution.
-		 */
-		if ( $wp_filesystem->exists( $dropin_path ) ) {
-			// If this constant evaluates to `false`, the existing file is for sure from a third party.
-			if ( false === $current_dropin_version ) {
-				// Set timeout of 1 day before retrying again (only in case the file already exists).
-				set_transient( 'perflab_set_object_cache_dropin', true, DAY_IN_SECONDS );
-				return;
-			}
-
-			// Otherwise, verify that it's actually the Performance Lab drop-in.
-			$test_content = "<?php\n/**\n * Plugin Name: Performance Lab Server Timing Object Cache Drop-In\n";
-			if ( ! str_starts_with( $wp_filesystem->get_contents( $dropin_path ), $test_content ) ) {
-				// Set timeout of 1 day before retrying again (only in case the file already exists).
-				set_transient( 'perflab_set_object_cache_dropin', true, DAY_IN_SECONDS );
-				return;
-			}
-
-			/*
-			 * If this logic is reached, the existing file is an older version
-			 * of the Performance Lab drop-in, so it can be safely deleted, and
-			 * then be replaced below.
-			 */
-			$wp_filesystem->delete( $dropin_path );
-		}
-
-		$wp_filesystem->copy( PERFLAB_PLUGIN_DIR_PATH . 'includes/server-timing/object-cache.copy.php', $dropin_path );
-	}
-
-	// Set timeout of 1 hour before retrying again (only relevant in case the above failed).
-	set_transient( 'perflab_set_object_cache_dropin', true, HOUR_IN_SECONDS );
-}
-add_action( 'admin_init', 'perflab_maybe_set_object_cache_dropin' );
-
-/**
- * Removes the Performance Lab's object cache drop-in from the drop-ins folder.
- *
- * This function should be run on plugin deactivation. For backward compatibility with
- * an earlier implementation of `perflab_maybe_set_object_cache_dropin()`, this function
- * checks whether there is an object-cache-plst-orig.php file, and if so restores it.
- *
- * This function will short-circuit if the constant
- * 'PERFLAB_DISABLE_OBJECT_CACHE_DROPIN' is set as true.
- *
- * @since 1.8.0
- *
- * @global WP_Filesystem_Base $wp_filesystem WordPress filesystem subclass.
- */
-function perflab_maybe_remove_object_cache_dropin(): void {
-	global $wp_filesystem;
-
-	// Bail if disabled via constant.
-	if ( defined( 'PERFLAB_DISABLE_OBJECT_CACHE_DROPIN' ) && PERFLAB_DISABLE_OBJECT_CACHE_DROPIN ) {
-		return;
-	}
-
-	// Bail if custom drop-in not present anyway.
-	if ( ! PERFLAB_OBJECT_CACHE_DROPIN_VERSION ) {
-		return;
-	}
-
-	if ( $wp_filesystem instanceof WP_Filesystem_Base || true === WP_Filesystem() ) {
-		$dropin_path        = WP_CONTENT_DIR . '/object-cache.php';
-		$dropin_backup_path = WP_CONTENT_DIR . '/object-cache-plst-orig.php';
-
-		/**
-		 * If there is an object-cache-plst-orig.php file, restore it and
-		 * override the Performance Lab file. This is only relevant for
-		 * backward-compatibility with previous Performance Lab versions
-		 * which were backing up the file and then loading both.
-		 * Otherwise, just delete the Performance Lab file.
-		 */
-		if ( $wp_filesystem->exists( $dropin_backup_path ) ) {
-			$wp_filesystem->move( $dropin_backup_path, $dropin_path, true );
-		} else {
-			$wp_filesystem->delete( $dropin_path );
-		}
-	}
-
-	// Delete transient for drop-in check in case the plugin is reactivated shortly after.
-	delete_transient( 'perflab_set_object_cache_dropin' );
-}
-register_deactivation_hook( __FILE__, 'perflab_maybe_remove_object_cache_dropin' );
-
-/**
- * Redirects legacy module page to the performance feature page.
- *
- * @since 3.0.0
- *
- * @global $plugin_page
- */
-function perflab_no_access_redirect_module_to_performance_feature_page(): void {
-	global $plugin_page;
-
-	if ( 'perflab-modules' !== $plugin_page ) {
-		return;
-	}
-
-	if (
-		current_user_can( 'manage_options' ) &&
-		wp_safe_redirect( add_query_arg( 'page', PERFLAB_SCREEN ) )
-	) {
-		exit;
-	}
-}
-add_action( 'admin_page_access_denied', 'perflab_no_access_redirect_module_to_performance_feature_page' );
-
-/**
- * Cleanup function to delete legacy 'perflab_modules_settings' option if present.
- *
- * @since 3.0.0
- */
-function perflab_cleanup_option(): void {
-	if ( current_user_can( 'manage_options' ) ) {
-		delete_option( 'perflab_modules_settings' );
-	}
-}
-add_action( 'admin_init', 'perflab_cleanup_option' );
-
 // Only load admin integration when in admin.
 if ( is_admin() ) {
 	require_once PERFLAB_PLUGIN_DIR_PATH . 'includes/admin/load.php';
Index: readme.txt
===================================================================
--- readme.txt	(revision 3592034)
+++ readme.txt	(working copy)
@@ -2,7 +2,7 @@
 
 Contributors: wordpressdotorg
 Tested up to: 7.0
-Stable tag:   4.1.0
+Stable tag:   4.2.0
 License:      GPLv2 or later
 License URI:  https://www.gnu.org/licenses/gpl-2.0.html
 Tags:         performance, site health, measurement, optimization, diagnostics
@@ -73,6 +73,15 @@
 
 == Changelog ==
 
+= 4.2.0 =
+
+**Enhancements**
+
+* Add `declare( strict_types = 1 )` to all PHP files. ([2424](https://github.com/WordPress/performance/pull/2424))
+* Bump minimum PHP requirement from 7.2 to 7.4. ([2469](https://github.com/WordPress/performance/pull/2469))
+* Enqueued Assets audit: treat zero-size and no-store responses as errors. ([2481](https://github.com/WordPress/performance/pull/2481))
+* Server Timing - Allow description and make duration optional. ([2379](https://github.com/WordPress/performance/pull/2379))
+
 = 4.1.0 =
 
 **Bug Fixes**

speculation-rules

Warning

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

svn status:

M       class-plsr-url-pattern-prefixer.php
M       hooks.php
M       load.php
M       plugin-api.php
M       settings.php
M       uninstall.php
M       wp-core-api.php
svn diff
Index: class-plsr-url-pattern-prefixer.php
===================================================================
--- class-plsr-url-pattern-prefixer.php	(revision 3592034)
+++ class-plsr-url-pattern-prefixer.php	(working copy)
@@ -6,6 +6,8 @@
  * @since 1.0.0
  */
 
+declare( strict_types = 1 );
+
 // @codeCoverageIgnoreStart
 if ( ! defined( 'ABSPATH' ) ) {
 	exit; // Exit if accessed directly.
@@ -25,7 +27,7 @@
 	 * @since 1.0.0
 	 * @var array<string, string>
 	 */
-	private $contexts;
+	private array $contexts;
 
 	/**
 	 * Constructor.
@@ -38,9 +40,7 @@
 	public function __construct( array $contexts = array() ) {
 		if ( count( $contexts ) > 0 ) {
 			$this->contexts = array_map(
-				static function ( string $str ): string {
-					return self::escape_pattern_string( trailingslashit( $str ) );
-				},
+				static fn ( string $str ): string => self::escape_pattern_string( trailingslashit( $str ) ),
 				$contexts
 			);
 		} else {
Index: hooks.php
===================================================================
--- hooks.php	(revision 3592034)
+++ hooks.php	(working copy)
@@ -6,6 +6,8 @@
  * @since 1.0.0
  */
 
+declare( strict_types = 1 );
+
 // @codeCoverageIgnoreStart
 if ( ! defined( 'ABSPATH' ) ) {
 	exit; // Exit if accessed directly.
Index: load.php
===================================================================
--- load.php	(revision 3592034)
+++ load.php	(working copy)
@@ -3,8 +3,8 @@
  * Plugin Name: Speculative Loading
  * Plugin URI: https://github.com/WordPress/performance/tree/trunk/plugins/speculation-rules
  * Description: Enables browsers to speculatively prerender or prefetch pages to achieve near-instant loads based on user interaction.
- * Requires at least: 6.6
- * Requires PHP: 7.2
+ * Requires at least: 6.9
+ * Requires PHP: 7.4
  * Version: 1.6.0
  * Author: WordPress Performance Team
  * Author URI: https://make.wordpress.org/performance/
@@ -15,6 +15,8 @@
  * @package speculation-rules
  */
 
+declare( strict_types = 1 );
+
 // @codeCoverageIgnoreStart
 if ( ! defined( 'ABSPATH' ) ) {
 	exit; // Exit if accessed directly.
@@ -59,8 +61,8 @@
 			// Otherwise, register this copy if it is actually the one installed in the directory for plugins.
 			rtrim( WP_PLUGIN_DIR, '/' ) === dirname( __DIR__ )
 		) {
-			$GLOBALS[ $global_var_name ]['version'] = $version;
-			$GLOBALS[ $global_var_name ]['load']    = $load;
+			$GLOBALS[ $global_var_name ]['version'] = $version; // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedVariableFound -- It is prefixed.
+			$GLOBALS[ $global_var_name ]['load']    = $load; // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedVariableFound -- It is prefixed.
 		}
 	}
 )(
Index: plugin-api.php
===================================================================
--- plugin-api.php	(revision 3592034)
+++ plugin-api.php	(working copy)
@@ -6,6 +6,8 @@
  * @since 1.0.0
  */
 
+declare( strict_types = 1 );
+
 // @codeCoverageIgnoreStart
 if ( ! defined( 'ABSPATH' ) ) {
 	exit; // Exit if accessed directly.
@@ -135,7 +137,7 @@
 	}
 
 	wp_print_inline_script_tag(
-		(string) wp_json_encode( plsr_get_speculation_rules() ),
+		(string) wp_json_encode( plsr_get_speculation_rules(), JSON_HEX_TAG | JSON_UNESCAPED_SLASHES ),
 		array( 'type' => 'speculationrules' )
 	);
 }
Index: settings.php
===================================================================
--- settings.php	(revision 3592034)
+++ settings.php	(working copy)
@@ -6,6 +6,8 @@
  * @since 1.0.0
  */
 
+declare( strict_types = 1 );
+
 // @codeCoverageIgnoreStart
 if ( ! defined( 'ABSPATH' ) ) {
 	exit; // Exit if accessed directly.
@@ -152,7 +154,12 @@
 		$value['authentication'] = $default_value['authentication'];
 	}
 
-	return $value;
+	// Return an explicit array literal so the sealed return shape is preserved (array_intersect_key() above yields a loose remainder).
+	return array(
+		'mode'           => $value['mode'],
+		'eagerness'      => $value['eagerness'],
+		'authentication' => $value['authentication'],
+	);
 }
 
 /**
@@ -243,7 +250,7 @@
 			'plsr_render_settings_field',
 			'reading',
 			'plsr_speculation_rules',
-			array_merge(
+			array_merge( // @phpstan-ignore argument.type (WordPress documents add_settings_field()'s $args as arbitrary extra arguments forwarded to the field callback, but php-stubs/wordpress-stubs types it as a sealed array{label_for?, class?}. TODO: Fix upstream in php-stubs/wordpress-stubs and remove.)
 				array( 'field' => $slug ),
 				$args
 			)
@@ -324,7 +331,7 @@
 			</div>
 			<?php
 			// phpcs:ignore Squiz.PHP.Heredoc.NotAllowed -- Part of the PCP ruleset. Appealed in <https://github.com/WordPress/plugin-check/issues/792#issuecomment-3214985527>.
-			$js = <<<'JS'
+			$js  = <<<'JS'
 				const authOptions = document.getElementById( 'plsr-authentication-setting' );
 				const noticeDiv = document.getElementById( 'plsr-auth-notice' );
 				if ( authOptions && noticeDiv ) {
@@ -338,8 +345,8 @@
 						noticeDiv.classList.toggle( 'notice-warning', ! isLoggedOut );
 					} );
 				}
-JS;
-			// 👆 This 'JS;' line can only be indented two tabs when minimum PHP version is increased to 7.3+.
+			JS;
+			$js .= "\n//# sourceURL=speculation-rules-auth-admin-notice";
 			wp_print_inline_script_tag( $js, array( 'type' => 'module' ) );
 			?>
 		<?php endif; ?>
Index: uninstall.php
===================================================================
--- uninstall.php	(revision 3592034)
+++ uninstall.php	(working copy)
@@ -6,6 +6,8 @@
  * @since 1.2.0
  */
 
+declare( strict_types = 1 );
+
 // If uninstall.php is not called by WordPress, bail.
 if ( ! defined( 'WP_UNINSTALL_PLUGIN' ) ) {
 	exit;// @codeCoverageIgnore
@@ -13,7 +15,7 @@
 
 // For a multisite, delete the option for all sites (however limited to 100 sites to avoid memory limit or timeout problems in large scale networks).
 if ( is_multisite() ) {
-	$site_ids = get_sites(
+	$plsr_site_ids = get_sites(
 		array(
 			'fields'                 => 'ids',
 			'number'                 => 100,
@@ -22,8 +24,8 @@
 		)
 	);
 
-	foreach ( $site_ids as $site_id ) {
-		switch_to_blog( $site_id );
+	foreach ( $plsr_site_ids as $plsr_site_id ) {
+		switch_to_blog( $plsr_site_id ); // @phpstan-ignore argument.type (get_sites( 'fields' => 'ids' ) returns int[], but php-stubs/wordpress-stubs uses a sealed array shape in its conditional return type so the narrowing is lost when extra args are passed. TODO: Fix upstream in php-stubs/wordpress-stubs and remove.)
 		plsr_delete_plugin_option();
 		restore_current_blog();
 	}
Index: wp-core-api.php
===================================================================
--- wp-core-api.php	(revision 3592034)
+++ wp-core-api.php	(working copy)
@@ -6,6 +6,8 @@
  * @since 1.5.0
  */
 
+declare( strict_types = 1 );
+
 // @codeCoverageIgnoreStart
 if ( ! defined( 'ABSPATH' ) ) {
 	exit; // Exit if accessed directly.

view-transitions

Important

Stable tag change: 1.2.0 → 1.2.1

svn status:

M       hooks.php
M       includes/admin.php
M       includes/class-plvt-view-transition-animation-registry.php
M       includes/class-plvt-view-transition-animation.php
M       includes/functions.php
M       includes/settings.php
M       includes/theme.php
M       js/types.ts
M       js/view-transitions.js
M       readme.txt
M       uninstall.php
M       view-transitions.php
svn diff
Index: hooks.php
===================================================================
--- hooks.php	(revision 3592034)
+++ hooks.php	(working copy)
@@ -6,6 +6,8 @@
  * @since 1.0.0
  */
 
+declare( strict_types = 1 );
+
 // @codeCoverageIgnoreStart
 if ( ! defined( 'ABSPATH' ) ) {
 	exit; // Exit if accessed directly.
Index: includes/admin.php
===================================================================
--- includes/admin.php	(revision 3592034)
+++ includes/admin.php	(working copy)
@@ -6,6 +6,8 @@
  * @since 1.1.0
  */
 
+declare( strict_types = 1 );
+
 // @codeCoverageIgnoreStart
 if ( ! defined( 'ABSPATH' ) ) {
 	exit; // Exit if accessed directly.
Index: includes/class-plvt-view-transition-animation-registry.php
===================================================================
--- includes/class-plvt-view-transition-animation-registry.php	(revision 3592034)
+++ includes/class-plvt-view-transition-animation-registry.php	(working copy)
@@ -6,6 +6,8 @@
  * @since 1.0.0
  */
 
+declare( strict_types = 1 );
+
 // @codeCoverageIgnoreStart
 if ( ! defined( 'ABSPATH' ) ) {
 	exit; // Exit if accessed directly.
@@ -27,7 +29,7 @@
 	 * @since 1.0.0
 	 * @var array<non-empty-string, PLVT_View_Transition_Animation>
 	 */
-	private $registered_animations = array();
+	private array $registered_animations = array();
 
 	/**
 	 * Map of aliases to their animation slugs.
@@ -37,7 +39,7 @@
 	 * @since 1.0.0
 	 * @var array<non-empty-string, non-empty-string>
 	 */
-	private $alias_map = array();
+	private array $alias_map = array();
 
 	/**
 	 * Registers a view transition animation.
Index: includes/class-plvt-view-transition-animation.php
===================================================================
--- includes/class-plvt-view-transition-animation.php	(revision 3592034)
+++ includes/class-plvt-view-transition-animation.php	(working copy)
@@ -6,6 +6,8 @@
  * @since 1.0.0
  */
 
+declare( strict_types = 1 );
+
 // @codeCoverageIgnoreStart
 if ( ! defined( 'ABSPATH' ) ) {
 	exit; // Exit if accessed directly.
@@ -34,7 +36,7 @@
 	 * @since 1.0.0
 	 * @var non-empty-string
 	 */
-	private $slug;
+	private string $slug;
 
 	/**
 	 * Unique aliases for the animation, if any.
@@ -42,7 +44,7 @@
 	 * @since 1.0.0
 	 * @var non-empty-string[]
 	 */
-	private $aliases = array();
+	private array $aliases = array();
 
 	/**
 	 * Whether the animation uses a stylesheet, optionally a concrete file path.
@@ -53,7 +55,7 @@
 	 * @since 1.0.0
 	 * @var bool|string
 	 */
-	private $use_stylesheet = false;
+	private $use_stylesheet = false; // phpcs:ignore SlevomatCodingStandard.TypeHints.PropertyTypeHint.MissingNativeTypeHint -- TODO: Use native bool|string union type when PHP 8.0 is the minimum.
 
 	/**
 	 * Whether to apply the global view transition names while using this animation.
@@ -61,7 +63,7 @@
 	 * @since 1.0.0
 	 * @var bool|callable
 	 */
-	private $use_global_transition_names = true;
+	private $use_global_transition_names = true; // phpcs:ignore SlevomatCodingStandard.TypeHints.PropertyTypeHint.MissingNativeTypeHint -- callable is not allowed as a native property type, and a bool|Closure union requires PHP 8.0.
 
 	/**
 	 * Whether to apply the post-specific view transition names while using this animation.
@@ -69,7 +71,7 @@
 	 * @since 1.0.0
 	 * @var bool|callable
 	 */
-	private $use_post_transition_names = true;
+	private $use_post_transition_names = true; // phpcs:ignore SlevomatCodingStandard.TypeHints.PropertyTypeHint.MissingNativeTypeHint -- callable is not allowed as a native property type, and a bool|Closure union requires PHP 8.0.
 
 	/**
 	 * Callback to get the stylesheet for the animation, as inline CSS.
@@ -83,7 +85,7 @@
 	 * @since 1.0.0
 	 * @var callable|null
 	 */
-	private $get_stylesheet_callback = null;
+	private $get_stylesheet_callback = null; // phpcs:ignore SlevomatCodingStandard.TypeHints.PropertyTypeHint.MissingNativeTypeHint -- callable is not allowed as a native property type.
 
 	/**
 	 * Default animation arguments.
@@ -94,7 +96,7 @@
 	 * @since 1.0.0
 	 * @var array<string, mixed>
 	 */
-	private $default_args = array();
+	private array $default_args = array();
 
 	/**
 	 * Constructor.
Index: includes/functions.php
===================================================================
--- includes/functions.php	(revision 3592034)
+++ includes/functions.php	(working copy)
@@ -6,6 +6,8 @@
  * @since 1.0.0
  */
 
+declare( strict_types = 1 );
+
 // @codeCoverageIgnoreStart
 if ( ! defined( 'ABSPATH' ) ) {
 	exit; // Exit if accessed directly.
Index: includes/settings.php
===================================================================
--- includes/settings.php	(revision 3592034)
+++ includes/settings.php	(working copy)
@@ -6,6 +6,8 @@
  * @since 1.0.0
  */
 
+declare( strict_types = 1 );
+
 // @codeCoverageIgnoreStart
 if ( ! defined( 'ABSPATH' ) ) {
 	exit; // Exit if accessed directly.
@@ -389,7 +391,7 @@
 			'plvt_render_settings_field',
 			'reading',
 			$section,
-			array_merge(
+			array_merge( // @phpstan-ignore argument.type (WordPress documents add_settings_field()'s $args as arbitrary extra arguments forwarded to the field callback, but php-stubs/wordpress-stubs types it as a sealed array{label_for?, class?}. TODO: Fix upstream in php-stubs/wordpress-stubs and remove.)
 				$additional_args,
 				$args
 			)
Index: includes/theme.php
===================================================================
--- includes/theme.php	(revision 3592034)
+++ includes/theme.php	(working copy)
@@ -6,6 +6,8 @@
  * @since 1.0.0
  */
 
+declare( strict_types = 1 );
+
 // @codeCoverageIgnoreStart
 if ( ! defined( 'ABSPATH' ) ) {
 	exit; // Exit if accessed directly.
@@ -123,9 +125,7 @@
 	 * provided, they should be applied. But if the entire page is the target, they would visually mess with the
 	 * animation.
 	 */
-	$is_specific_target_name = static function ( string $alias, array $args ): bool {
-		return '*' === $args['target-name'] ? false : true;
-	};
+	$is_specific_target_name = static fn ( string $alias, array $args ): bool => ! ( '*' === $args['target-name'] );
 
 	/*
 	 * This callback is used to return horizontal and vertical offsets (-1, 0, or 1) based on whether the given alias
@@ -352,7 +352,7 @@
 
 	$init_script = sprintf(
 		'plvtInitViewTransitions( %s )',
-		wp_json_encode( $config, JSON_FORCE_OBJECT | JSON_HEX_TAG | JSON_UNESCAPED_SLASHES )
+		(string) wp_json_encode( $config, JSON_FORCE_OBJECT | JSON_HEX_TAG | JSON_UNESCAPED_SLASHES )
 	);
 
 	/*
Index: js/types.ts
===================================================================
--- js/types.ts	(revision 3592034)
+++ js/types.ts	(working copy)
@@ -17,9 +17,6 @@
 declare global {
 	interface Window {
 		plvtInitViewTransitions?: InitViewTransitionsFunction;
-		navigation?: {
-			activation: NavigationActivation;
-		};
 	}
 }
 
Index: js/view-transitions.js
===================================================================
--- js/view-transitions.js	(revision 3592034)
+++ js/view-transitions.js	(working copy)
@@ -25,7 +25,7 @@
 	 * @param {string}       transitionType View transition type. Only 'default' is supported so far, but more to be added.
 	 * @param {Element}      bodyElement    The body element.
 	 * @param {Element|null} articleElement The post element relevant for the view transition, if any.
-	 * @return {Array[]} View transition entries with each one containing the element and its view transition name.
+	 * @return {Array<Array<any>>} View transition entries with each one containing the element and its view transition name.
 	 */
 	const getViewTransitionEntries = (
 		transitionType,
@@ -60,10 +60,27 @@
 	};
 
 	/**
+	 * Suppresses unhandled promise rejections on a ViewTransition.
+	 *
+	 * When a transition is already aborted (e.g. bfcache restoration), its ready
+	 * and finished promises reject. Without a rejection handler, these surface as
+	 * "Uncaught (in promise) InvalidStateError" errors.
+	 *
+	 * @since 1.2.1
+	 *
+	 * @param {ViewTransition} viewTransition The view transition to suppress rejections for.
+	 */
+	const suppressViewTransitionRejections = ( viewTransition ) => {
+		const noop = () => {};
+		viewTransition.ready.catch( noop );
+		viewTransition.finished.catch( noop );
+	};
+
+	/**
 	 * Temporarily sets view transition names for the given entries until the view transition has been completed.
 	 *
-	 * @param {Array[]}       entries   View transition entries as received from `getViewTransitionEntries()`.
-	 * @param {Promise<void>} vtPromise Promise that resolves after the view transition has been completed.
+	 * @param {Array<Array<any>>} entries   View transition entries as received from `getViewTransitionEntries()`.
+	 * @param {Promise<void>}     vtPromise Promise that resolves after the view transition has been completed.
 	 * @return {Promise<void>} Promise that resolves after the view transition names were reset.
 	 */
 	const setTemporaryViewTransitionNames = async ( entries, vtPromise ) => {
@@ -144,6 +161,7 @@
 		( /** @type {PageSwapEvent} */ event ) => {
 			if ( event.viewTransition ) {
 				const transitionType = 'default'; // Only 'default' is supported so far, but more to be added.
+				suppressViewTransitionRejections( event.viewTransition );
 				event.viewTransition.types.add( transitionType );
 
 				let viewTransitionEntries;
@@ -158,11 +176,13 @@
 					document.body.classList.contains( 'blog' ) ||
 					document.body.classList.contains( 'archive' )
 				) {
-					viewTransitionEntries = getViewTransitionEntries(
-						transitionType,
-						document.body,
-						getArticleForUrl( event.activation.entry.url )
-					);
+					if ( event.activation?.entry.url ) {
+						viewTransitionEntries = getViewTransitionEntries(
+							transitionType,
+							document.body,
+							getArticleForUrl( event.activation.entry.url )
+						);
+					}
 				}
 				if ( viewTransitionEntries ) {
 					setTemporaryViewTransitionNames(
@@ -185,6 +205,7 @@
 		( /** @type {PageRevealEvent} */ event ) => {
 			if ( event.viewTransition ) {
 				const transitionType = 'default'; // Only 'default' is supported so far, but more to be added.
+				suppressViewTransitionRejections( event.viewTransition );
 				event.viewTransition.types.add( transitionType );
 
 				let viewTransitionEntries;
@@ -201,7 +222,7 @@
 					viewTransitionEntries = getViewTransitionEntries(
 						transitionType,
 						document.body,
-						window.navigation.activation.from
+						window.navigation?.activation?.from?.url
 							? getArticleForUrl(
 									window.navigation.activation.from.url
 							  )
Index: readme.txt
===================================================================
--- readme.txt	(revision 3592034)
+++ readme.txt	(working copy)
@@ -2,7 +2,7 @@
 
 Contributors: wordpressdotorg
 Tested up to: 7.0
-Stable tag:   1.2.0
+Stable tag:   1.2.1
 License:      GPLv2 or later
 License URI:  https://www.gnu.org/licenses/gpl-2.0.html
 Tags:         performance, view transitions, smooth transitions, animations
@@ -56,6 +56,12 @@
 
 == Changelog ==
 
+= 1.2.1 =
+
+**Bug Fixes**
+
+* Fix `InvalidStateError` on bfcache navigations. ([2438](https://github.com/WordPress/performance/pull/2438))
+
 = 1.2.0 =
 
 **Enhancements**
Index: uninstall.php
===================================================================
--- uninstall.php	(revision 3592034)
+++ uninstall.php	(working copy)
@@ -6,6 +6,8 @@
  * @since 1.0.0
  */
 
+declare( strict_types = 1 );
+
 // If uninstall.php is not called by WordPress, bail.
 if ( ! defined( 'WP_UNINSTALL_PLUGIN' ) ) {
 	exit;// @codeCoverageIgnore
@@ -23,7 +25,7 @@
 	);
 
 	foreach ( $plvt_site_ids as $plvt_site_id ) {
-		switch_to_blog( $plvt_site_id );
+		switch_to_blog( $plvt_site_id ); // @phpstan-ignore argument.type (get_sites( 'fields' => 'ids' ) returns int[], but php-stubs/wordpress-stubs uses a sealed array shape in its conditional return type so the narrowing is lost when extra args are passed. TODO: Fix upstream in php-stubs/wordpress-stubs and remove.)
 		plvt_delete_plugin_option();
 		restore_current_blog();
 	}
Index: view-transitions.php
===================================================================
--- view-transitions.php	(revision 3592034)
+++ view-transitions.php	(working copy)
@@ -3,9 +3,9 @@
  * Plugin Name: View Transitions
  * Plugin URI: https://github.com/WordPress/performance/tree/trunk/plugins/view-transitions
  * Description: Adds smooth transitions between navigations to your WordPress site.
- * Requires at least: 6.6
- * Requires PHP: 7.2
- * Version: 1.2.0
+ * Requires at least: 6.9
+ * Requires PHP: 7.4
+ * Version: 1.2.1
  * Author: WordPress Performance Team
  * Author URI: https://make.wordpress.org/performance/
  * License: GPLv2 or later
@@ -15,6 +15,8 @@
  * @package view-transitions
  */
 
+declare( strict_types = 1 );
+
 // @codeCoverageIgnoreStart
 if ( ! defined( 'ABSPATH' ) ) {
 	exit; // Exit if accessed directly.
@@ -25,7 +27,7 @@
 	return;
 }
 
-define( 'VIEW_TRANSITIONS_VERSION', '1.2.0' );
+define( 'VIEW_TRANSITIONS_VERSION', '1.2.1' );
 define( 'VIEW_TRANSITIONS_MAIN_FILE', __FILE__ );
 
 require_once __DIR__ . '/includes/admin.php';

web-worker-offloading

Warning

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

svn status:

M       helper.php
M       hooks.php
M       load.php
M       third-party/google-site-kit.php
M       third-party/seo-by-rank-math.php
M       third-party/woocommerce.php
M       third-party.php
svn diff
Index: helper.php
===================================================================
--- helper.php	(revision 3592034)
+++ helper.php	(working copy)
@@ -6,6 +6,8 @@
  * @package web-worker-offloading
  */
 
+declare( strict_types = 1 );
+
 // @codeCoverageIgnoreStart
 if ( ! defined( 'ABSPATH' ) ) {
 	exit; // Exit if accessed directly.
@@ -107,7 +109,7 @@
 		'web-worker-offloading',
 		sprintf(
 			'window.partytown = {...(window.partytown || {}), ...%s};',
-			wp_json_encode( plwwo_get_configuration(), JSON_HEX_TAG | JSON_UNESCAPED_SLASHES )
+			(string) wp_json_encode( plwwo_get_configuration(), JSON_HEX_TAG | JSON_UNESCAPED_SLASHES )
 		),
 		'before'
 	);
Index: hooks.php
===================================================================
--- hooks.php	(revision 3592034)
+++ hooks.php	(working copy)
@@ -6,6 +6,8 @@
  * @package web-worker-offloading
  */
 
+declare( strict_types = 1 );
+
 // @codeCoverageIgnoreStart
 if ( ! defined( 'ABSPATH' ) ) {
 	exit; // Exit if accessed directly.
Index: load.php
===================================================================
--- load.php	(revision 3592034)
+++ load.php	(working copy)
@@ -3,8 +3,8 @@
  * Plugin Name: Web Worker Offloading
  * Plugin URI: https://github.com/WordPress/performance/issues/176
  * Description: Offloads select JavaScript execution to a Web Worker to reduce work on the main thread and improve the Interaction to Next Paint (INP) metric.
- * Requires at least: 6.6
- * Requires PHP: 7.2
+ * Requires at least: 6.9
+ * Requires PHP: 7.4
  * Version: 0.2.1
  * Author: WordPress Performance Team
  * Author URI: https://make.wordpress.org/performance/
@@ -15,6 +15,8 @@
  * @package web-worker-offloading
  */
 
+declare( strict_types = 1 );
+
 // @codeCoverageIgnoreStart
 if ( ! defined( 'ABSPATH' ) ) {
 	exit; // Exit if accessed directly.
Index: third-party/google-site-kit.php
===================================================================
--- third-party/google-site-kit.php	(revision 3592034)
+++ third-party/google-site-kit.php	(working copy)
@@ -6,6 +6,8 @@
  * @package web-worker-offloading
  */
 
+declare( strict_types = 1 );
+
 // @codeCoverageIgnoreStart
 if ( ! defined( 'ABSPATH' ) ) {
 	exit; // Exit if accessed directly.
Index: third-party/seo-by-rank-math.php
===================================================================
--- third-party/seo-by-rank-math.php	(revision 3592034)
+++ third-party/seo-by-rank-math.php	(working copy)
@@ -6,6 +6,8 @@
  * @package web-worker-offloading
  */
 
+declare( strict_types = 1 );
+
 // @codeCoverageIgnoreStart
 if ( ! defined( 'ABSPATH' ) ) {
 	exit; // Exit if accessed directly.
Index: third-party/woocommerce.php
===================================================================
--- third-party/woocommerce.php	(revision 3592034)
+++ third-party/woocommerce.php	(working copy)
@@ -6,6 +6,8 @@
  * @package web-worker-offloading
  */
 
+declare( strict_types = 1 );
+
 // @codeCoverageIgnoreStart
 if ( ! defined( 'ABSPATH' ) ) {
 	exit; // Exit if accessed directly.
Index: third-party.php
===================================================================
--- third-party.php	(revision 3592034)
+++ third-party.php	(working copy)
@@ -6,6 +6,8 @@
  * @package web-worker-offloading
  */
 
+declare( strict_types = 1 );
+
 // @codeCoverageIgnoreStart
 if ( ! defined( 'ABSPATH' ) ) {
 	exit; // Exit if accessed directly.
@@ -41,12 +43,8 @@
  */
 function plwwo_load_third_party_integrations(): void {
 	$plugins_with_integrations = array(
-		'google-site-kit'  => static function (): bool {
-			return defined( 'GOOGLESITEKIT_VERSION' );
-		},
-		'seo-by-rank-math' => static function (): bool {
-			return class_exists( 'RankMath' );
-		},
+		'google-site-kit'  => static fn (): bool => defined( 'GOOGLESITEKIT_VERSION' ),
+		'seo-by-rank-math' => static fn (): bool => class_exists( 'RankMath' ),
 		'woocommerce'      => static function (): bool {
 			// See <https://woocommerce.com/document/query-whether-woocommerce-is-activated/>.
 			return class_exists( 'WooCommerce' );

webp-uploads

Important

Stable tag change: 2.6.1 → 2.7.0

svn status:

M       deprecated.php
M       helper.php
M       hooks.php
M       image-edit.php
M       load.php
M       picture-element.php
M       readme.txt
M       rest-api.php
M       settings.php
M       uninstall.php
svn diff
Index: deprecated.php
===================================================================
--- deprecated.php	(revision 3592034)
+++ deprecated.php	(working copy)
@@ -7,6 +7,8 @@
  * @since 1.1.1
  */
 
+declare( strict_types = 1 );
+
 // @codeCoverageIgnoreStart
 if ( ! defined( 'ABSPATH' ) ) {
 	exit; // Exit if accessed directly.
@@ -69,3 +71,25 @@
 	</style>
 	<?php
 }
+
+/**
+ * Updates the references of the featured image to the new image format if available.
+ *
+ * @since 1.0.0
+ * @deprecated 2.7.0 Featured images are now rewritten through the `wp_get_attachment_image`
+ *                     filter; see webp_uploads_filter_wp_get_attachment_image().
+ *
+ * @param string $html          The current HTML markup of the featured image.
+ * @param int    $post_id       The current post ID where the featured image is requested.
+ * @param int    $attachment_id The ID of the attachment image.
+ * @return string The updated HTML markup.
+ */
+function webp_uploads_update_featured_image( string $html, int $post_id, int $attachment_id ): string {
+	_deprecated_function( __FUNCTION__, 'Modern Image Formats 2.7.0', 'webp_uploads_filter_wp_get_attachment_image()' );
+
+	if ( webp_uploads_is_picture_element_enabled() ) {
+		return webp_uploads_wrap_image_in_picture( $html, 'post_thumbnail_html', $attachment_id );
+	}
+
+	return webp_uploads_img_tag_update_mime_type( $html, 'post_thumbnail_html', $attachment_id );
+}
Index: helper.php
===================================================================
--- helper.php	(revision 3592034)
+++ helper.php	(working copy)
@@ -7,6 +7,8 @@
  * @since 1.0.0
  */
 
+declare( strict_types = 1 );
+
 // @codeCoverageIgnoreStart
 if ( ! defined( 'ABSPATH' ) ) {
 	exit; // Exit if accessed directly.
@@ -103,9 +105,9 @@
 	 *            file: string,
 	 *            path?: string,
 	 *            filesize?: int
-	 *        }|null|WP_Error $image         Image data, null, or WP_Error.
-	 * @param int             $attachment_id The ID of the attachment from where this image would be created.
-	 * @param string          $image_size    The size name that would be used to create this image, out of the registered subsizes.
+	 *        }|null|WP_Error|mixed $image         Image data, null, or WP_Error.
+	 * @param int                   $attachment_id The ID of the attachment from where this image would be created.
+	 * @param string                $image_size    The size name that would be used to create this image, out of the registered subsizes.
 	 * @param array{
 	 *            width: int,
 	 *            height: int,
@@ -120,11 +122,14 @@
 	if ( is_array( $image ) && array_key_exists( 'file', $image ) && is_string( $image['file'] ) ) {
 		// The filtered image provided all we need to short-circuit here.
 		if ( array_key_exists( 'filesize', $image ) && is_int( $image['filesize'] ) && $image['filesize'] > 0 ) {
-			return $image;
+			return array(
+				'file'     => $image['file'],
+				'filesize' => $image['filesize'],
+			);
 		}
 
 		// Supply the filesize based on the filter-provided path.
-		if ( array_key_exists( 'path', $image ) && is_int( $image['path'] ) ) {
+		if ( array_key_exists( 'path', $image ) && is_string( $image['path'] ) ) {
 			$filesize = wp_filesize( $image['path'] );
 			if ( $filesize > 0 ) {
 				return array(
@@ -154,9 +159,9 @@
 		return $editor;
 	}
 
-	$height = isset( $size_data['height'] ) ? (int) $size_data['height'] : 0;
-	$width  = isset( $size_data['width'] ) ? (int) $size_data['width'] : 0;
-	$crop   = isset( $size_data['crop'] ) ? $size_data['crop'] : false;
+	$height = (int) ( $size_data['height'] ?? 0 );
+	$width  = (int) ( $size_data['width'] ?? 0 );
+	$crop   = $size_data['crop'] ?? false;
 	if ( $width <= 0 && $height <= 0 ) {
 		return new WP_Error( 'image_wrong_dimensions', __( 'At least one of the dimensions must be a positive number.', 'webp-uploads' ) );
 	}
@@ -307,8 +312,10 @@
  *
  * @since 1.0.0
  *
- * @param array{ filesize?: int } $original   An array with the metadata of the attachment.
- * @param array{ filesize?: int } $additional An array containing the filename and file size for additional mime.
+ * @param array<string, mixed> $original   An array with the metadata of the attachment.
+ * @param array<string, mixed> $additional An array containing the filename and file size for additional mime.
+ * @phpstan-param array{ filesize?: int, ... } $original
+ * @phpstan-param array{ filesize?: int, ... } $additional
  * @return bool True if the additional image is larger than the original image, otherwise false.
  */
 function webp_uploads_should_discard_additional_image_file( array $original, array $additional ): bool {
@@ -340,6 +347,7 @@
  * Includes special handling for false positives on AVIF support.
  *
  * @since 2.0.0
+ * @since 2.7.0 AVIF is now only reported as supported when the server can also encode transparent AVIF images.
  *
  * @param string $mime_type The mime type to check.
  * @return bool Whether the server supports a given mime type.
@@ -351,16 +359,7 @@
 
 	// In certain server environments Image editors can report a false positive for AVIF support.
 	if ( 'image/avif' === $mime_type ) {
-		$editor = _wp_image_editor_choose( array( 'mime_type' => 'image/avif' ) );
-		if ( false === $editor ) {
-			return false;
-		}
-		if ( is_a( $editor, WP_Image_Editor_GD::class, true ) ) {
-			return function_exists( 'imageavif' );
-		}
-		if ( is_a( $editor, WP_Image_Editor_Imagick::class, true ) && class_exists( 'Imagick' ) ) {
-			return 0 !== count( Imagick::queryFormats( 'AVIF' ) );
-		}
+		return webp_uploads_avif_transparency_supported();
 	}
 
 	return true;
@@ -367,6 +366,58 @@
 }
 
 /**
+ * Checks if the server supports AVIF image format.
+ *
+ * @since 2.7.0
+ *
+ * @return bool True if the server supports AVIF, false otherwise.
+ */
+function webp_uploads_avif_supported(): bool {
+	$editor = _wp_image_editor_choose( array( 'mime_type' => 'image/avif' ) );
+	if ( false === $editor ) {
+		return false;
+	}
+	if ( is_a( $editor, WP_Image_Editor_GD::class, true ) ) {
+		return function_exists( 'imageavif' );
+	}
+	return webp_uploads_imagick_avif_supported( $editor );
+}
+
+/**
+ * Checks if the server supports transparent AVIF images.
+ *
+ * @since 2.7.0
+ *
+ * @return bool True if the server supports transparent AVIF images, false otherwise.
+ */
+function webp_uploads_avif_transparency_supported(): bool {
+	if ( ! webp_uploads_avif_supported() ) {
+		return false;
+	}
+	// AVIF is supported at this point. GD's imageavif() preserves alpha, so for GD (and any non-Imagick editor) AVIF support implies transparency support. The transparency regression is specific to older ImageMagick.
+	$editor = _wp_image_editor_choose( array( 'mime_type' => 'image/avif' ) );
+	if ( is_string( $editor ) && is_a( $editor, WP_Image_Editor_Imagick::class, true ) ) {
+		return webp_uploads_imagick_avif_transparency_supported();
+	}
+	return true;
+}
+
+/**
+ * Checks if Imagick supports AVIF format on the server.
+ *
+ * @since 2.7.0
+ *
+ * @param string $editor The image editor class name.
+ * @return bool True if Imagick supports AVIF, false otherwise.
+ */
+function webp_uploads_imagick_avif_supported( string $editor ): bool {
+	if ( is_a( $editor, WP_Image_Editor_Imagick::class, true ) && class_exists( 'Imagick' ) ) {
+		return 0 !== count( Imagick::queryFormats( 'AVIF' ) );
+	}
+	return false;
+}
+
+/**
  * Get the image output format setting from the option. Default is avif.
  *
  * @since 2.0.0
@@ -512,3 +563,46 @@
 	$mime_type = $filetype['type'] ?? get_post_mime_type( $attachment_id );
 	return is_string( $mime_type ) ? $mime_type : '';
 }
+
+/**
+ * Checks if Imagick has AVIF transparency support.
+ *
+ * ImageMagick versions prior to 6.9.12-68 report AVIF support (so AVIF images can be
+ * generated), but they do not preserve the alpha channel when encoding AVIF. As a result,
+ * transparent images converted to AVIF on those versions silently lose their transparency.
+ * Support for AVIF transparency was added in 6.9.12-68, so this gates on that minimum version.
+ *
+ * @since 2.7.0
+ *
+ * @link https://github.com/WordPress/performance/issues/2237
+ *
+ * @param string|null $version Optional Imagick version string. If not provided, the version will be retrieved from the Imagick class.
+ * @return bool True if Imagick has AVIF transparency support, false otherwise.
+ */
+function webp_uploads_imagick_avif_transparency_supported( ?string $version = null ): bool {
+	$imagick_version = $version;
+
+	if ( null === $imagick_version && extension_loaded( 'imagick' ) && class_exists( 'Imagick' ) ) {
+		$version_info    = Imagick::getVersion();
+		$imagick_version = is_array( $version_info ) && isset( $version_info['versionString'] ) && is_string( $version_info['versionString'] )
+			? $version_info['versionString']
+			: '';
+	}
+
+	if ( null === $imagick_version || '' === $imagick_version || ! (bool) preg_match( '/\d+(?:\.\d+)+(?:-\d+)?/', $imagick_version, $matches ) ) {
+		$supported = false;
+	} else {
+		// Transparency in AVIF is only handled correctly as of ImageMagick 6.9.12-68.
+		$imagick_version = $matches[0];
+		$supported       = version_compare( $imagick_version, '6.9.12-68', '>=' );
+	}
+
+	/**
+	 * Filters whether Imagick has AVIF transparency support.
+	 *
+	 * @since 2.7.0
+	 *
+	 * @param bool $supported Whether AVIF transparency is supported.
+	 */
+	return (bool) apply_filters( 'webp_uploads_imagick_avif_transparency_supported', $supported );
+}
Index: hooks.php
===================================================================
--- hooks.php	(revision 3592034)
+++ hooks.php	(working copy)
@@ -7,6 +7,8 @@
  * @since 1.0.0
  */
 
+declare( strict_types = 1 );
+
 // @codeCoverageIgnoreStart
 if ( ! defined( 'ABSPATH' ) ) {
 	exit; // Exit if accessed directly.
@@ -50,7 +52,8 @@
  *     sources?: array<string, array{
  *         file: string,
  *         filesize: int
- *     }>
+ *     }>,
+ *     ...
  * } 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 {
@@ -160,6 +163,7 @@
 
 			// Replace the attached file with the custom MIME type version.
 			if ( false !== $original_image ) {
+				// @phpstan-ignore no.private.function
 				$metadata = _wp_image_meta_replace_original( $saved_data, $original_image, $metadata, $attachment_id );
 			}
 
@@ -333,6 +337,11 @@
 		$output_format = array();
 	}
 
+	// Without a known source mime type there is nothing to map.
+	if ( null === $mime_type ) {
+		return $output_format;
+	}
+
 	// Use the original mime type if this type is allowed.
 	$valid_mime_transforms = webp_uploads_get_upload_image_mime_transforms();
 	if (
@@ -561,6 +570,56 @@
 }
 
 /**
+ * Filters `wp_get_attachment_image` so `<img>` tags produced outside of post
+ * content (archive templates, page builders, custom loops, featured-image
+ * template calls) also use the preferred MIME type where available.
+ *
+ * Only rewrites the HTML `<img>` (or `<picture>` wrapper in picture-element
+ * mode). Sibling URL-returning functions such as `wp_get_attachment_image_url()`
+ * and `wp_get_attachment_image_src()` are intentionally left untouched, since
+ * their return values feed non-HTML contexts (OG tags, RSS, JSON) where
+ * silently substituting a modern format is unsafe.
+ *
+ * @since 2.7.0
+ *
+ * @param string                 $html          HTML img element or empty string on failure.
+ * @param int                    $attachment_id Image attachment ID.
+ * @param string|array{int, int} $size          Requested image size.
+ * @param bool                   $icon          Whether the image should fall back to a mime type icon.
+ * @param array<string, string>  $attr          Array of attribute values for the image markup, keyed by attribute name.
+ * @phpstan-param int<1, max> $attachment_id
+ * @return string The filtered HTML.
+ */
+function webp_uploads_filter_wp_get_attachment_image( string $html, int $attachment_id, $size, bool $icon, array $attr ): string {
+	if ( '' === $html || 0 === $attachment_id || true === $icon || ! webp_uploads_in_frontend_body() ) {
+		return $html;
+	}
+
+	/**
+	 * Filters whether the Modern Image Formats plugin should rewrite an image returned by `wp_get_attachment_image()`.
+	 *
+	 * Returning false short-circuits the rewrite and preserves the original HTML. This gives
+	 * integrators a surgical per-call opt-out in addition to `remove_filter()`.
+	 *
+	 * @since 2.7.0
+	 *
+	 * @param bool                   $should_filter Whether to apply modern-format rewriting. Default true.
+	 * @param int<1, max>            $attachment_id Image attachment ID.
+	 * @param string|array{int, int} $size          Requested image size.
+	 * @param array<string, string>  $attr          Attribute array passed to `wp_get_attachment_image()`.
+	 */
+	if ( ! apply_filters( 'webp_uploads_filter_wp_get_attachment_image', true, $attachment_id, $size, $attr ) ) {
+		return $html;
+	}
+
+	if ( webp_uploads_is_picture_element_enabled() ) {
+		return webp_uploads_wrap_image_in_picture( $html, 'wp_get_attachment_image', $attachment_id );
+	}
+
+	return webp_uploads_img_tag_update_mime_type( $html, 'wp_get_attachment_image', $attachment_id );
+}
+
+/**
  * Finds all the urls with *.jpg and *.jpeg extension and updates with *.webp version for the provided image
  * for the specified image sizes, the *.webp references are stored inside of each size.
  *
@@ -662,25 +721,6 @@
 }
 
 /**
- * Updates the references of the featured image to the new image format if available, in the same way it
- * occurs in the_content of a post.
- *
- * @since 1.0.0
- *
- * @param string $html          The current HTML markup of the featured image.
- * @param int    $post_id       The current post ID where the featured image is requested.
- * @param int    $attachment_id The ID of the attachment image.
- * @return string The updated HTML markup.
- */
-function webp_uploads_update_featured_image( string $html, int $post_id, int $attachment_id ): string {
-	if ( webp_uploads_is_picture_element_enabled() ) {
-		return webp_uploads_wrap_image_in_picture( $html, 'post_thumbnail_html', $attachment_id );
-	}
-	return webp_uploads_img_tag_update_mime_type( $html, 'post_thumbnail_html', $attachment_id );
-}
-add_filter( 'post_thumbnail_html', 'webp_uploads_update_featured_image', 10, 3 );
-
-/**
  * Returns an array of image size names that have secondary mime type output enabled. Core sizes and
  * core theme sizes are enabled by default.
  *
@@ -852,6 +892,11 @@
 	// Filter regular image tags.
 	add_filter( 'wp_content_img_tag', webp_uploads_is_picture_element_enabled() ? 'webp_uploads_wrap_image_in_picture' : 'webp_uploads_filter_image_tag', 10, 3 );
 
+	// Filter `<img>` tags produced by template tags, page builders, and any other code path that calls
+	// `wp_get_attachment_image()` directly. `the_post_thumbnail()` also routes through this, so it covers
+	// featured images previously handled by a dedicated `post_thumbnail_html` filter.
+	add_filter( 'wp_get_attachment_image', 'webp_uploads_filter_wp_get_attachment_image', 10, 5 );
+
 	// Filter blocks that may contain background images.
 	add_filter( 'render_block_core/cover', 'webp_uploads_filter_block_background_images', 10, 2 );
 	add_filter( 'render_block_core/group', 'webp_uploads_filter_block_background_images', 10, 2 );
Index: image-edit.php
===================================================================
--- image-edit.php	(revision 3592034)
+++ image-edit.php	(working copy)
@@ -7,6 +7,8 @@
  * @since 1.0.0
  */
 
+declare( strict_types = 1 );
+
 // @codeCoverageIgnoreStart
 if ( ! defined( 'ABSPATH' ) ) {
 	exit; // Exit if accessed directly.
@@ -28,7 +30,7 @@
  *      sources?: array<string, array{ file: string, filesize: int }>,
  *      original_image?: string
  * } $metadata
- * @phpstan-param array<string, array{ file: string, path: string }> $main_images
+ * @phpstan-param array<string, array{ file: string, path: string, ... }> $main_images
  * @phpstan-param array<string, array<string, array{ file: string }>> $subsized_images
  *
  * @param array    $metadata              Metadata of the attachment.
@@ -293,7 +295,8 @@
  *     sizes: array<string, array{ file: string, width: int, height: int, 'mime-type': string, sources?: array<string, array{ file: string, filesize: int }> }>,
  *     image_meta: array<string, mixed>,
  *     filesize: int,
- *     original_image: string
+ *     original_image: string,
+ *     sources?: array<string, array{ file: string, filesize: int }>
  * } The updated metadata for the attachment to be stored in the meta table.
  */
 function webp_uploads_update_attachment_metadata( array $data, int $attachment_id ): array {
@@ -329,7 +332,7 @@
  *       width: int,
  *       height: int,
  *       file: string,
- *       sizes: array<string, array{ file: string, width: int, height: int, 'mime-type': string }>,
+ *       sizes: array<string, array{ file: string, width: int, height: int, 'mime-type': string, sources?: array<string, array{ file: string, filesize: int }> }>,
  *       image_meta: array<string, mixed>,
  *       filesize: int,
  *       original_image: string,
@@ -343,10 +346,11 @@
  *     width: int,
  *     height: int,
  *     file: string,
- *     sizes: array<string, array{ file: string, width: int, height: int, 'mime-type': string }>,
+ *     sizes: array<string, array{ file: string, width: int, height: int, 'mime-type': string, sources?: array<string, array{ file: string, filesize: int }> }>,
  *     image_meta: array<string, mixed>,
  *     filesize: int,
- *     original_image: string
+ *     original_image: string,
+ *     sources?: array<string, array{ file: string, filesize: int }>
  * } The updated metadata for the attachment.
  */
 function webp_uploads_backup_sources( int $attachment_id, array $data ): array {
@@ -473,10 +477,11 @@
  *        width: int,
  *        height: int,
  *        file: string,
- *        sizes: array<string, array{ file: string, width: int, height: int, 'mime-type': string }>,
+ *        sizes: array<string, array{ file: string, width: int, height: int, 'mime-type': string, sources?: array<string, array{ file: string, filesize: int }> }>,
  *        image_meta: array<string, mixed>,
  *        filesize: int,
- *        original_image: string
+ *        original_image: string,
+ *        sources?: array<string, array{ file: string, filesize: int }>
  *    } $data
  *
  * @param int                  $attachment_id The ID of the attachment.
@@ -485,7 +490,7 @@
  *     width: int,
  *     height: int,
  *     file: string,
- *     sizes: array<string, array{ file: string, width: int, height: int, 'mime-type': string }>,
+ *     sizes: array<string, array{ file: string, width: int, height: int, 'mime-type': string, sources?: array<string, array{ file: string, filesize: int }> }>,
  *     image_meta: array<string, mixed>,
  *     filesize: int,
  *     sources?: array<string, array{ file: string, filesize: int }>,
Index: load.php
===================================================================
--- load.php	(revision 3592034)
+++ load.php	(working copy)
@@ -3,9 +3,9 @@
  * Plugin Name: Modern Image Formats
  * Plugin URI: https://github.com/WordPress/performance/tree/trunk/plugins/webp-uploads
  * Description: Converts images to more modern formats such as WebP or AVIF during upload.
- * Requires at least: 6.6
- * Requires PHP: 7.2
- * Version: 2.6.1
+ * Requires at least: 6.9
+ * Requires PHP: 7.4
+ * Version: 2.7.0
  * Author: WordPress Performance Team
  * Author URI: https://make.wordpress.org/performance/
  * License: GPLv2 or later
@@ -15,6 +15,8 @@
  * @package webp-uploads
  */
 
+declare( strict_types = 1 );
+
 // @codeCoverageIgnoreStart
 if ( ! defined( 'ABSPATH' ) ) {
 	exit; // Exit if accessed directly.
@@ -26,7 +28,7 @@
 	return;
 }
 
-define( 'WEBP_UPLOADS_VERSION', '2.6.1' );
+define( 'WEBP_UPLOADS_VERSION', '2.7.0' );
 define( 'WEBP_UPLOADS_MAIN_FILE', plugin_basename( __FILE__ ) );
 
 require_once __DIR__ . '/helper.php';
Index: picture-element.php
===================================================================
--- picture-element.php	(revision 3592034)
+++ picture-element.php	(working copy)
@@ -7,6 +7,8 @@
  * @since 2.0.0
  */
 
+declare( strict_types = 1 );
+
 // @codeCoverageIgnoreStart
 if ( ! defined( 'ABSPATH' ) ) {
 	exit; // Exit if accessed directly.
@@ -25,10 +27,40 @@
  * @return string The new image tag.
  */
 function webp_uploads_wrap_image_in_picture( string $image, string $context, int $attachment_id ): string {
-	if ( ! in_array( $context, array( 'the_content', 'post_thumbnail_html', 'widget_block_content' ), true ) ) {
+	if ( ! in_array( $context, array( 'the_content', 'post_thumbnail_html', 'widget_block_content', 'wp_get_attachment_image' ), true ) ) {
 		return $image;
 	}
 
+	/*
+	 * Idempotency: bail if this markup has already been processed, to avoid
+	 * double-wrapping when more than one rewrite path fires on the same image.
+	 *
+	 * Two distinct cases are guarded:
+	 *
+	 * 1. The full `<picture>` string is passed in again.
+	 * 2. Only the inner `<img>` is passed in again. This happens when a
+	 *    `<picture>` produced for a `wp_get_attachment_image()` call is embedded
+	 *    in post content: `wp_filter_content_tags()` then extracts that inner
+	 *    `<img>` and runs it back through this function via `wp_content_img_tag`.
+	 *    The surrounding `<picture>` is not visible at that point, so the wrapped
+	 *    `<img>` carries a `data-wp-picture-wrapped` marker (added below) to be
+	 *    recognised here.
+	 *
+	 * The markup is parsed with WP_HTML_Tag_Processor rather than matched as a
+	 * raw substring, so a literal `<picture` or `data-wp-picture-wrapped` string
+	 * appearing inside an attribute value (such as `alt` text) cannot trigger a
+	 * false positive.
+	 */
+	$processor = new WP_HTML_Tag_Processor( $image );
+	while ( $processor->next_tag() ) {
+		if ( 'PICTURE' === $processor->get_tag() ) {
+			return $image;
+		}
+		if ( 'IMG' === $processor->get_tag() && null !== $processor->get_attribute( 'data-wp-picture-wrapped' ) ) {
+			return $image;
+		}
+	}
+
 	$original_file_mime_type = webp_uploads_get_attachment_file_mime_type( $attachment_id );
 	if ( '' === $original_file_mime_type ) {
 		return $image;
@@ -169,6 +201,22 @@
 		}
 	}
 
+	// Never emit a `<picture>` with no `<source>` children: if every modern-format
+	// source failed to resolve (e.g. the attachment has no modern sub-sizes), return
+	// the original markup untouched instead of a pointless empty wrapper element.
+	if ( '' === $picture_sources ) {
+		return $image;
+	}
+
+	// Tag the inner `<img>` so a later rewrite pass (for example `wp_content_img_tag`
+	// once this markup is embedded in post content) recognises it as already wrapped
+	// and skips it. See the idempotency guard above.
+	$marker = new WP_HTML_Tag_Processor( $image );
+	if ( $marker->next_tag( array( 'tag_name' => 'IMG' ) ) ) {
+		$marker->set_attribute( 'data-wp-picture-wrapped', true );
+		$image = $marker->get_updated_html();
+	}
+
 	return sprintf(
 		'<picture class="%s" style="display: contents;">%s%s</picture>',
 		esc_attr( 'wp-picture-' . $attachment_id ),
Index: readme.txt
===================================================================
--- readme.txt	(revision 3592034)
+++ readme.txt	(working copy)
@@ -2,7 +2,7 @@
 
 Contributors: wordpressdotorg
 Tested up to: 7.0
-Stable tag:   2.6.1
+Stable tag:   2.7.0
 License:      GPLv2 or later
 License URI:  https://www.gnu.org/licenses/gpl-2.0.html
 Tags:         performance, images, webp, avif, modern image formats
@@ -65,6 +65,18 @@
 
 == Changelog ==
 
+= 2.7.0 =
+
+**Enhancements**
+
+* Add explanation for discarded modern image outputs. ([2518](https://github.com/WordPress/performance/pull/2518))
+* Link to FAQ from settings section. ([2480](https://github.com/WordPress/performance/pull/2480))
+* Rewrite `img` tags output by `wp_get_attachment_image()`. ([2451](https://github.com/WordPress/performance/pull/2451))
+
+**Bug Fixes**
+
+* Disable AVIF when ImageMagick does not support AVIF transparency. ([2540](https://github.com/WordPress/performance/pull/2540))
+
 = 2.6.1 =
 
 **Bug Fixes**
Index: rest-api.php
===================================================================
--- rest-api.php	(revision 3592034)
+++ rest-api.php	(working copy)
@@ -7,6 +7,8 @@
  * @since 1.0.0
  */
 
+declare( strict_types = 1 );
+
 // @codeCoverageIgnoreStart
 if ( ! defined( 'ABSPATH' ) ) {
 	exit; // Exit if accessed directly.
@@ -38,7 +40,9 @@
 		foreach ( $details['sources'] as $mime => &$mime_details ) {
 			$mime_details['source_url'] = str_replace( $image_url_basename, $mime_details['file'], $details['source_url'] );
 		}
+		unset( $mime_details );
 	}
+	unset( $details );
 
 	$full_src = wp_get_attachment_image_src( $post->ID, 'full' );
 	if (
@@ -52,6 +56,7 @@
 		foreach ( $data['media_details']['sources'] as $mime => &$mime_details ) {
 			$mime_details['source_url'] = str_replace( $full_url_basename, $mime_details['file'], $full_src[0] );
 		}
+		unset( $mime_details );
 
 		$data['media_details']['sizes']['full']['sources'] = $data['media_details']['sources'];
 		unset( $data['media_details']['sources'] );
Index: settings.php
===================================================================
--- settings.php	(revision 3592034)
+++ settings.php	(working copy)
@@ -7,6 +7,8 @@
  * @since 1.0.0
  */
 
+declare( strict_types = 1 );
+
 // @codeCoverageIgnoreStart
 if ( ! defined( 'ABSPATH' ) ) {
 	exit; // Exit if accessed directly.
@@ -77,7 +79,19 @@
 	add_settings_section(
 		'perflab_modern_image_format_settings',
 		_x( 'Modern Image Formats', 'settings page section name', 'webp-uploads' ),
-		'__return_empty_string',
+		static function (): void {
+			printf(
+				'<p>%s</p>',
+				wp_kses(
+					sprintf(
+						/* translators: %s: URL to the plugin FAQ on WordPress.org */
+						__( 'If modern format images are not being generated after upload, see the <a href="%s">FAQ</a> for common reasons.', 'webp-uploads' ),
+						'https://wordpress.org/plugins/webp-uploads/#faq'
+					),
+					array( 'a' => array( 'href' => array() ) )
+				)
+			);
+		},
 		'media',
 		array(
 			'before_section' => '<div id="modern-image-formats">',
@@ -139,14 +153,16 @@
  */
 function webp_uploads_generate_avif_webp_setting_callback(): void {
 
-	$selected       = webp_uploads_get_image_output_format();
-	$avif_supported = webp_uploads_mime_type_supported( 'image/avif' );
-	$webp_supported = webp_uploads_mime_type_supported( 'image/webp' );
+	$selected                    = webp_uploads_get_image_output_format();
+	$avif_supported              = webp_uploads_avif_supported();
+	$avif_transparency_supported = webp_uploads_avif_transparency_supported();
+	$avif_fully_supported        = $avif_supported && $avif_transparency_supported;
+	$webp_supported              = webp_uploads_mime_type_supported( 'image/webp' );
 
 	// If neither format is support, the entire field is not shown.
-	if ( ! $avif_supported && ! $webp_supported ) {
+	if ( ! $avif_fully_supported && ! $webp_supported ) {
 		?>
-		<br />
+		<br>
 		<div class="notice notice-warning inline">
 			<p><b><?php esc_html_e( 'Modern image support is not available.', 'webp-uploads' ); ?></b></p>
 			<p><?php esc_html_e( 'WebP or AVIF support can only be enabled by your hosting provider, so contact them for more information.', 'webp-uploads' ); ?></p>
@@ -156,7 +172,7 @@
 	}
 
 	// If only one of the two formats is supported, the dropdown defaults to that type and the other type is disabled.
-	if ( ! $avif_supported && 'avif' === $selected ) {
+	if ( ! $avif_fully_supported && 'avif' === $selected ) {
 		$selected = 'webp';
 	} elseif ( ! $webp_supported && 'webp' === $selected ) {
 		$selected = 'avif';
@@ -164,21 +180,30 @@
 	?>
 	<select name="perflab_modern_image_format" id="perflab_modern_image_format" aria-describedby="perflab_modern_image_format_description">
 		<option value="webp"<?php selected( 'webp', $selected ); ?><?php disabled( ! $webp_supported ); ?>><?php esc_html_e( 'WebP', 'webp-uploads' ); ?></option>
-		<option value="avif"<?php selected( 'avif', $selected ); ?><?php disabled( ! $avif_supported ); ?>><?php esc_html_e( 'AVIF', 'webp-uploads' ); ?></option>
+		<option value="avif"<?php selected( 'avif', $selected ); ?><?php disabled( ! $avif_fully_supported ); ?>><?php esc_html_e( 'AVIF', 'webp-uploads' ); ?></option>
 	</select>
 	<label for="perflab_modern_image_format">
 		<?php esc_html_e( 'Generate images in this format', 'webp-uploads' ); ?>
 	</label>
-	<p class="description" id="perflab_modern_image_format_description"><?php esc_html_e( 'Select the format to use when generating new images from uploaded images.', 'webp-uploads' ); ?></p>
+	<p class="description" id="perflab_modern_image_format_description">
+		<?php esc_html_e( 'Select the format to use when generating new images from uploaded images.', 'webp-uploads' ); ?>
+		<?php esc_html_e( 'Generated images may be discarded if the file in the modern format is larger than the originally uploaded image.', 'webp-uploads' ); ?>
+	</p>
 	<?php if ( ! $avif_supported ) : ?>
-		<br />
+		<br>
 		<div class="notice notice-warning inline">
 			<p><b><?php esc_html_e( 'AVIF support is not available.', 'webp-uploads' ); ?></b></p>
 			<p><?php esc_html_e( 'AVIF support can only be enabled by your hosting provider, so contact them for more information.', 'webp-uploads' ); ?></p>
 		</div>
+	<?php elseif ( ! $avif_transparency_supported ) : ?>
+		<br>
+		<div class="notice notice-warning inline">
+			<p><b><?php esc_html_e( 'AVIF is supported, but not fully: transparency support is lacking.', 'webp-uploads' ); ?></b></p>
+			<p><?php esc_html_e( 'Current ImageMagick version does not support transparent AVIF images, so contact your hosting provider for more information.', 'webp-uploads' ); ?></p>
+		</div>
 	<?php endif; ?>
 	<?php if ( ! $webp_supported ) : ?>
-		<br />
+		<br>
 		<div class="notice notice-warning inline">
 			<p><b><?php esc_html_e( 'WebP support is not available.', 'webp-uploads' ); ?></b></p>
 			<p><?php esc_html_e( 'WebP support can only be enabled by your hosting provider, so contact them for more information.', 'webp-uploads' ); ?></p>
Index: uninstall.php
===================================================================
--- uninstall.php	(revision 3592034)
+++ uninstall.php	(working copy)
@@ -6,6 +6,8 @@
  * @since 1.1.0
  */
 
+declare( strict_types = 1 );
+
 // If uninstall.php is not called by WordPress, bail.
 if ( ! defined( 'WP_UNINSTALL_PLUGIN' ) ) {
 	exit;
@@ -23,7 +25,7 @@
 	);
 
 	foreach ( $webp_uploads_site_ids as $webp_uploads_site_id ) {
-		switch_to_blog( $webp_uploads_site_id );
+		switch_to_blog( $webp_uploads_site_id ); // @phpstan-ignore argument.type (get_sites( 'fields' => 'ids' ) returns int[], but php-stubs/wordpress-stubs uses a sealed array shape in its conditional return type so the narrowing is lost when extra args are passed. TODO: Fix upstream in php-stubs/wordpress-stubs and remove.)
 		webp_uploads_delete_plugin_option();
 		restore_current_blog();
 	}

@westonruter

Copy link
Copy Markdown
Member Author

@westonruter
westonruter marked this pull request as ready for review July 1, 2026 00:56
@github-actions

github-actions Bot commented Jul 1, 2026

Copy link
Copy Markdown

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

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

Co-authored-by: westonruter <westonruter@git.wordpress.org>
Co-authored-by: adamsilverstein <adamsilverstein@git.wordpress.org>

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

@codecov

codecov Bot commented Jul 1, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 33.33333% with 2 lines in your changes missing coverage. Please review.
✅ Project coverage is 70.38%. Comparing base (e5098bf) to head (afa7016).

Files with missing lines Patch % Lines
plugins/performance-lab/load.php 0.00% 1 Missing ⚠️
plugins/webp-uploads/load.php 0.00% 1 Missing ⚠️
Additional details and impacted files
@@                 Coverage Diff                 @@
##           release/2026-06-30    #2554   +/-   ##
===================================================
  Coverage               70.38%   70.38%           
===================================================
  Files                      91       91           
  Lines                    7850     7850           
===================================================
  Hits                     5525     5525           
  Misses                   2325     2325           
Flag Coverage Δ
multisite 70.38% <33.33%> (ø)
single 35.59% <0.00%> (ø)

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

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

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

@adamsilverstein adamsilverstein left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Fantastic

@westonruter
westonruter merged commit b8449ac into release/2026-06-30 Jul 1, 2026
65 checks passed
@westonruter
westonruter deleted the publish/2026-06-30 branch July 1, 2026 01:21
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Infrastructure Issues for the overall performance plugin infrastructure no milestone PRs that do not have a defined milestone for release skip changelog PRs that should not be mentioned in changelogs [Type] Documentation Documentation to be added or enhanced

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants