Skip to content

Prepare 2026-08-18 release - #2639

Merged
westonruter merged 11 commits into
release/2026-08-18from
publish/2026-08-18
Aug 26, 2026
Merged

Prepare 2026-08-18 release#2639
westonruter merged 11 commits into
release/2026-08-18from
publish/2026-08-18

Conversation

@westonruter

@westonruter westonruter commented Aug 21, 2026

Copy link
Copy Markdown
Member

Summary

Prepares the 2026-08-18 releases. Seven plugins are being released; the pending changes are almost entirely one cross-cutting sweep that had accumulated on trunk without a release: declare( strict_types = 1 ) in every PHP file, native property types, and the raised WordPress 6.9 / PHP 7.4 minimums.

Plugin Version
Enhanced Responsive Images 1.7.0 → 1.8.0
Image Placeholders 1.2.1 → 1.3.0
Speculative Loading 1.6.0 → 1.7.0
Web Worker Offloading 0.2.1 → 0.3.0
Embed Optimizer 1.0.0-beta5 → 1.0.0-beta6
Image Prioritizer 1.0.0-beta3 → 1.0.0-beta4
Optimization Detective 1.0.0-beta6 → 1.0.0-beta7

Performance Lab, View Transitions, and Modern Image Formats are deliberately not version-bumped. Their pending changes do not warrant a release: for Performance Lab and Modern Image Formats it is only the Tested up to: 7.1 bump, and View Transitions additionally has a rebuilt css/view-transition-animation-wipe.min.css whose only difference is the minifier emitting #0000 where it previously emitted transparent (the unminified source is byte-identical to what is published). The Tested up to bump reaches the directory through the bump-wordpress-tested-up-to workflow instead, which updates readme.txt in both trunk and the stable tag without a release.

The pending diff is posted as one comment per plugin below, rather than as a single comment, since the full report is 88 KB and exceeds GitHub's 65,536 character comment limit on its own. A separate comment covers the back-compat review and runtime verification of the web-vitals 5.1.0 → 6.1.0 upgrade in Optimization Detective.

Since the release date has moved on from the branch name, the plan is to merge this into release/2026-08-18, branch release/<publish date> from it, and tag from there. This follows what was done when the 2026-06-19 release slipped: release/2026-06-30 was cut from release/2026-06-19, tagged, and the earlier branch was left untagged.

Previous release preparation PR: #2619

Relevant technical choices

Minor rather than patch. The deciding factor is that all seven plugins raise their minimum requirements (Requires at least 6.6/6.8 → 6.9, Requires PHP 7.2 → 7.4). Users on the dropped versions stop being offered the update, which is a compatibility break that should be visible in the version number rather than buried in a patch. declare( strict_types = 1 ) reinforces this, since it is a runtime behaviour change and not a refactor: a value that was previously coerced can now raise a TypeError. The two 1.0.0-beta* plugins take a prerelease bump instead, since a minor bump would graduate them to stable.

Releasing without milestones. These releases carry only changes that were already merged, so no release milestones were created. Three separate commands turned out to derive their input from a dated milestone, and each needed an escape hatch:

  • bump-versions gains --increment <major|minor|patch|prerelease> and --set-version <version>, which derive the target from the plugin's current Stable tag instead. Plugins are named as positional arguments, matching generate-pending-release-diffs.sh. The list is required rather than inferred, because the correct increment level differs per plugin, so no single auto-detected set would be right. Incrementing a prerelease version by major/minor/patch is refused outright, and all target versions are resolved before anything is written so that guard aborts the run rather than leaving a partial bump.
  • prepare-release-notes likewise accepts plugin slugs positionally and skips the milestone lookup when given any. The milestone was never the source of the content — getReadmeChangelogEntry() already reads each plugin's changelog from its readme.txt — it only decided which plugins to include. This mattered more than it first appears: only one of the seven plugins has a dated milestone, and create-draft-release checked only that the notes file was non-empty, so a draft release would have been created covering one plugin out of seven with nothing to indicate the other six were missing. create-draft-release now forwards slugs through and verifies each is present in the generated notes; without slugs it lists which plugins the notes actually cover.
  • The changelog entries themselves are written by hand, since npm run readme is also milestone-bound.

Unlike bump-versions, prepare-release-notes deliberately has no option to select every plugin. A plugin that is not being released still has a changelog entry for its current stable tag, so including it would repeat an already-published entry in the new release notes. Where plugins are named explicitly, a failure on any of them is fatal and nothing is written at all, since asking for specific plugins and quietly getting fewer is the failure being guarded against.

npm run since was a no-op this cycle: there are no n.e.x.t markers anywhere in plugins/, which is consistent with a sweep that adds types rather than APIs.

Two fixes to generate-pending-release-diffs.sh. Both matter because this is the script the release is verified against.

  1. It could report a plugin as having no pending changes when it had some. Tested up to: 7.07.1 is a same-length edit, and the working copies under /tmp/stable-svn are reused across runs, so a previous run's rsync -a leaves the build file's mtime on the copy while a later svn revert restores the content without restoring the mtime. Both rsync's default quick check and SVN's own stat cache compare size and mtime rather than content, so each concluded the file was unchanged and svn status came back clean. Fixed with -c and --no-times; both are required, as -c alone leaves SVN unable to see the change.

  2. Generated assets were excluded from the copy, which blinded svn status to them. That is the wrong trade: svn status is the view that answers which files are being added, removed, and modified, and it should be complete. The exclusion also protected those files from --delete, so a stray minified asset dropped into an already-tracked directory was reported nowhere at all, and one landing in a new directory showed up only as its parent directory. Everything is now copied, and svn diff is filtered so generated files keep their header but have their contents replaced with (Built file content suppressed.). The report drops from 127 KB to 88 KB while svn status stays complete. Unversioned directories are also expanded in the status output so a batch of added files is listed individually.

The *.asset.php files are deliberately left unsuppressed, since their version is the readable signal that a bundled library changed. That carve-out earned its place immediately: it surfaced that Optimization Detective's bundled web-vitals goes from 5.1.0 to 6.1.0, across a major release, which was missing from its changelog entry and has been added.

Source maps for the vendored web-vitals bundles. web-vitals began publishing source maps in 6.0.0 and emits a sourceMappingURL comment alongside them. That reference is correct inside the package, where the map sits beside the bundle, but the webpack config copies only the bundles out of node_modules. Left alone, this release would have shipped two files pointing at maps that do not exist, so browsers would request a URL that 404s whenever devtools is open.

The maps are now copied too, rather than the comment being stripped. Upstream publishes them deliberately for exactly this case, and Optimization Detective is a plugin whose purpose is measuring real user performance, so readable web-vitals frames are worth having when debugging it or an extension built on it. Both maps carry sourcesContent, so they are self-contained. The cost is 49 KB on a 106 KB zip and nothing at runtime, since a browser only requests a map once devtools is open. For reference, the Gutenberg plugin ships source maps; WordPress core does not, but core is not distributed as a plugin.

Two renames had to be handled, since web-vitals.attribution.js is copied in as web-vitals-attribution.js: its map is named to match, the sourceMappingURL in the bundle is repointed at the new name, and the map's own file field is repointed at the renamed bundle. Both transformers are factories over a file name rather than hardcoded, since any vendored bundle renamed on the way in needs the same treatment.

Worth noting that the two added .map files appear in the pending diff as untracked additions only because of the second script fix above. Under the previous exclusion, 223 KB of new files would have been added to the plugin without appearing anywhere in the report.

Use of AI Tools

Claude Code (Opus) did the bulk of the work in this PR under close direction: analysing the pending release diff to determine the appropriate bump level per plugin, implementing the bump-versions and prepare-release-notes changes, diagnosing and fixing both generate-pending-release-diffs.sh issues, wiring up the source maps, reviewing the web-vitals upgrade for back-compat breakage and verifying collection at runtime, and drafting the changelog entries and this description.

Two of the decisions here were corrections of its work by the maintainer. The suppress-rather-than-exclude design in the second script fix replaced an earlier attempt that excluded the files outright and then added a separate file manifest to compensate, which was the wrong shape. Shipping the web-vitals source maps replaced a recommendation to strip the comment instead, which had been argued on a misstated cost: 223 KB uncompressed was quoted as though it were the download cost, when the actual zip delta is 49 KB, and it was framed as a burden on every site when no visitor ever fetches a source map.

Changelog PR attributions were verified against the GitHub API rather than inferred. All output was reviewed before committing.

westonruter and others added 7 commits August 17, 2026 14:02
WordPress 7.1 is imminent, so update the readme header for each of the ten
plugins published to the plugin directory ahead of the upcoming releases.

The unreleased od-* plugins have no readme.txt and are unaffected, and the
"Requires at least" floor is left at 6.9.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Bumping a plugin version previously required an open, dated release milestone
titled "<slug> <version>". That is a lot of ceremony for a release that only
carries changes which are already merged, so add two ways to bump without one:

* --increment <major|minor|patch|prerelease> derives the target version from the
  plugin's current "Stable tag".
* --set-version <version> sets an explicit version for a single plugin.

In either mode the plugins are named as positional arguments, matching the
calling convention of generate-pending-release-diffs.sh. The list is required
rather than inferred because the appropriate increment level differs per plugin,
so no single auto-detected set would be correct. Passing --all targets every
plugin for the rare case where one level does apply to all of them.

The "prerelease" level increments the trailing number of the prerelease
component, so 1.0.0-beta5 becomes 1.0.0-beta6. This is hand-rolled rather than
delegated to semver, which yields 1.0.0-beta5.0 because it treats "beta5" as a
single alphanumeric identifier.

Incrementing a version that has a prerelease component by major, minor, or patch
is refused, since that would silently graduate a beta to a stable release. All
target versions are resolved before any file is written, so that guard aborts the
whole run rather than leaving some plugins bumped and others not.

The milestone-based behaviour is unchanged when neither new option is passed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…n-rules, web-worker-offloading

Run via:

```bash
npm run bump-versions -- --increment minor auto-sizes dominant-color-images speculation-rules web-worker-offloading
```
Run via:

```bash
npm run bump-versions -- --increment prerelease embed-optimizer image-prioritizer
```
Fill in the empty changelog entries that bump-versions stubbed out for the seven
plugins being released. The entries are written by hand because the changelog and
prepare-release-notes commands derive their content from a release milestone, and
these releases deliberately skip milestones.

The bulk of each entry is the same across all seven plugins, since what is pending
is a single cross-cutting sweep: strict types, native property types, and the
raised WordPress 6.9 and PHP 7.4 minimums. Only three plugins carry anything
beyond that:

* Optimization Detective moved the URL Metrics storage HMAC validation into the
  REST endpoint callback and dropped its deprecated constants.
* Speculative Loading hardened the JSON encoding of its inline scripts.
* Image Prioritizer fixed TypeScript 6 type errors in the video lazy-loading
  script.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The script could report a plugin as having no pending changes when it actually
had some. Bumping "Tested up to: 7.0" to "7.1" replaces a line with one of
exactly the same length, and the working copy is reused between runs, so a
previous run's rsync leaves the build file's mtime on the copy. Both rsync's
default quick check and SVN's own stat cache compare size and mtime rather than
content, so each concludes the file is unchanged and "svn status" comes back
clean.

The failure is intermittent, since it only bites once a prior run has left a
matching mtime behind, and it is silent in the worst way: the affected plugin is
reported under a "No changes." note, which reads as confirmation that there is
nothing to release.

Pass -c so rsync compares checksums, and --no-times so the files it copies get a
fresh mtime that invalidates SVN's stat cache. Both are needed; -c alone still
leaves SVN unable to see the change.

Verified against a cold checkout and a warm one, with identical results.

Co-Authored-By: Claude Opus 5 <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 Aug 21, 2026
@codecov

codecov Bot commented Aug 21, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 0% with 3 lines in your changes missing coverage. Please review.
✅ Project coverage is 70.35%. Comparing base (79e7ac7) to head (8fb3ddd).

Files with missing lines Patch % Lines
plugins/embed-optimizer/load.php 0.00% 1 Missing ⚠️
plugins/image-prioritizer/load.php 0.00% 1 Missing ⚠️
plugins/optimization-detective/load.php 0.00% 1 Missing ⚠️
Additional details and impacted files
@@                 Coverage Diff                 @@
##           release/2026-08-18    #2639   +/-   ##
===================================================
  Coverage               70.35%   70.35%           
===================================================
  Files                      91       91           
  Lines                    7867     7867           
===================================================
  Hits                     5535     5535           
  Misses                   2332     2332           
Flag Coverage Δ
multisite 70.35% <0.00%> (ø)
single 35.17% <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.

@github-actions

github-actions Bot commented Aug 21, 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>
Co-authored-by: mukeshpanchal27 <mukesh27@git.wordpress.org>
Co-authored-by: swissspidy <swissspidy@git.wordpress.org>
Co-authored-by: b1ink0 <b1ink0@git.wordpress.org>

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

westonruter and others added 2 commits August 22, 2026 14:38
The two web-vitals bundles under build/ accounted for 35% of the whole diff,
42 KB out of 124 KB, for what amounts to a library upgrade. They are minified
despite not carrying a .min.js suffix, so the existing *.min.js exclusion misses
them, and each is a single line, meaning any change at all renders as a
whole-file rewrite.

Exclude build/*.js, which brings the diff down from 127 KB to 85 KB. The sibling
build/*.asset.php is deliberately left in, since its 'version' is the compact
signal that the bundled library changed: two lines showing 5.1.0 becoming 6.1.0,
in place of 42 KB of unreadable bundle.

That signal turned out to be worth keeping. It surfaced that web-vitals is going
from 5.1.0 to 6.1.0, across a major release, which was missing from Optimization
Detective's changelog entry and is now added.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Excluding generated assets from the rsync kept the diff readable but blinded
"svn status" to them, which is the wrong trade: status is the view that answers
which files are being added, removed, and modified, and it should be complete.
Worse, the exclusion also protected those files from --delete, so a stray
minified asset dropped into an already-tracked directory was reported nowhere at
all. Only one landing in a brand new directory showed up, and then merely as its
parent directory, with no indication of what was inside.

Copy everything instead, and filter "svn diff" so that generated files keep their
header but have their contents replaced with a placeholder. They stay visible as
changed without dragging in tens of KB of unreadable bundle: the whole report
goes from 127 KB to 88 KB. The *.asset.php files are left intact, since their
'version' is the readable signal that a bundled library changed.

Also expand unversioned directories in the status output, so that a batch of
added files is listed file by file rather than collapsing into its parent.

This supersedes the build/*.js exclusion added in the previous commit, whose
carve-out for *.asset.php is preserved here by the same reasoning.

Both edge cases are now covered: a stray min file in a tracked directory shows as
"? stray.min.js", and a new directory lists its contents. It immediately turned
up a real pending change that the exclusion had been hiding, a modified
view-transitions .min.css, which on inspection is only a minifier emitting #0000
where it used to emit transparent.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@westonruter

Copy link
Copy Markdown
Member Author

🤖 Comment by Claude Opus 5

auto-sizes

Important

Stable tag change: 1.7.0 → 1.8.0

svn status:

M       auto-sizes.php
M       hooks.php
M       includes/improve-calculate-sizes.php
M       readme.txt
svn diff
Index: auto-sizes.php
===================================================================
--- auto-sizes.php	(revision 3661224)
+++ auto-sizes.php	(working copy)
@@ -3,9 +3,9 @@
  * 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
- * Version: 1.7.0
+ * Requires at least: 6.9
+ * Requires PHP: 7.4
+ * Version: 1.8.0
  * Author: WordPress Performance Team
  * Author URI: https://make.wordpress.org/performance/
  * License: GPLv2 or later
@@ -15,6 +15,8 @@
  * @package auto-sizes
  */
 
+declare( strict_types = 1 );
+
 // @codeCoverageIgnoreStart
 if ( ! defined( 'ABSPATH' ) ) {
 	exit; // Exit if accessed directly.
@@ -25,7 +27,7 @@
 	return;
 }
 
-define( 'IMAGE_AUTO_SIZES_VERSION', '1.7.0' );
+define( 'IMAGE_AUTO_SIZES_VERSION', '1.8.0' );
 
 require_once __DIR__ . '/includes/improve-calculate-sizes.php';
 require_once __DIR__ . '/hooks.php';
Index: hooks.php
===================================================================
--- hooks.php	(revision 3661224)
+++ 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 3661224)
+++ 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.
Index: readme.txt
===================================================================
--- readme.txt	(revision 3661224)
+++ readme.txt	(working copy)
@@ -1,8 +1,8 @@
 === Enhanced Responsive Images ===
 
 Contributors: wordpressdotorg
-Tested up to: 7.0
-Stable tag:   1.7.0
+Tested up to: 7.1
+Stable tag:   1.8.0
 License:      GPLv2 or later
 License URI:  https://www.gnu.org/licenses/gpl-2.0.html
 Tags:         performance, images, auto-sizes
@@ -52,6 +52,15 @@
 
 == Changelog ==
 
+= 1.8.0 =
+
+**Enhancements**
+
+* Add `declare( strict_types = 1 )` to all PHP files. ([2424](https://github.com/WordPress/performance/pull/2424))
+* Bump minimum required WordPress version to 6.9. ([2517](https://github.com/WordPress/performance/pull/2517))
+* Bump minimum required PHP version from 7.2 to 7.4. ([2469](https://github.com/WordPress/performance/pull/2469))
+* Improve code quality with native property types and stricter static analysis. ([1729](https://github.com/WordPress/performance/pull/1729))
+
 = 1.7.0 =
 
 **Enhancements**

@westonruter

Copy link
Copy Markdown
Member Author

🤖 Comment by Claude Opus 5

dominant-color-images

Important

Stable tag change: 1.2.1 → 1.3.0

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
M       readme.txt
svn diff
Index: class-dominant-color-image-editor-gd.php
===================================================================
--- class-dominant-color-image-editor-gd.php	(revision 3661224)
+++ 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 3661224)
+++ 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 3661224)
+++ 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 3661224)
+++ 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 3661224)
+++ load.php	(working copy)
@@ -3,9 +3,9 @@
  * 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
- * Version: 1.2.1
+ * Requires at least: 6.9
+ * Requires PHP: 7.4
+ * Version: 1.3.0
  * Author: WordPress Performance Team
  * Author URI: https://make.wordpress.org/performance/
  * License: GPLv2 or later
@@ -15,6 +15,8 @@
  * @package dominant-color-images
  */
 
+declare( strict_types = 1 );
+
 // @codeCoverageIgnoreStart
 if ( ! defined( 'ABSPATH' ) ) {
 	exit; // Exit if accessed directly.
@@ -26,7 +28,7 @@
 	return;
 }
 
-define( 'DOMINANT_COLOR_IMAGES_VERSION', '1.2.1' );
+define( 'DOMINANT_COLOR_IMAGES_VERSION', '1.3.0' );
 
 require_once __DIR__ . '/helper.php';
 require_once __DIR__ . '/hooks.php';
Index: readme.txt
===================================================================
--- readme.txt	(revision 3661224)
+++ readme.txt	(working copy)
@@ -1,8 +1,8 @@
 === Image Placeholders ===
 
 Contributors: wordpressdotorg
-Tested up to: 7.0
-Stable tag:   1.2.1
+Tested up to: 7.1
+Stable tag:   1.3.0
 License:      GPLv2 or later
 License URI:  https://www.gnu.org/licenses/gpl-2.0.html
 Tags:         performance, images, dominant color
@@ -47,6 +47,15 @@
 
 == Changelog ==
 
+= 1.3.0 =
+
+**Enhancements**
+
+* Add `declare( strict_types = 1 )` to all PHP files. ([2424](https://github.com/WordPress/performance/pull/2424))
+* Bump minimum required WordPress version to 6.9. ([2517](https://github.com/WordPress/performance/pull/2517))
+* Bump minimum required PHP version from 7.2 to 7.4. ([2469](https://github.com/WordPress/performance/pull/2469))
+* Improve code quality with native property types and stricter static analysis. ([1729](https://github.com/WordPress/performance/pull/1729))
+
 = 1.2.1 =
 
 **Bug Fixes**

@westonruter

Copy link
Copy Markdown
Member Author

🤖 Comment by Claude Opus 5

embed-optimizer

Important

Stable tag change: 1.0.0-beta5 → 1.0.0-beta6

svn status:

M       class-embed-optimizer-tag-visitor.php
M       detect.js
M       detect.min.js
M       helper.php
M       hooks.php
M       lazy-load.min.js
M       load.php
M       readme.txt
svn diff
Index: class-embed-optimizer-tag-visitor.php
===================================================================
--- class-embed-optimizer-tag-visitor.php	(revision 3661224)
+++ 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 3661224)
+++ 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: detect.min.js
===================================================================
--- detect.min.js	(revision 3661224)
+++ detect.min.js	(working copy)
(Built file content suppressed.)
Index: helper.php
===================================================================
--- helper.php	(revision 3661224)
+++ 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 3661224)
+++ 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: lazy-load.min.js
===================================================================
--- lazy-load.min.js	(revision 3661224)
+++ lazy-load.min.js	(working copy)
(Built file content suppressed.)
Index: load.php
===================================================================
--- load.php	(revision 3661224)
+++ load.php	(working copy)
@@ -3,9 +3,9 @@
  * 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
- * Version: 1.0.0-beta5
+ * Requires at least: 6.9
+ * Requires PHP: 7.4
+ * Version: 1.0.0-beta6
  * Author: WordPress Performance Team
  * Author URI: https://make.wordpress.org/performance/
  * License: GPLv2 or later
@@ -15,6 +15,8 @@
  * @package embed-optimizer
  */
 
+declare( strict_types = 1 );
+
 // @codeCoverageIgnoreStart
 if ( ! defined( 'ABSPATH' ) ) {
 	exit; // Exit if accessed directly.
@@ -71,7 +73,7 @@
 	}
 )(
 	'embed_optimizer_pending_plugin',
-	'1.0.0-beta5',
+	'1.0.0-beta6',
 	static function ( string $version ): void {
 		if ( defined( 'EMBED_OPTIMIZER_VERSION' ) ) {
 			return;
Index: readme.txt
===================================================================
--- readme.txt	(revision 3661224)
+++ readme.txt	(working copy)
@@ -1,8 +1,8 @@
 === Embed Optimizer ===
 
 Contributors: wordpressdotorg
-Tested up to: 7.0
-Stable tag:   1.0.0-beta5
+Tested up to: 7.1
+Stable tag:   1.0.0-beta6
 License:      GPLv2 or later
 License URI:  https://www.gnu.org/licenses/gpl-2.0.html
 Tags:         performance, embeds, optimization-detective
@@ -67,6 +67,15 @@
 
 == Changelog ==
 
+= 1.0.0-beta6 =
+
+**Enhancements**
+
+* Add `declare( strict_types = 1 )` to all PHP files. ([2424](https://github.com/WordPress/performance/pull/2424))
+* Bump minimum required WordPress version to 6.9. ([2517](https://github.com/WordPress/performance/pull/2517))
+* Bump minimum required PHP version from 7.2 to 7.4. ([2469](https://github.com/WordPress/performance/pull/2469))
+* Improve code quality with native property types and stricter static analysis. ([1729](https://github.com/WordPress/performance/pull/1729))
+
 = 1.0.0-beta5 =
 
 **Bug Fixes**

@westonruter

Copy link
Copy Markdown
Member Author

🤖 Comment by Claude Opus 5

image-prioritizer

Important

Stable tag change: 1.0.0-beta3 → 1.0.0-beta4

svn status:

M       class-image-prioritizer-background-image-styled-tag-visitor.php
M       class-image-prioritizer-img-tag-visitor.php
M       class-image-prioritizer-tag-visitor.php
M       class-image-prioritizer-video-tag-visitor.php
M       detect.min.js
M       helper.php
M       hooks.php
M       lazy-load-bg-image.min.js
M       lazy-load-video.js
M       lazy-load-video.min.js
M       load.php
M       readme.txt
svn diff
Index: class-image-prioritizer-background-image-styled-tag-visitor.php
===================================================================
--- class-image-prioritizer-background-image-styled-tag-visitor.php	(revision 3661224)
+++ 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 3661224)
+++ 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 3661224)
+++ 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 3661224)
+++ 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: detect.min.js
===================================================================
--- detect.min.js	(revision 3661224)
+++ detect.min.js	(working copy)
(Built file content suppressed.)
Index: helper.php
===================================================================
--- helper.php	(revision 3661224)
+++ 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 3661224)
+++ 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-bg-image.min.js
===================================================================
--- lazy-load-bg-image.min.js	(revision 3661224)
+++ lazy-load-bg-image.min.js	(working copy)
(Built file content suppressed.)
Index: lazy-load-video.js
===================================================================
--- lazy-load-video.js	(revision 3661224)
+++ 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: lazy-load-video.min.js
===================================================================
--- lazy-load-video.min.js	(revision 3661224)
+++ lazy-load-video.min.js	(working copy)
(Built file content suppressed.)
Index: load.php
===================================================================
--- load.php	(revision 3661224)
+++ load.php	(working copy)
@@ -3,10 +3,10 @@
  * 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
+ * Version: 1.0.0-beta4
  * Author: WordPress Performance Team
  * Author URI: https://make.wordpress.org/performance/
  * License: GPLv2 or later
@@ -16,6 +16,8 @@
  * @package image-prioritizer
  */
 
+declare( strict_types = 1 );
+
 // @codeCoverageIgnoreStart
 if ( ! defined( 'ABSPATH' ) ) {
 	exit; // Exit if accessed directly.
@@ -72,7 +74,7 @@
 	}
 )(
 	'image_prioritizer_pending_plugin',
-	'1.0.0-beta3',
+	'1.0.0-beta4',
 	static function ( string $version ): void {
 		if ( defined( 'IMAGE_PRIORITIZER_VERSION' ) ) {
 			return;
Index: readme.txt
===================================================================
--- readme.txt	(revision 3661224)
+++ readme.txt	(working copy)
@@ -1,8 +1,8 @@
 === Image Prioritizer ===
 
 Contributors: wordpressdotorg
-Tested up to: 7.0
-Stable tag:   1.0.0-beta3
+Tested up to: 7.1
+Stable tag:   1.0.0-beta4
 License:      GPLv2 or later
 License URI:  https://www.gnu.org/licenses/gpl-2.0.html
 Tags:         performance, optimization, image, optimization-detective
@@ -72,6 +72,19 @@
 
 == Changelog ==
 
+= 1.0.0-beta4 =
+
+**Enhancements**
+
+* Add `declare( strict_types = 1 )` to all PHP files. ([2424](https://github.com/WordPress/performance/pull/2424))
+* Bump minimum required WordPress version to 6.9. ([2517](https://github.com/WordPress/performance/pull/2517))
+* Bump minimum required PHP version from 7.2 to 7.4. ([2469](https://github.com/WordPress/performance/pull/2469))
+* Improve code quality with native property types and stricter static analysis. ([1729](https://github.com/WordPress/performance/pull/1729))
+
+**Bug Fixes**
+
+* Fix TypeScript 6 type errors in the video lazy-loading script. ([2434](https://github.com/WordPress/performance/pull/2434))
+
 = 1.0.0-beta3 =
 
 **Enhancements**

@westonruter

westonruter commented Aug 22, 2026

Copy link
Copy Markdown
Member Author

🤖 Comment by Claude Opus 5

optimization-detective

Important

Stable tag change: 1.0.0-beta6 → 1.0.0-beta7

svn status:

M       build/web-vitals-attribution.js
?       build/web-vitals-attribution.js.map
M       build/web-vitals.asset.php
M       build/web-vitals.js
?       build/web-vitals.js.map
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-loader.min.js
M       detect.js
M       detect.min.js
M       detection.php
M       helper.php
M       hooks.php
M       load.php
M       optimization.php
M       readme.txt
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 3661233)
+++ build/web-vitals-attribution.js	(working copy)
(Built file content suppressed.)
Index: build/web-vitals.asset.php
===================================================================
--- build/web-vitals.asset.php	(revision 3661233)
+++ 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' => '6.1.0');
\ No newline at end of file
Index: build/web-vitals.js
===================================================================
--- build/web-vitals.js	(revision 3661233)
+++ build/web-vitals.js	(working copy)
(Built file content suppressed.)
Index: class-od-data-validation-exception.php
===================================================================
--- class-od-data-validation-exception.php	(revision 3661233)
+++ 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 3661233)
+++ 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 3661233)
+++ 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 3661233)
+++ 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 3661233)
+++ 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 3661233)
+++ 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 3661233)
+++ 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 3661233)
+++ 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 3661233)
+++ 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 3661233)
+++ 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 3661233)
+++ 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 3661233)
+++ 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 3661233)
+++ 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' ) {
@@ -39,7 +43,7 @@
 		);
 	}
 
-	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-loader.min.js
===================================================================
--- detect-loader.min.js	(revision 3661233)
+++ detect-loader.min.js	(working copy)
(Built file content suppressed.)
Index: detect.js
===================================================================
--- detect.js	(revision 3661233)
+++ 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: detect.min.js
===================================================================
--- detect.min.js	(revision 3661233)
+++ detect.min.js	(working copy)
(Built file content suppressed.)
Index: detection.php
===================================================================
--- detection.php	(revision 3661233)
+++ 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.
@@ -86,6 +88,11 @@
 	 */
 	$use_attribution_build = (bool) apply_filters( 'od_use_web_vitals_attribution_build', false );
 
+	/**
+	 * Lib data for web-vitals.
+	 *
+	 * @var array{ version: non-empty-string, dependencies: list<non-empty-string> } $web_vitals_lib_data
+	 */
 	$web_vitals_lib_data = require __DIR__ . '/build/web-vitals.asset.php';
 	$web_vitals_lib_src  = $use_attribution_build ?
 		plugins_url( 'build/web-vitals-attribution.js', __FILE__ ) :
@@ -133,13 +140,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 3661233)
+++ 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 3661233)
+++ 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 3661233)
+++ load.php	(working copy)
@@ -3,9 +3,9 @@
  * 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
- * Version: 1.0.0-beta6
+ * Requires at least: 6.9
+ * Requires PHP: 7.4
+ * Version: 1.0.0-beta7
  * Author: WordPress Performance Team
  * Author URI: https://make.wordpress.org/performance/
  * License: GPLv2 or later
@@ -15,6 +15,8 @@
  * @package optimization-detective
  */
 
+declare( strict_types = 1 );
+
 // @codeCoverageIgnoreStart
 if ( ! defined( 'ABSPATH' ) ) {
 	exit; // Exit if accessed directly.
@@ -71,7 +73,7 @@
 	}
 )(
 	'optimization_detective_pending_plugin',
-	'1.0.0-beta6',
+	'1.0.0-beta7',
 	static function ( string $version ): void {
 		if ( defined( 'OPTIMIZATION_DETECTIVE_VERSION' ) ) {
 			return;
@@ -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 3661233)
+++ 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: readme.txt
===================================================================
--- readme.txt	(revision 3661233)
+++ readme.txt	(working copy)
@@ -1,8 +1,8 @@
 === Optimization Detective ===
 
 Contributors: wordpressdotorg
-Tested up to: 7.0
-Stable tag:   1.0.0-beta6
+Tested up to: 7.1
+Stable tag:   1.0.0-beta7
 License:      GPLv2 or later
 License URI:  https://www.gnu.org/licenses/gpl-2.0.html
 Tags:         performance, optimization, rum
@@ -55,6 +55,19 @@
 
 == Changelog ==
 
+= 1.0.0-beta7 =
+
+**Enhancements**
+
+* Add `declare( strict_types = 1 )` to all PHP files. ([2424](https://github.com/WordPress/performance/pull/2424))
+* Bump minimum required WordPress version to 6.9. ([2517](https://github.com/WordPress/performance/pull/2517))
+* Bump minimum required PHP version from 7.2 to 7.4. ([2469](https://github.com/WordPress/performance/pull/2469))
+* Improve code quality with native property types and stricter static analysis. ([1729](https://github.com/WordPress/performance/pull/1729))
+* Bump the bundled web-vitals library from 5.1.0 to 6.1.0, including its 6.0.0 major release. ([2606](https://github.com/WordPress/performance/pull/2606), [2629](https://github.com/WordPress/performance/pull/2629))
+* Ship the source maps for the bundled web-vitals library, which it began publishing in 6.0.0.
+* Move URL Metrics storage HMAC validation into the REST endpoint callback so it runs once all parameters have been validated and sanitized, returning a `rest_invalid_param` error. ([2436](https://github.com/WordPress/performance/pull/2436))
+* Remove the deprecated constants along with the `deprecated.php` file that contained them.
+
 = 1.0.0-beta6 =
 
 **Security**
Index: site-health.php
===================================================================
--- site-health.php	(revision 3661233)
+++ 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 3661233)
+++ 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 3661233)
+++ 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 3661233)
+++ 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 3661233)
+++ 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 3661233)
+++ 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 3661233)
+++ 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();
 	}

@westonruter

Copy link
Copy Markdown
Member Author

🤖 Comment by Claude Opus 5

performance-lab

Warning

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

svn status:

M       readme.txt
svn diff
Index: readme.txt
===================================================================
--- readme.txt	(revision 3661224)
+++ readme.txt	(working copy)
@@ -1,7 +1,7 @@
 === Performance Lab ===
 
 Contributors: wordpressdotorg
-Tested up to: 7.0
+Tested up to: 7.1
 Stable tag:   4.2.0
 License:      GPLv2 or later
 License URI:  https://www.gnu.org/licenses/gpl-2.0.html

@westonruter

Copy link
Copy Markdown
Member Author

🤖 Comment by Claude Opus 5

speculation-rules

Important

Stable tag change: 1.6.0 → 1.7.0

svn status:

M       class-plsr-url-pattern-prefixer.php
M       hooks.php
M       load.php
M       plugin-api.php
M       readme.txt
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 3661224)
+++ 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 3661224)
+++ 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 3661224)
+++ load.php	(working copy)
@@ -3,9 +3,9 @@
  * 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
- * Version: 1.6.0
+ * Requires at least: 6.9
+ * Requires PHP: 7.4
+ * Version: 1.7.0
  * Author: WordPress Performance Team
  * Author URI: https://make.wordpress.org/performance/
  * License: GPLv2 or later
@@ -15,6 +15,8 @@
  * @package speculation-rules
  */
 
+declare( strict_types = 1 );
+
 // @codeCoverageIgnoreStart
 if ( ! defined( 'ABSPATH' ) ) {
 	exit; // Exit if accessed directly.
@@ -59,13 +61,13 @@
 			// 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.
 		}
 	}
 )(
 	'plsr_pending_plugin_info',
-	'1.6.0',
+	'1.7.0',
 	static function ( string $version ): void {
 
 		// Define the constant.
Index: plugin-api.php
===================================================================
--- plugin-api.php	(revision 3661224)
+++ 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: readme.txt
===================================================================
--- readme.txt	(revision 3661224)
+++ readme.txt	(working copy)
@@ -1,8 +1,8 @@
 === Speculative Loading ===
 
 Contributors: wordpressdotorg
-Tested up to: 7.0
-Stable tag:   1.6.0
+Tested up to: 7.1
+Stable tag:   1.7.0
 License:      GPLv2 or later
 License URI:  https://www.gnu.org/licenses/gpl-2.0.html
 Tags:         performance, javascript, speculation rules, prerender, prefetch
@@ -122,6 +122,16 @@
 
 == Changelog ==
 
+= 1.7.0 =
+
+**Enhancements**
+
+* Add `declare( strict_types = 1 )` to all PHP files. ([2424](https://github.com/WordPress/performance/pull/2424))
+* Bump minimum required WordPress version to 6.9. ([2517](https://github.com/WordPress/performance/pull/2517))
+* Bump minimum required PHP version from 7.2 to 7.4. ([2469](https://github.com/WordPress/performance/pull/2469))
+* Improve code quality with native property types and stricter static analysis. ([1729](https://github.com/WordPress/performance/pull/1729))
+* Harden the JSON encoding of the inline speculation rules script and add a `sourceURL` to the authentication admin notice script. ([2169](https://github.com/WordPress/performance/pull/2169))
+
 = 1.6.0 =
 
 **Enhancements**
Index: settings.php
===================================================================
--- settings.php	(revision 3661224)
+++ 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 3661224)
+++ 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 3661224)
+++ 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.

@westonruter

Copy link
Copy Markdown
Member Author

🤖 Comment by Claude Opus 5

view-transitions

Warning

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

svn status:

M       css/view-transition-animation-wipe.min.css
M       readme.txt
svn diff
Index: css/view-transition-animation-wipe.min.css
===================================================================
--- css/view-transition-animation-wipe.min.css	(revision 3661224)
+++ css/view-transition-animation-wipe.min.css	(working copy)
(Built file content suppressed.)
Index: readme.txt
===================================================================
--- readme.txt	(revision 3661224)
+++ readme.txt	(working copy)
@@ -1,7 +1,7 @@
 === View Transitions ===
 
 Contributors: wordpressdotorg
-Tested up to: 7.0
+Tested up to: 7.1
 Stable tag:   1.2.1
 License:      GPLv2 or later
 License URI:  https://www.gnu.org/licenses/gpl-2.0.html

@westonruter

Copy link
Copy Markdown
Member Author

🤖 Comment by Claude Opus 5

web-worker-offloading

Important

Stable tag change: 0.2.1 → 0.3.0

svn status:

M       helper.php
M       hooks.php
M       load.php
M       readme.txt
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 3661224)
+++ 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 3661224)
+++ 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 3661224)
+++ load.php	(working copy)
@@ -3,9 +3,9 @@
  * 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
- * Version: 0.2.1
+ * Requires at least: 6.9
+ * Requires PHP: 7.4
+ * Version: 0.3.0
  * Author: WordPress Performance Team
  * Author URI: https://make.wordpress.org/performance/
  * License: GPLv2 or later
@@ -15,6 +15,8 @@
  * @package web-worker-offloading
  */
 
+declare( strict_types = 1 );
+
 // @codeCoverageIgnoreStart
 if ( ! defined( 'ABSPATH' ) ) {
 	exit; // Exit if accessed directly.
@@ -43,7 +45,7 @@
 	);
 }
 
-define( 'WEB_WORKER_OFFLOADING_VERSION', '0.2.1' );
+define( 'WEB_WORKER_OFFLOADING_VERSION', '0.3.0' );
 
 require_once __DIR__ . '/helper.php';
 require_once __DIR__ . '/hooks.php';
Index: readme.txt
===================================================================
--- readme.txt	(revision 3661224)
+++ readme.txt	(working copy)
@@ -1,8 +1,8 @@
 === Web Worker Offloading ===
 
 Contributors: wordpressdotorg
-Tested up to: 7.0
-Stable tag:   0.2.1
+Tested up to: 7.1
+Stable tag:   0.3.0
 License:      GPLv2 or later
 License URI:  https://www.gnu.org/licenses/gpl-2.0.html
 Tags:         performance, JavaScript, web worker, partytown, analytics
@@ -94,6 +94,15 @@
 
 == Changelog ==
 
+= 0.3.0 =
+
+**Enhancements**
+
+* Add `declare( strict_types = 1 )` to all PHP files. ([2424](https://github.com/WordPress/performance/pull/2424))
+* Bump minimum required WordPress version to 6.9. ([2517](https://github.com/WordPress/performance/pull/2517))
+* Bump minimum required PHP version from 7.2 to 7.4. ([2469](https://github.com/WordPress/performance/pull/2469))
+* Improve code quality with native property types and stricter static analysis. ([1729](https://github.com/WordPress/performance/pull/1729))
+
 = 0.2.1 =
 
 * Intend to sunset. ([2404](https://github.com/WordPress/performance/pull/2404))
Index: third-party/google-site-kit.php
===================================================================
--- third-party/google-site-kit.php	(revision 3661224)
+++ 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 3661224)
+++ 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 3661224)
+++ 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 3661224)
+++ 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' );

@westonruter

Copy link
Copy Markdown
Member Author

🤖 Comment by Claude Opus 5

webp-uploads

Warning

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

svn status:

M       readme.txt
svn diff
Index: readme.txt
===================================================================
--- readme.txt	(revision 3661225)
+++ readme.txt	(working copy)
@@ -1,7 +1,7 @@
 === Modern Image Formats ===
 
 Contributors: wordpressdotorg
-Tested up to: 7.0
+Tested up to: 7.1
 Stable tag:   2.7.1
 License:      GPLv2 or later
 License URI:  https://www.gnu.org/licenses/gpl-2.0.html

web-vitals began publishing source maps in 6.0.0, and emits a sourceMappingURL
comment alongside them. That reference is correct inside the package, where the
map sits next to the bundle, but the webpack config copies only the bundles out
of node_modules. The upgrade would therefore have shipped two files pointing at
maps that do not exist, making browsers request a URL that 404s whenever devtools
is open.

Copy the maps too, rather than stripping the comment. Upstream publishes them
deliberately for exactly this case, and Optimization Detective is a plugin whose
entire purpose is measuring real user performance, so readable web-vitals frames
are worth having when debugging it or an extension built on it. Both maps carry
sourcesContent, so they are self-contained and need nothing else fetched. The
Gutenberg plugin ships source maps for the same reason; WordPress core does not,
but core is not distributed as a plugin.

The cost is 49 KB on a 106 KB zip, and nothing at runtime: browsers only request
a map once devtools is open, so no visitor ever pays for it.

Two renames had to be handled, since web-vitals.attribution.js is copied in as
web-vitals-attribution.js. Its map is now named to match, the sourceMappingURL
comment in the bundle is repointed at the new name, and the map's own "file"
field is repointed at the renamed bundle. Both transformers are written as
factories over a file name rather than hardcoded, since any vendored bundle
renamed on the way in needs the same treatment.

Note that the two added .map files are visible in the pending release diff as
untracked additions only because generated assets are now copied and suppressed
rather than excluded. Under the previous exclusion they would have been added to
the plugin without appearing anywhere in the report.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

@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.

Nifty!

@westonruter

Copy link
Copy Markdown
Member Author

Quick way to install the above plugin builds in Bash via WP-CLI:

urls=(
  "https://github.com/user-attachments/files/31340635/auto-sizes.zip"
  "https://github.com/user-attachments/files/31340637/dominant-color-images.zip"
  "https://github.com/user-attachments/files/31340638/embed-optimizer.zip"
  "https://github.com/user-attachments/files/31340639/image-prioritizer.zip"
  "https://github.com/user-attachments/files/31340640/optimization-detective.zip"
  "https://github.com/user-attachments/files/31340641/performance-lab.zip"
  "https://github.com/user-attachments/files/31340642/speculation-rules.zip"
  "https://github.com/user-attachments/files/31340643/view-transitions.zip"
  "https://github.com/user-attachments/files/31340644/web-worker-offloading.zip"
  "https://github.com/user-attachments/files/31340645/webp-uploads.zip"
)

for url in "${urls[@]}"; do
  wp plugin install --force "$url"
done

@b1ink0

b1ink0 commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

Smoke-tested the following plugins, and they are working correctly on WordPress 7.1:

  1. Modern Image Formats
  2. View Transitions
  3. Speculative Loading
  4. Image Placeholders

@westonruter

westonruter commented Aug 26, 2026

Copy link
Copy Markdown
Member Author

I smoke tested:

  1. Optimization Detective
  2. Image Prioritizer
  3. Embed Optimizer
  4. Enhanced Responsive Images
  5. Image Placeholders

I did not test Web Worker Offloading as this is being sunset.

@westonruter

westonruter commented Aug 26, 2026

Copy link
Copy Markdown
Member Author

🤖 Comment by Claude Opus 5

web-vitals 5.1.0 → 6.1.0: back-compat review and runtime verification

Optimization Detective's bundled web-vitals library crosses a major version in this release, so the upgrade was reviewed against the v6 upgrade guide and then verified at runtime. No back-compat breakage was found for Optimization Detective or its extensions.

Breaking changes assessed

The v6 guide lists four changes relevant to consumers.

Soft Navigation reporting. This was the main concern, since a soft navigation could produce an LCP belonging to a different URL than the one a URL Metric is being built for. It turns out to be opt-in through the reportSoftNavs: true reporting option, which is not passed anywhere in this repository, so the behaviour stays off. Worth recording for anyone who enables it later: under soft navigations TTFB reports as 0, and elements persisting across navigations do not count because they are not repainted.

requestIdleCallback capped to 1 second. This one genuinely applies. The guide warns that metrics "may be reported more often with the reportAllChanges option ... when previously they were not reported", and reportAllChanges: true is used both in optimization-detective/detect.js and in image-prioritizer/detect.js. Both absorb the extra reports correctly:

  • Optimization Detective accumulates into lcpMetricCandidates[] and reads [ length - 1 ], so additional reports simply move the winner forward.
  • Image Prioritizer runs handleLCPMetric() on every report, which calls extendRootData(). That is Object.assign( urlMetric, properties ) followed by a debounced compress, so repeat calls overwrite rather than accumulate or throw.

includeProcessedEventEntries default flipped to false. There is no use of processedEventEntries, interactionTarget, or INPAttribution anywhere under plugins/, so the changed default has no effect.

Types exported as explicit types. The guide notes this does not cover the functions, and types.ts imports only functions, deriving types via typeof. npm run tsc passes against 6.1.0.

Because the jump is 5.1.0 to 6.1.0 rather than a single major, the intermediate 5.2.0 and 5.3.0 entries were swept as well. Two touch these code paths and both are safe: using LargestContentfulPaint.id as a fallback when the element is removed from the DOM (#676) is already guarded by Image Prioritizer's ! ( entry.element instanceof HTMLElement ) check, and the removed getFirstHiddenTimePolyfill (#729) is unused. The exports of both bundled builds are unchanged at onCLS, onFCP, onINP, onLCP, onTTFB plus the threshold constants.

Runtime verification

Static review cannot show that metrics are still actually collected, and the reporting timing did change, so collection was exercised against a local WordPress 7.1 site running the branch builds. Both bundles were covered, since od_use_web_vitals_attribution_build selects between them and only one is loaded per request.

Viewport Build LCP element Elements intersectionRatio
360×640 attribution IMG 300×200 3 1
2768×977 standard IMG 272×178 3 1
500×915 standard IMG 425×283 3 1

Each stored URL Metric has exactly one element flagged isLCP, which means the onLCP callback fired, the candidate array was populated, and last-candidate selection resolved to a real element. That is the timing-sensitive path the requestIdleCallback cap could have disturbed. Every metric also carries root extension data (queryVars, userAgent), confirming that the repeatedly-called extendRootData() neither threw nor corrupted the metric. Distinct LCP elements were resolved per breakpoint rather than a duplicate being reused.

The release notes were the last step still requiring milestones, and it failed
quietly rather than loudly. Only one of the seven plugins being released has a
dated milestone, so prepare-release-notes produced notes for that one alone, and
create-draft-release checked only that the notes file was non-empty. A draft
release would therefore have been created covering one plugin out of seven, with
nothing to indicate the other six were missing.

The milestone was never the source of the content: getReadmeChangelogEntry()
already reads each plugin's changelog from its readme.txt, and the milestone only
decided which plugins to include. So accept plugin slugs as positional arguments
and skip the lookup entirely when any are given, matching what bump-versions
already does.

There is deliberately no option to select every plugin here, unlike bump-versions
where one exists but is rarely useful. A plugin that is not being released still
has a changelog entry for its current stable tag, so including it would repeat an
already-published entry in the new release notes.

When plugins are named explicitly, a failure on any of them is now fatal and
nothing at all is written, rather than the previous per-plugin tolerance emitting
a subset. Asking for specific plugins and silently getting fewer is the exact
failure this commit exists to prevent. Milestone selection keeps the old
behaviour, since there the set was never asserted by hand.

create-draft-release forwards any slugs through, then verifies each one is present
in the generated notes before creating the draft. Where no slugs are given it now
lists which plugins the notes actually cover, so an omission is visible rather
than implied by silence.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@westonruter
westonruter merged commit a9e9dce into release/2026-08-18 Aug 26, 2026
35 checks passed
@westonruter
westonruter deleted the publish/2026-08-18 branch August 26, 2026 04:29
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.

5 participants