Skip to content

Conversation

@westonruter
Copy link
Member

@westonruter westonruter commented Oct 21, 2024

  • Bump versions
  • Populate since version tags
  • Generate changelogs

The following plugins are slated for release:

  1. embed-optimizer v0.3.0
  2. image-prioritizer v0.2.0
  3. optimization-detective v0.7.0
  4. performance-lab v3.5.0
  5. web-worker-offloading v0.1.1

Fixes #1608.

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

Pending Release Diffs

auto-sizes

Note

No changes.

dominant-color-images

Note

No changes.

embed-optimizer

Important

Stable tag change: 0.2.0 → 0.3.0

svn status:

M       class-embed-optimizer-tag-visitor.php
?       detect.js
M       hooks.php
?       lazy-load.js
M       load.php
M       readme.txt
svn diff
Index: class-embed-optimizer-tag-visitor.php
===================================================================
--- class-embed-optimizer-tag-visitor.php	(revision 3172589)
+++ class-embed-optimizer-tag-visitor.php	(working copy)
@@ -14,6 +14,8 @@
 /**
  * Tag visitor that optimizes embeds.
  *
+ * @phpstan-import-type DOMRect from OD_URL_Metric
+ *
  * @since 0.2.0
  * @access private
  */
@@ -27,95 +29,248 @@
 	protected $added_lazy_script = false;
 
 	/**
+	 * Determines whether the processor is currently at a figure.wp-block-embed tag.
+	 *
+	 * @since 0.3.0
+	 *
+	 * @param OD_HTML_Tag_Processor $processor Processor.
+	 * @return bool Whether at the tag.
+	 */
+	private function is_embed_figure( OD_HTML_Tag_Processor $processor ): bool {
+		return (
+			'FIGURE' === $processor->get_tag()
+			&&
+			true === $processor->has_class( 'wp-block-embed' )
+		);
+	}
+
+	/**
+	 * Determines whether the processor is currently at a div.wp-block-embed__wrapper tag (which is a child of figure.wp-block-embed).
+	 *
+	 * @since 0.3.0
+	 *
+	 * @param OD_HTML_Tag_Processor $processor Processor.
+	 * @return bool Whether the tag should be measured and stored in URL metrics.
+	 */
+	private function is_embed_wrapper( OD_HTML_Tag_Processor $processor ): bool {
+		return (
+			'DIV' === $processor->get_tag()
+			&&
+			true === $processor->has_class( 'wp-block-embed__wrapper' )
+		);
+	}
+
+	/**
 	 * Visits a tag.
 	 *
+	 * This visitor has two entry points, the `figure.wp-block-embed` tag and its child the `div.wp-block-embed__wrapper`
+	 * tag. For example:
+	 *
+	 *     <figure class="wp-block-embed is-type-video is-provider-wordpress-tv wp-block-embed-wordpress-tv wp-embed-aspect-16-9 wp-has-aspect-ratio">
+	 *         <div class="wp-block-embed__wrapper">
+	 *             <iframe title="VideoPress Video Player" aria-label='VideoPress Video Player' width='750' height='422' src='https://video.wordpress.com/embed/vaWm9zO6?hd=1&amp;cover=1' frameborder='0' allowfullscreen allow='clipboard-write'></iframe>
+	 *             <script src='https://v0.wordpress.com/js/next/videopress-iframe.js?m=1674852142'></script>
+	 *         </div>
+	 *     </figure>
+	 *
+	 * For the `div.wp-block-embed__wrapper` tag, the only thing this tag visitor does is flag it for tracking in URL
+	 * Metrics (by returning true). When visiting the parent `figure.wp-block-embed` tag, it does all the actual
+	 * processing. In particular, it will use the element metrics gathered for the child `div.wp-block-embed__wrapper`
+	 * element to set the min-height style on the `figure.wp-block-embed` to avoid layout shifts. Additionally, when
+	 * the embed is in the initial viewport for any breakpoint, it will add preconnect links for key resources.
+	 * Otherwise, if the embed is not in any initial viewport, it will add lazy-loading logic.
+	 *
 	 * @since 0.2.0
 	 *
 	 * @param OD_Tag_Visitor_Context $context Tag visitor context.
-	 * @return bool Whether the visit or visited the tag.
+	 * @return bool Whether the tag should be tracked in URL metrics.
 	 */
 	public function __invoke( OD_Tag_Visitor_Context $context ): bool {
 		$processor = $context->processor;
-		if ( ! (
-			'FIGURE' === $processor->get_tag()
-			&&
-			$processor->has_class( 'wp-block-embed' )
-		) ) {
+
+		/*
+		 * The only thing we need to do if it is a div.wp-block-embed__wrapper tag is return true so that the tag
+		 * will get measured and stored in the URL Metrics.
+		 */
+		if ( $this->is_embed_wrapper( $processor ) ) {
+			return true;
+		}
+
+		// Short-circuit if not a figure.wp-block-embed tag.
+		if ( ! $this->is_embed_figure( $processor ) ) {
 			return false;
 		}
 
-		$max_intersection_ratio = $context->url_metrics_group_collection->get_element_max_intersection_ratio( $processor->get_xpath() );
+		$this->reduce_layout_shifts( $context );
 
-		if ( $max_intersection_ratio > 0 ) {
-			/*
-			 * The following embeds have been chosen for optimization due to their relative popularity among all embed types.
-			 * See <https://colab.sandbox.google.com/drive/1nSpg3qoCLY-cBTV2zOUkgUCU7R7X2f_R?resourcekey=0-MgT7Ur0pT__vw-5_AHjgWQ#scrollTo=utZv59sXzXvS>.
-			 * The list of hosts being preconnected to was obtained by inserting an embed into a post and then looking
-			 * at the network log on the frontend as the embed renders. Each should include the host of the iframe src
-			 * as well as URLs for assets used by the embed, _if_ the URL looks like it is not geotargeted (e.g. '-us')
-			 * or load-balanced (e.g. 's0.example.com'). For the load balancing case, attempt to load the asset by
-			 * incrementing the number appearing in the subdomain (e.g. s1.example.com). If the asset still loads, then
-			 * it is a likely case of a load balancing domain name which cannot be safely preconnected since it could
-			 * not end up being the load balanced domain used for the embed. Lastly, these domains are only for the URLs
-			 * for GET requests, as POST requests are not likely to be part of the critical rendering path.
+		// Preconnect links and lazy-loading can only be done once there are URL metrics collected for both mobile and desktop.
+		if (
+			$context->url_metric_group_collection->get_first_group()->count() > 0
+			&&
+			$context->url_metric_group_collection->get_last_group()->count() > 0
+		) {
+			$max_intersection_ratio = $context->url_metric_group_collection->get_element_max_intersection_ratio( self::get_embed_wrapper_xpath( $processor->get_xpath() ) );
+			if ( $max_intersection_ratio > 0 ) {
+				/*
+				 * The following embeds have been chosen for optimization due to their relative popularity among all embed types.
+				 * See <https://colab.sandbox.google.com/drive/1nSpg3qoCLY-cBTV2zOUkgUCU7R7X2f_R?resourcekey=0-MgT7Ur0pT__vw-5_AHjgWQ#scrollTo=utZv59sXzXvS>.
+				 * The list of hosts being preconnected to was obtained by inserting an embed into a post and then looking
+				 * at the network log on the frontend as the embed renders. Each should include the host of the iframe src
+				 * as well as URLs for assets used by the embed, _if_ the URL looks like it is not geotargeted (e.g. '-us')
+				 * or load-balanced (e.g. 's0.example.com'). For the load balancing case, attempt to load the asset by
+				 * incrementing the number appearing in the subdomain (e.g. s1.example.com). If the asset still loads, then
+				 * it is a likely case of a load balancing domain name which cannot be safely preconnected since it could
+				 * not end up being the load balanced domain used for the embed. Lastly, these domains are only for the URLs
+				 * for GET requests, as POST requests are not likely to be part of the critical rendering path.
+				 */
+				$preconnect_hrefs = array();
+				$has_class        = static function ( string $wanted_class ) use ( $processor ): bool {
+					return true === $processor->has_class( $wanted_class );
+				};
+				if ( $has_class( 'wp-block-embed-youtube' ) ) {
+					$preconnect_hrefs[] = 'https://www.youtube.com';
+					$preconnect_hrefs[] = 'https://i.ytimg.com';
+				} elseif ( $has_class( 'wp-block-embed-twitter' ) ) {
+					$preconnect_hrefs[] = 'https://syndication.twitter.com';
+					$preconnect_hrefs[] = 'https://pbs.twimg.com';
+				} elseif ( $has_class( 'wp-block-embed-vimeo' ) ) {
+					$preconnect_hrefs[] = 'https://player.vimeo.com';
+					$preconnect_hrefs[] = 'https://f.vimeocdn.com';
+					$preconnect_hrefs[] = 'https://i.vimeocdn.com';
+				} elseif ( $has_class( 'wp-block-embed-spotify' ) ) {
+					$preconnect_hrefs[] = 'https://apresolve.spotify.com';
+					$preconnect_hrefs[] = 'https://embed-cdn.spotifycdn.com';
+					$preconnect_hrefs[] = 'https://encore.scdn.co';
+					$preconnect_hrefs[] = 'https://i.scdn.co';
+				} elseif ( $has_class( 'wp-block-embed-videopress' ) || $has_class( 'wp-block-embed-wordpress-tv' ) ) {
+					$preconnect_hrefs[] = 'https://video.wordpress.com';
+					$preconnect_hrefs[] = 'https://public-api.wordpress.com';
+					$preconnect_hrefs[] = 'https://videos.files.wordpress.com';
+					$preconnect_hrefs[] = 'https://v0.wordpress.com'; // This does not appear to be a load-balanced domain since v1.wordpress.com is not valid.
+				} elseif ( $has_class( 'wp-block-embed-instagram' ) ) {
+					$preconnect_hrefs[] = 'https://www.instagram.com';
+					$preconnect_hrefs[] = 'https://static.cdninstagram.com';
+					$preconnect_hrefs[] = 'https://scontent.cdninstagram.com';
+				} elseif ( $has_class( 'wp-block-embed-tiktok' ) ) {
+					$preconnect_hrefs[] = 'https://www.tiktok.com';
+					// Note: The other domains used for TikTok embeds include https://lf16-tiktok-web.tiktokcdn-us.com,
+					// https://lf16-cdn-tos.tiktokcdn-us.com, and https://lf16-tiktok-common.tiktokcdn-us.com among others
+					// which either appear to be geo-targeted ('-us') _or_ load-balanced ('lf16'). So these are not added
+					// to the preconnected hosts.
+				} elseif ( $has_class( 'wp-block-embed-amazon' ) ) {
+					$preconnect_hrefs[] = 'https://read.amazon.com';
+					$preconnect_hrefs[] = 'https://m.media-amazon.com';
+				} elseif ( $has_class( 'wp-block-embed-soundcloud' ) ) {
+					$preconnect_hrefs[] = 'https://w.soundcloud.com';
+					$preconnect_hrefs[] = 'https://widget.sndcdn.com';
+					// Note: There is also https://i1.sndcdn.com which is for the album art, but the '1' indicates it may be geotargeted/load-balanced.
+				} elseif ( $has_class( 'wp-block-embed-pinterest' ) ) {
+					$preconnect_hrefs[] = 'https://assets.pinterest.com';
+					$preconnect_hrefs[] = 'https://widgets.pinterest.com';
+					$preconnect_hrefs[] = 'https://i.pinimg.com';
+				}
+
+				foreach ( $preconnect_hrefs as $preconnect_href ) {
+					$context->link_collection->add_link(
+						array(
+							'rel'  => 'preconnect',
+							'href' => $preconnect_href,
+						)
+					);
+				}
+			} elseif ( embed_optimizer_update_markup( $processor, false ) && ! $this->added_lazy_script ) {
+				$processor->append_body_html( wp_get_inline_script_tag( embed_optimizer_get_lazy_load_script(), array( 'type' => 'module' ) ) );
+				$this->added_lazy_script = true;
+			}
+		}
+
+		/*
+		 * At this point the tag is a figure.wp-block-embed, and we can return false because this does not need to be
+		 * measured and stored in URL Metrics. Only the child div.wp-block-embed__wrapper tag is measured and stored
+		 * so that this visitor can look up the height to set as a min-height on the figure.wp-block-embed. For more
+		 * information on what the return values mean for tag visitors, see <https://github.com/WordPress/performance/issues/1342>.
+		 */
+		return false;
+	}
+
+	/**
+	 * Gets the XPath for the embed wrapper DIV which is the sole child of the embed block FIGURE.
+	 *
+	 * @since 0.3.0
+	 *
+	 * @param string $embed_block_xpath XPath for the embed block FIGURE tag. For example: `/*[1][self::HTML]/*[2][self::BODY]/*[1][self::FIGURE]`.
+	 * @return string XPath for the child DIV. For example: `/*[1][self::HTML]/*[2][self::BODY]/*[1][self::FIGURE]/*[1][self::DIV]`
+	 */
+	private static function get_embed_wrapper_xpath( string $embed_block_xpath ): string {
+		return $embed_block_xpath . '/*[1][self::DIV]';
+	}
+
+	/**
+	 * Reduces layout shifts.
+	 *
+	 * @since 0.3.0
+	 *
+	 * @param OD_Tag_Visitor_Context $context Tag visitor context, with the cursor currently at an embed block.
+	 */
+	private function reduce_layout_shifts( OD_Tag_Visitor_Context $context ): void {
+		$processor           = $context->processor;
+		$embed_wrapper_xpath = self::get_embed_wrapper_xpath( $processor->get_xpath() );
+
+		/**
+		 * 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
+		 */
+		$minimums = array();
+
+		$elements = $context->url_metric_group_collection->get_xpath_elements_map()[ $embed_wrapper_xpath ] ?? array();
+		foreach ( $elements as $element ) {
+			/**
+			 * Resized bounding client rect.
+			 *
+			 * @var DOMRect|null $resized_bounding_client_rect
 			 */
-			$preconnect_hrefs = array();
-			if ( $processor->has_class( 'wp-block-embed-youtube' ) ) {
-				$preconnect_hrefs[] = 'https://www.youtube.com';
-				$preconnect_hrefs[] = 'https://i.ytimg.com';
-			} elseif ( $processor->has_class( 'wp-block-embed-twitter' ) ) {
-				$preconnect_hrefs[] = 'https://syndication.twitter.com';
-				$preconnect_hrefs[] = 'https://pbs.twimg.com';
-			} elseif ( $processor->has_class( 'wp-block-embed-vimeo' ) ) {
-				$preconnect_hrefs[] = 'https://player.vimeo.com';
-				$preconnect_hrefs[] = 'https://f.vimeocdn.com';
-				$preconnect_hrefs[] = 'https://i.vimeocdn.com';
-			} elseif ( $processor->has_class( 'wp-block-embed-spotify' ) ) {
-				$preconnect_hrefs[] = 'https://apresolve.spotify.com';
-				$preconnect_hrefs[] = 'https://embed-cdn.spotifycdn.com';
-				$preconnect_hrefs[] = 'https://encore.scdn.co';
-				$preconnect_hrefs[] = 'https://i.scdn.co';
-			} elseif ( $processor->has_class( 'wp-block-embed-videopress' ) || $processor->has_class( 'wp-block-embed-wordpress-tv' ) ) {
-				$preconnect_hrefs[] = 'https://video.wordpress.com';
-				$preconnect_hrefs[] = 'https://public-api.wordpress.com';
-				$preconnect_hrefs[] = 'https://videos.files.wordpress.com';
-				$preconnect_hrefs[] = 'https://v0.wordpress.com'; // This does not appear to be a load-balanced domain since v1.wordpress.com is not valid.
-			} elseif ( $processor->has_class( 'wp-block-embed-instagram' ) ) {
-				$preconnect_hrefs[] = 'https://www.instagram.com';
-				$preconnect_hrefs[] = 'https://static.cdninstagram.com';
-				$preconnect_hrefs[] = 'https://scontent.cdninstagram.com';
-			} elseif ( $processor->has_class( 'wp-block-embed-tiktok' ) ) {
-				$preconnect_hrefs[] = 'https://www.tiktok.com';
-				// Note: The other domains used for TikTok embeds include https://lf16-tiktok-web.tiktokcdn-us.com,
-				// https://lf16-cdn-tos.tiktokcdn-us.com, and https://lf16-tiktok-common.tiktokcdn-us.com among others
-				// which either appear to be geo-targeted ('-us') _or_ load-balanced ('lf16'). So these are not added
-				// to the preconnected hosts.
-			} elseif ( $processor->has_class( 'wp-block-embed-amazon' ) ) {
-				$preconnect_hrefs[] = 'https://read.amazon.com';
-				$preconnect_hrefs[] = 'https://m.media-amazon.com';
-			} elseif ( $processor->has_class( 'wp-block-embed-soundcloud' ) ) {
-				$preconnect_hrefs[] = 'https://w.soundcloud.com';
-				$preconnect_hrefs[] = 'https://widget.sndcdn.com';
-				// Note: There is also https://i1.sndcdn.com which is for the album art, but the '1' indicates it may be geotargeted/load-balanced.
-			} elseif ( $processor->has_class( 'wp-block-embed-pinterest' ) ) {
-				$preconnect_hrefs[] = 'https://assets.pinterest.com';
-				$preconnect_hrefs[] = 'https://widgets.pinterest.com';
-				$preconnect_hrefs[] = 'https://i.pinimg.com';
+			$resized_bounding_client_rect = $element->get( 'resizedBoundingClientRect' );
+			if ( ! is_array( $resized_bounding_client_rect ) ) {
+				continue;
 			}
+			$group = $element->get_url_metric_group();
+			if ( null === $group ) {
+				continue; // Technically could be null but in practice it never will be.
+			}
+			$group_min_width = $group->get_minimum_viewport_width();
+			if ( ! isset( $minimums[ $group_min_width ] ) ) {
+				$minimums[ $group_min_width ] = array(
+					'group'  => $group,
+					'height' => $resized_bounding_client_rect['height'],
+				);
+			} else {
+				$minimums[ $group_min_width ]['height'] = min(
+					$minimums[ $group_min_width ]['height'],
+					$resized_bounding_client_rect['height']
+				);
+			}
+		}
 
-			foreach ( $preconnect_hrefs as $preconnect_href ) {
-				$context->link_collection->add_link(
-					array(
-						'rel'  => 'preconnect',
-						'href' => $preconnect_href,
-					)
+		// Add style rules to set the min-height for each viewport group.
+		if ( count( $minimums ) > 0 ) {
+			$element_id = $processor->get_attribute( 'id' );
+			if ( ! is_string( $element_id ) ) {
+				$element_id = 'embed-optimizer-' . md5( $processor->get_xpath() );
+				$processor->set_attribute( 'id', $element_id );
+			}
+
+			$style_rules = array();
+			foreach ( $minimums as $minimum ) {
+				$style_rules[] = sprintf(
+					'@media %s { #%s { min-height: %dpx; } }',
+					od_generate_media_query( $minimum['group']->get_minimum_viewport_width(), $minimum['group']->get_maximum_viewport_width() ),
+					$element_id,
+					$minimum['height']
 				);
 			}
-		} elseif ( embed_optimizer_update_markup( $processor, false ) && ! $this->added_lazy_script ) {
-			$processor->append_body_html( wp_get_inline_script_tag( embed_optimizer_get_lazy_load_script(), array( 'type' => 'module' ) ) );
-			$this->added_lazy_script = true;
+
+			$processor->append_head_html( sprintf( "<style>\n%s\n</style>\n", join( "\n", $style_rules ) ) );
 		}
-
-		return true;
 	}
 }
Index: hooks.php
===================================================================
--- hooks.php	(revision 3172589)
+++ hooks.php	(working copy)
@@ -18,15 +18,54 @@
 function embed_optimizer_add_hooks(): void {
 	add_action( 'wp_head', 'embed_optimizer_render_generator' );
 
-	if ( defined( 'OPTIMIZATION_DETECTIVE_VERSION' ) ) {
-		add_action( 'od_register_tag_visitors', 'embed_optimizer_register_tag_visitors' );
-	} else {
-		add_filter( 'embed_oembed_html', 'embed_optimizer_filter_oembed_html' );
-	}
+	add_action( 'od_init', 'embed_optimizer_init_optimization_detective' );
+	add_action( 'wp_loaded', 'embed_optimizer_add_non_optimization_detective_hooks' );
 }
 add_action( 'init', 'embed_optimizer_add_hooks' );
 
 /**
+ * Adds hooks for when the Optimization Detective logic is not running.
+ *
+ * @since 0.3.0
+ */
+function embed_optimizer_add_non_optimization_detective_hooks(): void {
+	if ( false === has_action( 'od_register_tag_visitors', 'embed_optimizer_register_tag_visitors' ) ) {
+		add_filter( 'embed_oembed_html', 'embed_optimizer_filter_oembed_html_to_lazy_load' );
+	}
+}
+
+/**
+ * Initializes Embed Optimizer when Optimization Detective has loaded.
+ *
+ * @since 0.3.0
+ *
+ * @param string $optimization_detective_version Current version of the optimization detective plugin.
+ */
+function embed_optimizer_init_optimization_detective( string $optimization_detective_version ): void {
+	$required_od_version = '0.7.0';
+	if ( version_compare( (string) strtok( $optimization_detective_version, '-' ), $required_od_version, '<' ) ) {
+		add_action(
+			'admin_notices',
+			static function (): void {
+				global $pagenow;
+				if ( ! in_array( $pagenow, array( 'index.php', 'plugins.php' ), true ) ) {
+					return;
+				}
+				wp_admin_notice(
+					esc_html__( 'The Embed Optimizer plugin requires a newer version of the Optimization Detective plugin. Please update your plugins.', 'embed-optimizer' ),
+					array( 'type' => 'warning' )
+				);
+			}
+		);
+		return;
+	}
+
+	add_action( 'od_register_tag_visitors', 'embed_optimizer_register_tag_visitors' );
+	add_filter( 'embed_oembed_html', 'embed_optimizer_filter_oembed_html_to_detect_embed_presence' );
+	add_filter( 'od_url_metric_schema_element_item_additional_properties', 'embed_optimizer_add_element_item_schema_properties' );
+}
+
+/**
  * Registers the tag visitor for embeds.
  *
  * @since 0.2.0
@@ -40,17 +79,85 @@
 }
 
 /**
- * Filter the oEmbed HTML.
+ * Filters additional properties for the element item schema for Optimization Detective.
  *
+ * @since 0.3.0
+ *
+ * @param array<string, array{type: string}> $additional_properties 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(
+		'type'       => 'object',
+		'properties' => array_fill_keys(
+			array(
+				'width',
+				'height',
+				'x',
+				'y',
+				'top',
+				'right',
+				'bottom',
+				'left',
+			),
+			array(
+				'type'     => 'number',
+				'required' => true,
+			)
+		),
+	);
+	return $additional_properties;
+}
+
+/**
+ * Filters the list of Optimization Detective extension module URLs to include the extension for Embed Optimizer.
+ *
+ * @since 0.3.0
+ *
+ * @param string[]|mixed $extension_module_urls Extension module URLs.
+ * @return string[] Extension module URLs.
+ */
+function embed_optimizer_filter_extension_module_urls( $extension_module_urls ): array {
+	if ( ! is_array( $extension_module_urls ) ) {
+		$extension_module_urls = array();
+	}
+	$extension_module_urls[] = add_query_arg( 'ver', EMBED_OPTIMIZER_VERSION, plugin_dir_url( __FILE__ ) . 'detect.js' );
+	return $extension_module_urls;
+}
+
+/**
+ * Filter the oEmbed HTML to detect when an embed is present so that the Optimization Detective extension module can be enqueued.
+ *
+ * This ensures that the module for handling embeds is only loaded when there is an embed on the page.
+ *
+ * @since 0.3.0
+ *
+ * @param string|mixed $html The oEmbed HTML.
+ * @return string Unchanged oEmbed HTML.
+ */
+function embed_optimizer_filter_oembed_html_to_detect_embed_presence( $html ): string {
+	if ( ! is_string( $html ) ) {
+		$html = '';
+	}
+	add_filter( 'od_extension_module_urls', 'embed_optimizer_filter_extension_module_urls' );
+	return $html;
+}
+
+/**
+ * Filter the oEmbed HTML to lazy load the embed.
+ *
  * Add loading="lazy" to any iframe tags.
  * Lazy load any script tags.
  *
  * @since 0.1.0
  *
- * @param string $html The oEmbed HTML.
+ * @param string|mixed $html The oEmbed HTML.
  * @return string Filtered oEmbed HTML.
  */
-function embed_optimizer_filter_oembed_html( string $html ): string {
+function embed_optimizer_filter_oembed_html_to_lazy_load( $html ): string {
+	if ( ! is_string( $html ) ) {
+		$html = '';
+	}
 	$html_processor = new WP_HTML_Tag_Processor( $html );
 	if ( embed_optimizer_update_markup( $html_processor, true ) ) {
 		add_action( 'wp_footer', 'embed_optimizer_lazy_load_scripts' );
@@ -120,7 +227,8 @@
 
 			if ( 'IFRAME' === $html_processor->get_tag() ) {
 				$loading_value = $html_processor->get_attribute( 'loading' );
-				if ( empty( $loading_value ) ) {
+				// Per the HTML spec: "The attribute's missing value default and invalid value default are both the Eager state".
+				if ( 'lazy' !== $loading_value ) {
 					++$iframe_count;
 					if ( ! $html_processor->set_bookmark( $bookmark_names['iframe'] ) ) {
 						throw new Exception(
@@ -130,7 +238,7 @@
 					}
 				}
 			} elseif ( 'SCRIPT' === $html_processor->get_tag() ) {
-				if ( ! $html_processor->get_attribute( 'src' ) ) {
+				if ( null === $html_processor->get_attribute( 'src' ) ) {
 					$has_inline_script = true;
 				} else {
 					++$script_count;
@@ -147,7 +255,7 @@
 		if ( 1 === $script_count && ! $has_inline_script && $html_processor->has_bookmark( $bookmark_names['script'] ) ) {
 			$needs_lazy_script = true;
 			if ( $html_processor->seek( $bookmark_names['script'] ) ) {
-				if ( $html_processor->get_attribute( 'type' ) ) {
+				if ( is_string( $html_processor->get_attribute( 'type' ) ) ) {
 					$html_processor->set_attribute( 'data-original-type', $html_processor->get_attribute( 'type' ) );
 				}
 				$html_processor->set_attribute( 'type', 'application/vnd.embed-optimizer.javascript' );
@@ -166,7 +274,7 @@
 				// For post embeds, use visibility:hidden instead of clip since browsers will consistently load the
 				// lazy-loaded iframe (where Chromium is unreliably with clip) while at the same time improve accessibility
 				// by preventing links in the hidden iframe from receiving focus.
-				if ( $html_processor->has_class( 'wp-embedded-content' ) ) {
+				if ( true === $html_processor->has_class( 'wp-embedded-content' ) ) {
 					$style = $html_processor->get_attribute( 'style' );
 					if ( is_string( $style ) ) {
 						// WordPress core injects this clip CSS property:
@@ -217,44 +325,13 @@
  * @since 0.2.0
  */
 function embed_optimizer_get_lazy_load_script(): string {
-	return <<<JS
-		const lazyEmbedsScripts = document.querySelectorAll( 'script[type="application/vnd.embed-optimizer.javascript"]' );
-		const lazyEmbedScriptsByParents = new Map();
+	$script = file_get_contents( __DIR__ . '/lazy-load.js' ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- It's a local filesystem path not a remote request.
 
-		const lazyEmbedObserver = new IntersectionObserver(
-			( entries ) => {
-				for ( const entry of entries ) {
-					if ( entry.isIntersecting ) {
-						const lazyEmbedParent = entry.target;
-						const lazyEmbedScript = /** @type {HTMLScriptElement} */ lazyEmbedScriptsByParents.get( lazyEmbedParent );
-						const embedScript = document.createElement( 'script' );
-						for ( const attr of lazyEmbedScript.attributes ) {
-							if ( attr.nodeName === 'type' ) {
-								// Omit type=application/vnd.embed-optimizer.javascript type.
-								continue;
-							}
-							embedScript.setAttribute(
-								attr.nodeName === 'data-original-type' ? 'type' : attr.nodeName,
-								attr.nodeValue
-							);
-						}
-						lazyEmbedScript.replaceWith( embedScript );
-						lazyEmbedObserver.unobserve( lazyEmbedParent );
-					}
-				}
-			},
-			{
-				rootMargin: '100% 0% 100% 0%',
-				threshold: 0
-			}
-		);
+	if ( false === $script ) {
+		return '';
+	}
 
-		for ( const lazyEmbedScript of lazyEmbedsScripts ) {
-			const lazyEmbedParent = /** @type {HTMLElement} */ lazyEmbedScript.parentNode;
-			lazyEmbedScriptsByParents.set( lazyEmbedParent, lazyEmbedScript );
-			lazyEmbedObserver.observe( lazyEmbedParent );
-		}
-JS;
+	return $script;
 }
 
 /**
Index: load.php
===================================================================
--- load.php	(revision 3172589)
+++ load.php	(working copy)
@@ -5,7 +5,7 @@
  * Description: Optimizes the performance of embeds by lazy-loading iframes and scripts.
  * Requires at least: 6.5
  * Requires PHP: 7.2
- * Version: 0.2.0
+ * Version: 0.3.0
  * Author: WordPress Performance Team
  * Author URI: https://make.wordpress.org/performance/
  * License: GPLv2 or later
@@ -43,9 +43,14 @@
 				}
 			};
 
-			// Wait until after the plugins have loaded and the theme has loaded. The after_setup_theme action is used
-			// because it is the first action that fires once the theme is loaded.
-			add_action( 'after_setup_theme', $bootstrap, PHP_INT_MIN );
+			/*
+			 * Wait until after the plugins have loaded and the theme has loaded. The after_setup_theme action could be
+			 * used since it is the first action that fires once the theme is loaded. However, plugins may embed this
+			 * logic inside a module which initializes even later at the init action. The earliest action that this
+			 * plugin has hooks for is the init action at the default priority of 10 (which includes the rest_api_init
+			 * action), so this is why it gets initialized at priority 9.
+			 */
+			add_action( 'init', $bootstrap, 9 );
 		}
 
 		// Register this copy of the plugin.
@@ -65,8 +70,11 @@
 	}
 )(
 	'embed_optimizer_pending_plugin',
-	'0.2.0',
+	'0.3.0',
 	static function ( string $version ): void {
+		if ( defined( 'EMBED_OPTIMIZER_VERSION' ) ) {
+			return;
+		}
 
 		define( 'EMBED_OPTIMIZER_VERSION', $version );
 
Index: readme.txt
===================================================================
--- readme.txt	(revision 3172589)
+++ readme.txt	(working copy)
@@ -2,7 +2,7 @@
 
 Contributors: wordpressdotorg
 Tested up to: 6.6
-Stable tag:   0.2.0
+Stable tag:   0.3.0
 License:      GPLv2 or later
 License URI:  https://www.gnu.org/licenses/gpl-2.0.html
 Tags:         performance, embeds
@@ -51,6 +51,13 @@
 
 == Changelog ==
 
+= 0.3.0 =
+
+**Enhancements**
+
+* Leverage URL metrics to reserve space for embeds to reduce CLS. ([1373](https://github.com/WordPress/performance/pull/1373))
+* Moves embed-optimizer-lazy-load script to js file. ([1601](https://github.com/WordPress/performance/pull/1601))
+
 = 0.2.0 =
 
 **Enhancements**

image-prioritizer

Important

Stable tag change: 0.1.4 → 0.2.0

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
?       class-image-prioritizer-video-tag-visitor.php
M       helper.php
M       hooks.php
?       lazy-load.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 3172589)
+++ class-image-prioritizer-background-image-styled-tag-visitor.php	(working copy)
@@ -55,7 +55,7 @@
 		$xpath = $processor->get_xpath();
 
 		// If this element is the LCP (for a breakpoint group), add a preload link for it.
-		foreach ( $context->url_metrics_group_collection->get_groups_by_lcp_element( $xpath ) as $group ) {
+		foreach ( $context->url_metric_group_collection->get_groups_by_lcp_element( $xpath ) as $group ) {
 			$link_attributes = array(
 				'rel'           => 'preload',
 				'fetchpriority' => 'high',
Index: class-image-prioritizer-img-tag-visitor.php
===================================================================
--- class-image-prioritizer-img-tag-visitor.php	(revision 3172589)
+++ class-image-prioritizer-img-tag-visitor.php	(working copy)
@@ -40,19 +40,9 @@
 
 		$xpath = $processor->get_xpath();
 
-		/**
-		 * Gets attribute value.
-		 *
-		 * @param string $attribute_name Attribute name.
-		 * @return string|true|null Normalized attribute value.
-		 */
-		$get_attribute_value = static function ( string $attribute_name ) use ( $processor ) {
-			$value = $processor->get_attribute( $attribute_name );
-			if ( is_string( $value ) ) {
-				$value = strtolower( trim( $value, " \t\f\r\n" ) );
-			}
-			return $value;
-		};
+		$current_fetchpriority = $this->get_attribute_value( $processor, 'fetchpriority' );
+		$is_lazy_loaded        = 'lazy' === $this->get_attribute_value( $processor, 'loading' );
+		$updated_fetchpriority = null;
 
 		/*
 		 * When the same LCP element is common/shared among all viewport groups, make sure that the element has
@@ -59,20 +49,13 @@
 		 * fetchpriority=high, even though it won't really be needed because a preload link with fetchpriority=high
 		 * will also be added. Additionally, ensure that this common LCP element is never lazy-loaded.
 		 */
-		$common_lcp_element = $context->url_metrics_group_collection->get_common_lcp_element();
-		if ( ! is_null( $common_lcp_element ) && $xpath === $common_lcp_element['xpath'] ) {
-			if ( 'high' === $get_attribute_value( 'fetchpriority' ) ) {
-				$processor->set_meta_attribute( 'fetchpriority-already-added', true );
-			} else {
-				$processor->set_attribute( 'fetchpriority', 'high' );
-			}
+		$common_lcp_element = $context->url_metric_group_collection->get_common_lcp_element();
+		if ( $common_lcp_element instanceof OD_Element && $xpath === $common_lcp_element->get_xpath() ) {
+			$updated_fetchpriority = 'high';
 		} elseif (
-			is_string( $processor->get_attribute( 'fetchpriority' ) )
+			'high' === $current_fetchpriority
 			&&
-			// Temporary condition in case someone updates Image Prioritizer without also updating Optimization Detective.
-			method_exists( $context->url_metrics_group_collection, 'is_any_group_populated' )
-			&&
-			$context->url_metrics_group_collection->is_any_group_populated()
+			$context->url_metric_group_collection->is_any_group_populated()
 		) {
 			/*
 			 * At this point, the element is not the shared LCP across all viewport groups. Nevertheless, server-side
@@ -84,30 +67,68 @@
 			 * fetchpriority=high in such case to prevent server-side heuristics from prioritizing loading the image
 			 * which isn't actually the LCP element for actual visitors.
 			 */
-			$processor->remove_attribute( 'fetchpriority' );
+			$updated_fetchpriority = false; // That is, remove it.
 		}
 
-		$element_max_intersection_ratio = $context->url_metrics_group_collection->get_element_max_intersection_ratio( $xpath );
+		/*
+		 * Do not do any lazy-loading if the mobile and desktop viewport groups lack URL metrics. This is important
+		 * because if there is an IMG in the initial viewport on desktop but not mobile, if then there are only URL
+		 * metrics collected for mobile then the IMG will get lazy-loaded which is good for mobile but for desktop
+		 * it will hurt performance. So this is why it is important to have URL metrics collected for both desktop and
+		 * mobile to verify whether maximum intersectionRatio is accounting for both screen sizes.
+		 */
+		$element_max_intersection_ratio = $context->url_metric_group_collection->get_element_max_intersection_ratio( $xpath );
 
 		// If the element was not found, we don't know if it was visible for not, so don't do anything.
 		if ( is_null( $element_max_intersection_ratio ) ) {
 			$processor->set_meta_attribute( 'unknown-tag', true ); // Mostly useful for debugging why an IMG isn't optimized.
-		} else {
+		} elseif (
+			$context->url_metric_group_collection->get_first_group()->count() > 0
+			&&
+			$context->url_metric_group_collection->get_last_group()->count() > 0
+		) {
+			// TODO: Take into account whether the element has the computed style of visibility:hidden, in such case it should also get fetchpriority=low.
 			// Otherwise, make sure visible elements omit the loading attribute, and hidden elements include loading=lazy.
 			$is_visible = $element_max_intersection_ratio > 0.0;
-			$loading    = $get_attribute_value( 'loading' );
-			if ( $is_visible && 'lazy' === $loading ) {
-				$processor->remove_attribute( 'loading' );
-			} elseif ( ! $is_visible && 'lazy' !== $loading ) {
+			if ( true === $context->url_metric_group_collection->is_element_positioned_in_any_initial_viewport( $xpath ) ) {
+				if ( ! $is_visible ) {
+					// If an element is positioned in the initial viewport and yet it is it not visible, it may be
+					// located in a subsequent carousel slide or inside a hidden navigation menu which could be
+					// displayed at any time. Therefore, it should get fetchpriority=low so that any images which are
+					// visible can be loaded with a higher priority.
+					$updated_fetchpriority = 'low';
+
+					// Also prevent the image from being lazy-loaded (or eager-loaded) since it may be revealed at any
+					// time without the browser having any signal (e.g. user scrolling toward it) to start downloading.
+					$processor->remove_attribute( 'loading' );
+				} elseif ( $is_lazy_loaded ) {
+					// Otherwise, if the image is positioned inside any initial viewport then it should never get lazy-loaded.
+					$processor->remove_attribute( 'loading' );
+				}
+			} elseif ( ! $is_lazy_loaded && ! $is_visible ) {
+				// Otherwise, the element is not positioned in any initial viewport, so it should always get lazy-loaded.
+				// The `! $is_visible` condition should always evaluate to true since the intersectionRatio of an
+				// element positioned below the initial viewport should by definition never be visible.
 				$processor->set_attribute( 'loading', 'lazy' );
 			}
 		}
 		// TODO: If an image is visible in one breakpoint but not another, add loading=lazy AND add a regular-priority preload link with media queries (unless LCP in which case it should already have a fetchpriority=high link) so that the image won't be eagerly-loaded for viewports on which it is not shown.
 
+		// Set the fetchpriority attribute if needed.
+		if ( is_string( $updated_fetchpriority ) ) {
+			if ( $updated_fetchpriority !== $current_fetchpriority ) {
+				$processor->set_attribute( 'fetchpriority', $updated_fetchpriority );
+			} else {
+				$processor->set_meta_attribute( 'fetchpriority-already-added', true );
+			}
+		} elseif ( false === $updated_fetchpriority ) {
+			$processor->remove_attribute( 'fetchpriority' );
+		}
+
 		// Ensure that sizes=auto is set properly.
 		$sizes = $processor->get_attribute( 'sizes' );
 		if ( is_string( $sizes ) ) {
-			$is_lazy  = 'lazy' === $get_attribute_value( 'loading' );
+			$is_lazy  = 'lazy' === $this->get_attribute_value( $processor, 'loading' );
 			$has_auto = $this->sizes_attribute_includes_valid_auto( $sizes );
 
 			if ( $is_lazy && ! $has_auto ) {
@@ -122,7 +143,7 @@
 		}
 
 		// If this element is the LCP (for a breakpoint group), add a preload link for it.
-		foreach ( $context->url_metrics_group_collection->get_groups_by_lcp_element( $xpath ) as $group ) {
+		foreach ( $context->url_metric_group_collection->get_groups_by_lcp_element( $xpath ) as $group ) {
 			$link_attributes = array_merge(
 				array(
 					'rel'           => 'preload',
@@ -141,7 +162,7 @@
 				)
 			);
 
-			$crossorigin = $get_attribute_value( 'crossorigin' );
+			$crossorigin = $this->get_attribute_value( $processor, 'crossorigin' );
 			if ( null !== $crossorigin ) {
 				$link_attributes['crossorigin'] = 'use-credentials' === $crossorigin ? 'use-credentials' : 'anonymous';
 			}
Index: class-image-prioritizer-tag-visitor.php
===================================================================
--- class-image-prioritizer-tag-visitor.php	(revision 3172589)
+++ class-image-prioritizer-tag-visitor.php	(working copy)
@@ -14,6 +14,8 @@
 /**
  * Tag visitor that optimizes image tags.
  *
+ * @phpstan-type NormalizedAttributeNames 'fetchpriority'|'loading'|'crossorigin'|'preload'
+ *
  * @since 0.1.0
  * @access private
  */
@@ -36,4 +38,24 @@
 	protected function is_data_url( string $url ): bool {
 		return str_starts_with( strtolower( $url ), 'data:' );
 	}
+
+	/**
+	 * Gets attribute value for select attributes.
+	 *
+	 * @since 0.2.0
+	 * @todo Move this into the OD_HTML_Tag_Processor/OD_HTML_Processor class eventually.
+	 *
+	 * @phpstan-param NormalizedAttributeNames $attribute_name
+	 *
+	 * @param OD_HTML_Tag_Processor $processor      Processor.
+	 * @param string                $attribute_name Attribute name.
+	 * @return string|true|null Normalized attribute value.
+	 */
+	protected function get_attribute_value( OD_HTML_Tag_Processor $processor, string $attribute_name ) {
+		$value = $processor->get_attribute( $attribute_name );
+		if ( is_string( $value ) ) {
+			$value = strtolower( trim( $value, " \t\f\r\n" ) );
+		}
+		return $value;
+	}
 }
Index: helper.php
===================================================================
--- helper.php	(revision 3172589)
+++ helper.php	(working copy)
@@ -11,6 +11,42 @@
 }
 
 /**
+ * Initializes Image Prioritizer when Optimization Detective has loaded.
+ *
+ * @since 0.2.0
+ *
+ * @param string $optimization_detective_version Current version of the optimization detective plugin.
+ */
+function image_prioritizer_init( string $optimization_detective_version ): void {
+	$required_od_version = '0.7.0';
+	if ( ! version_compare( (string) strtok( $optimization_detective_version, '-' ), $required_od_version, '>=' ) ) {
+		add_action(
+			'admin_notices',
+			static function (): void {
+				global $pagenow;
+				if ( ! in_array( $pagenow, array( 'index.php', 'plugins.php' ), true ) ) {
+					return;
+				}
+				wp_admin_notice(
+					esc_html__( 'The Image Prioritizer plugin requires a newer version of the Optimization Detective plugin. Please update your plugins.', 'image-prioritizer' ),
+					array( 'type' => 'warning' )
+				);
+			}
+		);
+		return;
+	}
+
+	// Classes are required here because only here do we know the expected version of Optimization Detective is active.
+	require_once __DIR__ . '/class-image-prioritizer-tag-visitor.php';
+	require_once __DIR__ . '/class-image-prioritizer-img-tag-visitor.php';
+	require_once __DIR__ . '/class-image-prioritizer-background-image-styled-tag-visitor.php';
+	require_once __DIR__ . '/class-image-prioritizer-video-tag-visitor.php';
+
+	add_action( 'wp_head', 'image_prioritizer_render_generator_meta_tag' );
+	add_action( 'od_register_tag_visitors', 'image_prioritizer_register_tag_visitors' );
+}
+
+/**
  * Displays the HTML generator meta tag for the Image Prioritizer plugin.
  *
  * See {@see 'wp_head'}.
@@ -23,7 +59,7 @@
 }
 
 /**
- * Registers tag visitors for images.
+ * Registers tag visitors.
  *
  * @since 0.1.0
  *
@@ -32,8 +68,11 @@
 function image_prioritizer_register_tag_visitors( OD_Tag_Visitor_Registry $registry ): void {
 	// Note: The class is invocable (it has an __invoke() method).
 	$img_visitor = new Image_Prioritizer_Img_Tag_Visitor();
-	$registry->register( 'img-tags', $img_visitor );
+	$registry->register( 'image-prioritizer/img', $img_visitor );
 
 	$bg_image_visitor = new Image_Prioritizer_Background_Image_Styled_Tag_Visitor();
-	$registry->register( 'bg-image-tags', $bg_image_visitor );
+	$registry->register( 'image-prioritizer/background-image', $bg_image_visitor );
+
+	$video_visitor = new Image_Prioritizer_Video_Tag_Visitor();
+	$registry->register( 'image-prioritizer/video', $video_visitor );
 }
Index: hooks.php
===================================================================
--- hooks.php	(revision 3172589)
+++ hooks.php	(working copy)
@@ -10,6 +10,23 @@
 	exit; // Exit if accessed directly.
 }
 
-add_action( 'wp_head', 'image_prioritizer_render_generator_meta_tag' );
+add_action( 'od_init', 'image_prioritizer_init' );
 
-add_action( 'od_register_tag_visitors', 'image_prioritizer_register_tag_visitors' );
+/**
+ * Gets the script to lazy-load videos.
+ *
+ * Load a video and its poster image when it approaches the viewport using an IntersectionObserver.
+ *
+ * Handles 'autoplay' and 'preload' attributes accordingly.
+ *
+ * @since 0.2.0
+ */
+function image_prioritizer_get_lazy_load_script(): string {
+	$script = file_get_contents( __DIR__ . '/lazy-load.js' ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- It's a local filesystem path not a remote request.
+
+	if ( false === $script ) {
+		return '';
+	}
+
+	return $script;
+}
Index: load.php
===================================================================
--- load.php	(revision 3172589)
+++ load.php	(working copy)
@@ -6,7 +6,7 @@
  * Requires at least: 6.5
  * Requires PHP: 7.2
  * Requires Plugins: optimization-detective
- * Version: 0.1.4
+ * Version: 0.2.0
  * Author: WordPress Performance Team
  * Author URI: https://make.wordpress.org/performance/
  * License: GPLv2 or later
@@ -44,9 +44,14 @@
 				}
 			};
 
-			// Wait until after the plugins have loaded and the theme has loaded. The after_setup_theme action is used
-			// because it is the first action that fires once the theme is loaded.
-			add_action( 'after_setup_theme', $bootstrap, PHP_INT_MIN );
+			/*
+			 * Wait until after the plugins have loaded and the theme has loaded. The after_setup_theme action could be
+			 * used since it is the first action that fires once the theme is loaded. However, plugins may embed this
+			 * logic inside a module which initializes even later at the init action. The earliest action that this
+			 * plugin has hooks for is the init action at the default priority of 10 (which includes the rest_api_init
+			 * action), so this is why it gets initialized at priority 9.
+			 */
+			add_action( 'init', $bootstrap, 9 );
 		}
 
 		// Register this copy of the plugin.
@@ -66,10 +71,8 @@
 	}
 )(
 	'image_prioritizer_pending_plugin',
-	'0.1.4',
+	'0.2.0',
 	static function ( string $version ): void {
-
-		// Define the constant.
 		if ( defined( 'IMAGE_PRIORITIZER_VERSION' ) ) {
 			return;
 		}
@@ -76,9 +79,6 @@
 
 		define( 'IMAGE_PRIORITIZER_VERSION', $version );
 
-		require_once __DIR__ . '/class-image-prioritizer-tag-visitor.php';
-		require_once __DIR__ . '/class-image-prioritizer-img-tag-visitor.php';
-		require_once __DIR__ . '/class-image-prioritizer-background-image-styled-tag-visitor.php';
 		require_once __DIR__ . '/helper.php';
 		require_once __DIR__ . '/hooks.php';
 	}
Index: readme.txt
===================================================================
--- readme.txt	(revision 3172589)
+++ readme.txt	(working copy)
@@ -2,7 +2,7 @@
 
 Contributors: wordpressdotorg
 Tested up to: 6.6
-Stable tag:   0.1.4
+Stable tag:   0.2.0
 License:      GPLv2 or later
 License URI:  https://www.gnu.org/licenses/gpl-2.0.html
 Tags:         performance, optimization, image, lcp, lazy-load
@@ -62,6 +62,16 @@
 
 == Changelog ==
 
+= 0.2.0 =
+
+**Enhancements**
+
+* Add fetchpriority=low to occluded initial-viewport images. ([1482](https://github.com/WordPress/performance/pull/1482))
+* Avoid lazy-loading images and embeds unless there are URL Metrics for both mobile and desktop. ([1604](https://github.com/WordPress/performance/pull/1604))
+* Choose smaller poster image size based on actual dimensions. ([1595](https://github.com/WordPress/performance/pull/1595))
+* Lazy load videos and video posters. ([1596](https://github.com/WordPress/performance/pull/1596))
+* Prioritize loading poster image of video LCP elements. ([1498](https://github.com/WordPress/performance/pull/1498))
+
 = 0.1.4 =
 
 **Enhancements**

optimization-detective

Important

Stable tag change: 0.6.0 → 0.7.0

svn status:

?       class-od-element.php
M       class-od-html-tag-processor.php
M       class-od-link-collection.php
M       class-od-tag-visitor-context.php
?       class-od-url-metric-group-collection.php
?       class-od-url-metric-group.php
M       class-od-url-metric.php
!       class-od-url-metrics-group-collection.php
!       class-od-url-metrics-group.php
M       detect.js
M       detection.php
M       helper.php
M       hooks.php
M       load.php
M       optimization.php
M       readme.txt
?       storage/class-od-url-metric-store-request-context.php
M       storage/class-od-url-metrics-post-type.php
M       storage/data.php
M       storage/rest-api.php
?       types.ts
svn diff
Index: class-od-html-tag-processor.php
===================================================================
--- class-od-html-tag-processor.php	(revision 3172589)
+++ class-od-html-tag-processor.php	(working copy)
@@ -179,6 +179,15 @@
 	private $buffered_text_replacements = array();
 
 	/**
+	 * Whether the end of the document was reached.
+	 *
+	 * @since 0.7.0
+	 * @see self::next_token()
+	 * @var bool
+	 */
+	private $reached_end_of_document = false;
+
+	/**
 	 * Count for the number of times that the cursor was moved.
 	 *
 	 * @since 0.6.0
@@ -263,6 +272,9 @@
 		if ( ! parent::next_token() ) {
 			$this->open_stack_tags    = array();
 			$this->open_stack_indices = array();
+
+			// Mark that the end of the document was reached, meaning that get_modified_html() can should now be able to append markup to the HEAD and the BODY.
+			$this->reached_end_of_document = true;
 			return false;
 		}
 
@@ -365,8 +377,8 @@
 	public function set_attribute( $name, $value ): bool { // phpcs:ignore SlevomatCodingStandard.TypeHints.ParameterTypeHint.MissingNativeTypeHint
 		$existing_value = $this->get_attribute( $name );
 		$result         = parent::set_attribute( $name, $value );
-		if ( $result ) {
-			if ( is_string( $existing_value ) ) {
+		if ( $result && $existing_value !== $value ) {
+			if ( null !== $existing_value ) {
 				$this->set_meta_attribute( "replaced-{$name}", $existing_value );
 			} else {
 				$this->set_meta_attribute( "added-{$name}", true );
@@ -559,11 +571,25 @@
 	/**
 	 * Returns the string representation of the HTML Tag Processor.
 	 *
+	 * Once the end of the document has been reached this is responsible for adding the pending markup to append to the
+	 * HEAD and the BODY. It waits to do this injection until the end of the document has been reached because every
+	 * time that seek() is called it the HTML Processor will flush any pending updates to the document. This means that
+	 * if there is any pending markup to append to the end of the BODY then the insertion will fail because the closing
+	 * tag for the BODY has not been encountered yet. Additionally, by not prematurely processing the buffered text
+	 * replacements in get_updated_html() then we avoid trying to insert them every time that seek() is called which is
+	 * wasteful as they are only needed once finishing iterating over the document.
+	 *
 	 * @since 0.4.0
+	 * @see WP_HTML_Tag_Processor::get_updated_html()
+	 * @see WP_HTML_Tag_Processor::seek()
 	 *
 	 * @return string The processed HTML.
 	 */
 	public function get_updated_html(): string {
+		if ( ! $this->reached_end_of_document ) {
+			return parent::get_updated_html();
+		}
+
 		foreach ( array_keys( $this->buffered_text_replacements ) as $bookmark ) {
 			$html_strings = $this->buffered_text_replacements[ $bookmark ];
 			if ( count( $html_strings ) === 0 ) {
Index: class-od-link-collection.php
===================================================================
--- class-od-link-collection.php	(revision 3172589)
+++ class-od-link-collection.php	(working copy)
@@ -192,20 +192,13 @@
 		// Add media attributes to the deduplicated links.
 		return array_map(
 			static function ( array $link ): array {
-				$media_attributes = array();
-				if ( null !== $link['minimum_viewport_width'] && $link['minimum_viewport_width'] > 0 ) {
-					$media_attributes[] = sprintf( '(min-width: %dpx)', $link['minimum_viewport_width'] );
-				}
-				if ( null !== $link['maximum_viewport_width'] && PHP_INT_MAX !== $link['maximum_viewport_width'] ) {
-					$media_attributes[] = sprintf( '(max-width: %dpx)', $link['maximum_viewport_width'] );
-				}
-				if ( count( $media_attributes ) > 0 ) {
+				$media_query = od_generate_media_query( $link['minimum_viewport_width'], $link['maximum_viewport_width'] );
+				if ( null !== $media_query ) {
 					if ( ! isset( $link['attributes']['media'] ) ) {
-						$link['attributes']['media'] = '';
+						$link['attributes']['media'] = $media_query;
 					} else {
-						$link['attributes']['media'] .= ' and ';
+						$link['attributes']['media'] .= " and $media_query";
 					}
-					$link['attributes']['media'] .= implode( ' and ', $media_attributes );
 				}
 				return $link['attributes'];
 			},
Index: class-od-tag-visitor-context.php
===================================================================
--- class-od-tag-visitor-context.php	(revision 3172589)
+++ class-od-tag-visitor-context.php	(working copy)
@@ -16,6 +16,8 @@
  *
  * @since 0.4.0
  * @access private
+ *
+ * @property-read OD_URL_Metric_Group_Collection $url_metrics_group_collection Deprecated property accessed via magic getter. Use the url_metric_group_collection property instead.
  */
 final class OD_Tag_Visitor_Context {
 
@@ -28,12 +30,12 @@
 	public $processor;
 
 	/**
-	 * URL metrics group collection.
+	 * URL metric group collection.
 	 *
-	 * @var OD_URL_Metrics_Group_Collection
+	 * @var OD_URL_Metric_Group_Collection
 	 * @readonly
 	 */
-	public $url_metrics_group_collection;
+	public $url_metric_group_collection;
 
 	/**
 	 * Link collection.
@@ -46,13 +48,50 @@
 	/**
 	 * Constructor.
 	 *
-	 * @param OD_HTML_Tag_Processor           $processor                    HTML tag processor.
-	 * @param OD_URL_Metrics_Group_Collection $url_metrics_group_collection URL metrics group collection.
-	 * @param OD_Link_Collection              $link_collection              Link collection.
+	 * @param OD_HTML_Tag_Processor          $processor                   HTML tag processor.
+	 * @param OD_URL_Metric_Group_Collection $url_metric_group_collection URL metric group collection.
+	 * @param OD_Link_Collection             $link_collection             Link collection.
 	 */
-	public function __construct( OD_HTML_Tag_Processor $processor, OD_URL_Metrics_Group_Collection $url_metrics_group_collection, OD_Link_Collection $link_collection ) {
-		$this->processor                    = $processor;
-		$this->url_metrics_group_collection = $url_metrics_group_collection;
-		$this->link_collection              = $link_collection;
+	public function __construct( OD_HTML_Tag_Processor $processor, OD_URL_Metric_Group_Collection $url_metric_group_collection, OD_Link_Collection $link_collection ) {
+		$this->processor                   = $processor;
+		$this->url_metric_group_collection = $url_metric_group_collection;
+		$this->link_collection             = $link_collection;
 	}
+
+	/**
+	 * Gets deprecated property.
+	 *
+	 * @since 0.7.0
+	 * @todo Remove this when no plugins are possibly referring to the url_metrics_group_collection property anymore.
+	 *
+	 * @param string $name Property name.
+	 * @return OD_URL_Metric_Group_Collection URL metric group collection.
+	 *
+	 * @throws Error When property is unknown.
+	 */
+	public function __get( string $name ): OD_URL_Metric_Group_Collection {
+		if ( 'url_metrics_group_collection' === $name ) {
+			_doing_it_wrong(
+				__CLASS__ . '::$url_metrics_group_collection',
+				esc_html(
+					sprintf(
+						/* translators: %s is class member variable name */
+						__( 'Use %s instead.', 'optimization-detective' ),
+						__CLASS__ . '::$url_metric_group_collection'
+					)
+				),
+				'optimization-detective 0.7.0'
+			);
+			return $this->url_metric_group_collection;
+		}
+		throw new Error(
+			esc_html(
+				sprintf(
+					/* translators: %s is class member variable name */
+					__( 'Unknown property %s.', 'optimization-detective' ),
+					__CLASS__ . '::$' . $name
+				)
+			)
+		);
+	}
 }
Index: class-od-url-metric.php
===================================================================
--- class-od-url-metric.php	(revision 3172589)
+++ class-od-url-metric.php	(working copy)
@@ -31,14 +31,14 @@
  * @phpstan-type ElementData  array{
  *                                isLCP: bool,
  *                                isLCPCandidate: bool,
- *                                xpath: string,
+ *                                xpath: non-empty-string,
  *                                intersectionRatio: float,
  *                                intersectionRect: DOMRect,
  *                                boundingClientRect: DOMRect,
  *                            }
  * @phpstan-type Data         array{
- *                                uuid: string,
- *                                url: string,
+ *                                uuid: non-empty-string,
+ *                                url: non-empty-string,
  *                                timestamp: float,
  *                                viewport: ViewportRect,
  *                                elements: ElementData[]
@@ -57,6 +57,21 @@
 	protected $data;
 
 	/**
+	 * Elements.
+	 *
+	 * @var OD_Element[]
+	 */
+	protected $elements;
+
+	/**
+	 * Group.
+	 *
+	 * @since 0.7.0
+	 * @var OD_URL_Metric_Group|null
+	 */
+	protected $group = null;
+
+	/**
 	 * Constructor.
 	 *
 	 * @phpstan-param Data|array<string, mixed> $data Valid data or invalid data (in which case an exception is thrown).
@@ -96,11 +111,12 @@
 			throw new OD_Data_Validation_Exception(
 				esc_html(
 					sprintf(
-						/* translators: 1: current aspect ratio, 2: minimum aspect ratio, 3: maximum aspect ratio */
-						__( 'Viewport aspect ratio (%1$s) is not in the accepted range of %2$s to %3$s.', 'optimization-detective' ),
+						/* translators: 1: current aspect ratio, 2: minimum aspect ratio, 3: maximum aspect ratio, 4: viewport dimensions */
+						__( 'Viewport aspect ratio (%1$s) is not in the accepted range of %2$s to %3$s. Viewport dimensions: %4$s', 'optimization-detective' ),
 						$aspect_ratio,
 						$min_aspect_ratio,
-						$max_aspect_ratio
+						$max_aspect_ratio,
+						$data['viewport']['width'] . 'x' . $data['viewport']['height']
 					)
 				)
 			);
@@ -109,6 +125,33 @@
 	}
 
 	/**
+	 * Gets the group that this URL metric is a part of (which may not be any).
+	 *
+	 * @since 0.7.0
+	 *
+	 * @return OD_URL_Metric_Group|null Group.
+	 */
+	public function get_group(): ?OD_URL_Metric_Group {
+		return $this->group;
+	}
+
+	/**
+	 * Sets the group that this URL metric is a part of.
+	 *
+	 * @since 0.7.0
+	 *
+	 * @param OD_URL_Metric_Group $group Group.
+	 *
+	 * @throws InvalidArgumentException When the supplied group has minimum/maximum viewport widths which are out of bounds with the viewport width for this URL Metric.
+	 */
+	public function set_group( OD_URL_Metric_Group $group ): void {
+		if ( ! $group->is_viewport_width_in_range( $this->get_viewport_width() ) ) {
+			throw new InvalidArgumentException( 'Group does not have the correct minimum or maximum viewport widths for this URL Metric.' );
+		}
+		$this->group = $group;
+	}
+
+	/**
 	 * Gets JSON schema for URL Metric.
 	 *
 	 * @todo Cache the return value?
@@ -355,6 +398,9 @@
 	 * @return mixed|null The property value, or null if not set.
 	 */
 	public function get( string $key ) {
+		if ( 'elements' === $key ) {
+			return $this->get_elements();
+		}
 		return $this->data[ $key ] ?? null;
 	}
 
@@ -406,10 +452,18 @@
 	/**
 	 * Gets elements.
 	 *
-	 * @return ElementData[] Elements.
+	 * @return OD_Element[] Elements.
 	 */
 	public function get_elements(): array {
-		return $this->data['elements'];
+		if ( ! is_array( $this->elements ) ) {
+			$this->elements = array_map(
+				function ( array $element ): OD_Element {
+					return new OD_Element( $element, $this );
+				},
+				$this->data['elements']
+			);
+		}
+		return $this->elements;
 	}
 
 	/**
Index: detect.js
===================================================================
--- detect.js	(revision 3172589)
+++ detect.js	(working copy)
@@ -1 +1 @@
-const win=window,doc=win.document,consoleLogPrefix="[Optimization Detective]",storageLockTimeSessionKey="odStorageLockTime";function isStorageLocked(e,t){if(0===t)return!1;try{const o=parseInt(sessionStorage.getItem(storageLockTimeSessionKey));return!isNaN(o)&&e<o+1e3*t}catch(e){return!1}}function setStorageLock(e){try{sessionStorage.setItem(storageLockTimeSessionKey,String(e))}catch(e){}}function log(...e){console.log(consoleLogPrefix,...e)}function warn(...e){console.warn(consoleLogPrefix,...e)}function error(...e){console.error(consoleLogPrefix,...e)}function isViewportNeeded(e,t){let o=!1;for(const{minimumViewportWidth:n,complete:i}of t){if(!(e>=n))break;o=!i}return o}function getCurrentTime(){return Date.now()}export default async function detect({serveTime:e,detectionTimeWindow:t,minViewportAspectRatio:o,maxViewportAspectRatio:n,isDebug:i,restApiEndpoint:r,restApiNonce:s,currentUrl:c,urlMetricsSlug:a,urlMetricsNonce:d,urlMetricsGroupStatuses:l,storageLockTTL:u,webVitalsLibrarySrc:g,urlMetricsGroupCollection:w}){const p=getCurrentTime();if(i&&log("Stored URL metrics group collection:",w),p-e>t)return void(i&&warn("Aborted detection due to being outside detection time window."));if(!isViewportNeeded(win.innerWidth,l))return void(i&&log("No need for URL metrics from the current viewport."));const m=win.innerWidth/win.innerHeight;if(m<o||m>n)return void(i&&warn(`Viewport aspect ratio (${m}) is not in the accepted range of ${o} to ${n}.`));if(await new Promise((e=>{"loading"!==doc.readyState?e():doc.addEventListener("DOMContentLoaded",e,{once:!0})})),await new Promise((e=>{"complete"===doc.readyState?e():win.addEventListener("load",e,{once:!0})})),"function"==typeof requestIdleCallback&&await new Promise((e=>{requestIdleCallback(e)})),isStorageLocked(p,u))return void(i&&warn("Aborted detection due to storage being locked."));if(doc.documentElement.scrollTop>0)return void(i&&warn("Aborted detection since initial scroll position of page is not at the top."));i&&log("Proceeding with detection");const f=doc.body.querySelectorAll("[data-od-xpath]"),h=new Map([...f].map((e=>[e,e.dataset.odXpath]))),L=[];let S;function b(){S instanceof IntersectionObserver&&(S.disconnect(),win.removeEventListener("scroll",b))}h.size>0&&(await new Promise((e=>{S=new IntersectionObserver((t=>{for(const e of t)L.push(e);e()}),{root:null,threshold:0});for(const e of h.keys())S.observe(e)})),win.addEventListener("scroll",b,{once:!0,passive:!0}));const{onLCP:v}=await import(g),y=[];await new Promise((e=>{v((t=>{y.push(t),e()}),{reportAllChanges:!0})})),b(),i&&log("Detection is stopping.");const P={url:c,slug:a,nonce:d,viewport:{width:win.innerWidth,height:win.innerHeight},elements:[]},C=y.at(-1);for(const e of L){const t=h.get(e.target);if(!t){i&&error("Unable to look up XPath for element");continue}const o={isLCP:e.target===C?.entries[0]?.element,isLCPCandidate:!!y.find((t=>t.entries[0]?.element===e.target)),xpath:t,intersectionRatio:e.intersectionRatio,intersectionRect:e.intersectionRect,boundingClientRect:e.boundingClientRect};P.elements.push(o)}i&&log("Current URL metrics:",P),await new Promise((e=>{setTimeout(e,0)}));try{const e=await fetch(r,{method:"POST",headers:{"Content-Type":"application/json","X-WP-Nonce":s},body:JSON.stringify(P)});if(200===e.status&&setStorageLock(getCurrentTime()),i){const t=await e.json();200===e.status?log("Response:",t):error("Failure:",t)}}catch(e){i&&error(e)}h.clear()}
\ No newline at end of file
+const win=window,doc=win.document,consoleLogPrefix="[Optimization Detective]",storageLockTimeSessionKey="odStorageLockTime";function isStorageLocked(e,t){if(0===t)return!1;try{const n=parseInt(sessionStorage.getItem(storageLockTimeSessionKey));return!isNaN(n)&&e<n+1e3*t}catch(e){return!1}}function setStorageLock(e){try{sessionStorage.setItem(storageLockTimeSessionKey,String(e))}catch(e){}}function log(...e){console.log(consoleLogPrefix,...e)}function warn(...e){console.warn(consoleLogPrefix,...e)}function error(...e){console.error(consoleLogPrefix,...e)}function isViewportNeeded(e,t){let n=!1;for(const{minimumViewportWidth:o,complete:i}of t){if(!(e>=o))break;n=!i}return n}function getCurrentTime(){return Date.now()}function recursiveFreeze(e){for(const t of Object.getOwnPropertyNames(e)){const n=e[t];null!==n&&"object"==typeof n&&recursiveFreeze(n)}Object.freeze(e)}let urlMetric;const reservedRootPropertyKeys=new Set(["url","viewport","elements"]);function getRootData(){const e=structuredClone(urlMetric);return recursiveFreeze(e),e}function extendRootData(e){for(const t of Object.getOwnPropertyNames(e))if(reservedRootPropertyKeys.has(t))throw new Error(`Disallowed setting of key '${t}' on root.`);Object.assign(urlMetric,e)}const elementsByXPath=new Map,reservedElementPropertyKeys=new Set(["isLCP","isLCPCandidate","xpath","intersectionRatio","intersectionRect","boundingClientRect"]);function getElementData(e){const t=elementsByXPath.get(e);if(t){const e=structuredClone(t);return recursiveFreeze(e),e}return null}function extendElementData(e,t){if(!elementsByXPath.has(e))throw new Error(`Unknown element with XPath: ${e}`);for(const e of Object.getOwnPropertyNames(t))if(reservedElementPropertyKeys.has(e))throw new Error(`Disallowed setting of key '${e}' on element.`);const n=elementsByXPath.get(e);Object.assign(n,t)}export default async function detect({serveTime:e,detectionTimeWindow:t,minViewportAspectRatio:n,maxViewportAspectRatio:o,isDebug:i,extensionModuleUrls:r,restApiEndpoint:s,restApiNonce:c,currentUrl:a,urlMetricSlug:l,urlMetricNonce:d,urlMetricGroupStatuses:u,storageLockTTL:g,webVitalsLibrarySrc:w,urlMetricGroupCollection:f}){const m=getCurrentTime();if(i&&log("Stored URL metric group collection:",f),m-e>t)return void(i&&warn("Aborted detection due to being outside detection time window."));if(!isViewportNeeded(win.innerWidth,u))return void(i&&log("No need for URL metrics from the current viewport."));const p=win.innerWidth/win.innerHeight;if(p<n||p>o)return void(i&&warn(`Viewport aspect ratio (${p}) is not in the accepted range of ${n} to ${o}.`));if(await new Promise((e=>{"loading"!==doc.readyState?e():doc.addEventListener("DOMContentLoaded",e,{once:!0})})),await new Promise((e=>{"complete"===doc.readyState?e():win.addEventListener("load",e,{once:!0})})),"function"==typeof requestIdleCallback&&await new Promise((e=>{requestIdleCallback(e)})),isStorageLocked(m,g))return void(i&&warn("Aborted detection due to storage being locked."));if(doc.documentElement.scrollTop>0)return void(i&&warn("Aborted detection since initial scroll position of page is not at the top."));i&&log("Proceeding with detection");const h=new Map;for(const e of r)try{const t=await import(e);h.set(e,t),t.initialize instanceof Function&&t.initialize({isDebug:i})}catch(t){error(`Failed to initialize extension '${e}':`,t)}const y=doc.body.querySelectorAll("[data-od-xpath]"),v=new Map([...y].map((e=>[e,e.dataset.odXpath]))),L=[];let P;function b(){P instanceof IntersectionObserver&&(P.disconnect(),win.removeEventListener("scroll",b))}v.size>0&&(await new Promise((e=>{P=new IntersectionObserver((t=>{for(const e of t)L.push(e);e()}),{root:null,threshold:0});for(const e of v.keys())P.observe(e)})),win.addEventListener("scroll",b,{once:!0,passive:!0}));const{onLCP:R}=await import(w),S=[];await new Promise((e=>{R((t=>{S.push(t),e()}),{reportAllChanges:!0})})),b(),i&&log("Detection is stopping."),urlMetric={url:a,viewport:{width:win.innerWidth,height:win.innerHeight},elements:[]};const C=S.at(-1);for(const e of L){const t=v.get(e.target);if(!t){i&&error("Unable to look up XPath for element");continue}const n={isLCP:e.target===C?.entries[0]?.element,isLCPCandidate:!!S.find((t=>t.entries[0]?.element===e.target)),xpath:t,intersectionRatio:e.intersectionRatio,intersectionRect:e.intersectionRect,boundingClientRect:e.boundingClientRect};urlMetric.elements.push(n),elementsByXPath.set(n.xpath,n)}if(i&&log("Current URL metric:",urlMetric),await new Promise((e=>{win.addEventListener("pagehide",e,{once:!0}),win.addEventListener("pageswap",e,{once:!0}),doc.addEventListener("visibilitychange",(()=>{"hidden"===document.visibilityState&&e()}),{once:!0})})),h.size>0)for(const[e,t]of h.entries())if(t.finalize instanceof Function)try{await t.finalize({isDebug:i,getRootData,getElementData,extendElementData,extendRootData})}catch(t){error(`Unable to finalize module '${e}':`,t)}setStorageLock(getCurrentTime()),i&&log("Sending URL metric:",urlMetric);const D=new URL(s);D.searchParams.set("_wpnonce",c),D.searchParams.set("slug",l),D.searchParams.set("nonce",d),navigator.sendBeacon(D,new Blob([JSON.stringify(urlMetric)],{type:"application/json"})),v.clear()}
\ No newline at end of file
Index: detection.php
===================================================================
--- detection.php	(revision 3172589)
+++ detection.php	(working copy)
@@ -16,10 +16,10 @@
  * @since 0.1.0
  * @access private
  *
- * @param string                          $slug             URL metrics slug.
- * @param OD_URL_Metrics_Group_Collection $group_collection URL metrics group collection.
+ * @param string                         $slug             URL metrics slug.
+ * @param OD_URL_Metric_Group_Collection $group_collection URL metric group collection.
  */
-function od_get_detection_script( string $slug, OD_URL_Metrics_Group_Collection $group_collection ): string {
+function od_get_detection_script( string $slug, OD_URL_Metric_Group_Collection $group_collection ): string {
 	/**
 	 * Filters the time window between serve time and run time in which loading detection is allowed to run.
 	 *
@@ -38,20 +38,30 @@
 	$web_vitals_lib_data = require __DIR__ . '/build/web-vitals.asset.php';
 	$web_vitals_lib_src  = add_query_arg( 'ver', $web_vitals_lib_data['version'], plugin_dir_url( __FILE__ ) . 'build/web-vitals.js' );
 
+	/**
+	 * Filters the list of extension script module URLs to import when performing detection.
+	 *
+	 * @since 0.7.0
+	 *
+	 * @param string[] $extension_module_urls Extension module URLs.
+	 */
+	$extension_module_urls = (array) apply_filters( 'od_extension_module_urls', array() );
+
 	$current_url = od_get_current_url();
 	$detect_args = array(
-		'serveTime'               => microtime( true ) * 1000, // In milliseconds for comparison with `Date.now()` in JavaScript.
-		'detectionTimeWindow'     => $detection_time_window,
-		'minViewportAspectRatio'  => od_get_minimum_viewport_aspect_ratio(),
-		'maxViewportAspectRatio'  => od_get_maximum_viewport_aspect_ratio(),
-		'isDebug'                 => WP_DEBUG,
-		'restApiEndpoint'         => rest_url( OD_REST_API_NAMESPACE . OD_URL_METRICS_ROUTE ),
-		'restApiNonce'            => wp_create_nonce( 'wp_rest' ),
-		'currentUrl'              => $current_url,
-		'urlMetricsSlug'          => $slug,
-		'urlMetricsNonce'         => od_get_url_metrics_storage_nonce( $slug, $current_url ),
-		'urlMetricsGroupStatuses' => array_map(
-			static function ( OD_URL_Metrics_Group $group ): array {
+		'serveTime'              => microtime( true ) * 1000, // In milliseconds for comparison with `Date.now()` in JavaScript.
+		'detectionTimeWindow'    => $detection_time_window,
+		'minViewportAspectRatio' => od_get_minimum_viewport_aspect_ratio(),
+		'maxViewportAspectRatio' => od_get_maximum_viewport_aspect_ratio(),
+		'isDebug'                => WP_DEBUG,
+		'extensionModuleUrls'    => $extension_module_urls,
+		'restApiEndpoint'        => rest_url( OD_REST_API_NAMESPACE . OD_URL_METRICS_ROUTE ),
+		'restApiNonce'           => wp_create_nonce( 'wp_rest' ),
+		'currentUrl'             => $current_url,
+		'urlMetricSlug'          => $slug,
+		'urlMetricNonce'         => od_get_url_metrics_storage_nonce( $slug, $current_url ),
+		'urlMetricGroupStatuses' => array_map(
+			static function ( OD_URL_Metric_Group $group ): array {
 				return array(
 					'minimumViewportWidth' => $group->get_minimum_viewport_width(),
 					'complete'             => $group->is_complete(),
@@ -59,11 +69,11 @@
 			},
 			iterator_to_array( $group_collection )
 		),
-		'storageLockTTL'          => OD_Storage_Lock::get_ttl(),
-		'webVitalsLibrarySrc'     => $web_vitals_lib_src,
+		'storageLockTTL'         => OD_Storage_Lock::get_ttl(),
+		'webVitalsLibrarySrc'    => $web_vitals_lib_src,
 	);
 	if ( WP_DEBUG ) {
-		$detect_args['urlMetricsGroupCollection'] = $group_collection;
+		$detect_args['urlMetricGroupCollection'] = $group_collection;
 	}
 
 	return wp_get_inline_script_tag(
Index: helper.php
===================================================================
--- helper.php	(revision 3172589)
+++ helper.php	(working copy)
@@ -11,6 +11,49 @@
 }
 
 /**
+ * Initializes extensions for Optimization Detective.
+ *
+ * @since 0.7.0
+ */
+function od_initialize_extensions(): void {
+	/**
+	 * Fires when extensions to Optimization Detective can be loaded and initialized.
+	 *
+	 * @since 0.7.0
+	 *
+	 * @param string $version Optimization Detective version.
+	 */
+	do_action( 'od_init', OPTIMIZATION_DETECTIVE_VERSION );
+}
+
+/**
+ * Generates a media query for the provided minimum and maximum viewport widths.
+ *
+ * @since 0.7.0
+ *
+ * @param int|null $minimum_viewport_width Minimum viewport width.
+ * @param int|null $maximum_viewport_width Maximum viewport width.
+ * @return non-empty-string|null Media query, or null if the min/max were both unspecified or invalid.
+ */
+function od_generate_media_query( ?int $minimum_viewport_width, ?int $maximum_viewport_width ): ?string {
+	if ( is_int( $minimum_viewport_width ) && is_int( $maximum_viewport_width ) && $minimum_viewport_width > $maximum_viewport_width ) {
+		_doing_it_wrong( __FUNCTION__, esc_html__( 'The minimum width must be greater than the maximum width.', 'optimization-detective' ), 'Optimization Detective 0.7.0' );
+		return null;
+	}
+	$media_attributes = array();
+	if ( null !== $minimum_viewport_width && $minimum_viewport_width > 0 ) {
+		$media_attributes[] = sprintf( '(min-width: %dpx)', $minimum_viewport_width );
+	}
+	if ( null !== $maximum_viewport_width && PHP_INT_MAX !== $maximum_viewport_width ) {
+		$media_attributes[] = sprintf( '(max-width: %dpx)', $maximum_viewport_width );
+	}
+	if ( count( $media_attributes ) === 0 ) {
+		return null;
+	}
+	return join( ' and ', $media_attributes );
+}
+
+/**
  * Displays the HTML generator meta tag for the Optimization Detective plugin.
  *
  * See {@see 'wp_head'}.
Index: hooks.php
===================================================================
--- hooks.php	(revision 3172589)
+++ hooks.php	(working copy)
@@ -10,6 +10,7 @@
 	exit; // Exit if accessed directly.
 }
 
+add_action( 'init', 'od_initialize_extensions', PHP_INT_MAX );
 add_filter( 'template_include', 'od_buffer_output', PHP_INT_MAX );
 OD_URL_Metrics_Post_Type::add_hooks();
 add_action( 'wp', 'od_maybe_add_template_output_buffer_filter' );
Index: load.php
===================================================================
--- load.php	(revision 3172589)
+++ load.php	(working copy)
@@ -5,7 +5,7 @@
  * Description: Provides an API for leveraging real user metrics to detect optimizations to apply on the frontend to improve page performance.
  * Requires at least: 6.5
  * Requires PHP: 7.2
- * Version: 0.6.0
+ * Version: 0.7.0
  * Author: WordPress Performance Team
  * Author URI: https://make.wordpress.org/performance/
  * License: GPLv2 or later
@@ -43,9 +43,14 @@
 				}
 			};
 
-			// Wait until after the plugins have loaded and the theme has loaded. The after_setup_theme action is used
-			// because it is the first action that fires once the theme is loaded.
-			add_action( 'after_setup_theme', $bootstrap, PHP_INT_MIN );
+			/*
+			 * Wait until after the plugins have loaded and the theme has loaded. The after_setup_theme action could be
+			 * used since it is the first action that fires once the theme is loaded. However, plugins may embed this
+			 * logic inside a module which initializes even later at the init action. The earliest action that this
+			 * plugin has hooks for is the init action at the default priority of 10 (which includes the rest_api_init
+			 * action), so this is why it gets initialized at priority 9.
+			 */
+			add_action( 'init', $bootstrap, 9 );
 		}
 
 		// Register this copy of the plugin.
@@ -65,10 +70,8 @@
 	}
 )(
 	'optimization_detective_pending_plugin',
-	'0.6.0',
+	'0.7.0',
 	static function ( string $version ): void {
-
-		// Define the constant.
 		if ( defined( 'OPTIMIZATION_DETECTIVE_VERSION' ) ) {
 			return;
 		}
@@ -99,9 +102,12 @@
 		require_once __DIR__ . '/class-od-data-validation-exception.php';
 		require_once __DIR__ . '/class-od-html-tag-processor.php';
 		require_once __DIR__ . '/class-od-url-metric.php';
+		require_once __DIR__ . '/class-od-element.php';
 		require_once __DIR__ . '/class-od-strict-url-metric.php';
-		require_once __DIR__ . '/class-od-url-metrics-group.php';
-		require_once __DIR__ . '/class-od-url-metrics-group-collection.php';
+		require_once __DIR__ . '/class-od-url-metric-group.php';
+		require_once __DIR__ . '/class-od-url-metric-group-collection.php';
+		class_alias( OD_URL_Metric_Group::class, 'OD_URL_Metrics_Group' ); // Temporary class alias for back-compat after rename.
+		class_alias( OD_URL_Metric_Group_Collection::class, 'OD_URL_Metrics_Group_Collection' ); // Temporary class alias for back-compat after rename.
 
 		// Storage logic.
 		require_once __DIR__ . '/storage/class-od-url-metrics-post-type.php';
@@ -108,6 +114,7 @@
 		require_once __DIR__ . '/storage/class-od-storage-lock.php';
 		require_once __DIR__ . '/storage/data.php';
 		require_once __DIR__ . '/storage/rest-api.php';
+		require_once __DIR__ . '/storage/class-od-url-metric-store-request-context.php';
 
 		// Detection logic.
 		require_once __DIR__ . '/detection.php';
Index: optimization.php
===================================================================
--- optimization.php	(revision 3172589)
+++ optimization.php	(working copy)
@@ -27,10 +27,10 @@
  * @access private
  * @link https://core.trac.wordpress.org/ticket/43258
  *
- * @param string $passthrough Value for the template_include filter which is passed through.
- * @return string Unmodified value of $passthrough.
+ * @param string|mixed $passthrough Value for the template_include filter which is passed through.
+ * @return string|mixed Unmodified value of $passthrough.
  */
-function od_buffer_output( string $passthrough ): string {
+function od_buffer_output( $passthrough ) {
 	/*
 	 * Instead of the default PHP_OUTPUT_HANDLER_STDFLAGS (cleanable, flushable, and removable) being used for flags,
 	 * we need to omit PHP_OUTPUT_HANDLER_FLUSHABLE. If the buffer were flushable, then each time that ob_flush() is
@@ -189,7 +189,7 @@
 	$slug = od_get_url_metrics_slug( od_get_normalized_query_vars() );
 	$post = OD_URL_Metrics_Post_Type::get_post( $slug );
 
-	$group_collection = new OD_URL_Metrics_Group_Collection(
+	$group_collection = new OD_URL_Metric_Group_Collection(
 		$post instanceof WP_Post ? OD_URL_Metrics_Post_Type::get_url_metrics_from_post( $post ) : array(),
 		od_get_breakpoint_max_widths(),
 		od_get_url_metrics_breakpoint_sample_size(),
Index: readme.txt
===================================================================
--- readme.txt	(revision 3172589)
+++ readme.txt	(working copy)
@@ -2,7 +2,7 @@
 
 Contributors: wordpressdotorg
 Tested up to: 6.6
-Stable tag:   0.6.0
+Stable tag:   0.7.0
 License:      GPLv2 or later
 License URI:  https://www.gnu.org/licenses/gpl-2.0.html
 Tags:         performance, optimization, rum
@@ -157,6 +157,18 @@
 
 == Changelog ==
 
+= 0.7.0 =
+
+**Enhancements**
+
+* Add group collection helper methods to get the first/last groups. ([1602](https://github.com/WordPress/performance/pull/1602))
+* Introduce OD_Element class. ([1585](https://github.com/WordPress/performance/pull/1585))
+
+**Bug Fixes**
+
+* Fix Optimization Detective compatibility with WooCommerce when Coming Soon page is served. ([1565](https://github.com/WordPress/performance/pull/1565))
+* Fix storage of URL Metric when plain non-pretty permalinks are enabled. ([1574](https://github.com/WordPress/performance/pull/1574))
+
 = 0.6.0 =
 
 **Enhancements**
Index: storage/class-od-url-metrics-post-type.php
===================================================================
--- storage/class-od-url-metrics-post-type.php	(revision 3172589)
+++ storage/class-od-url-metrics-post-type.php	(working copy)
@@ -213,7 +213,7 @@
 			$url_metrics            = array();
 		}
 
-		$group_collection = new OD_URL_Metrics_Group_Collection(
+		$group_collection = new OD_URL_Metric_Group_Collection(
 			$url_metrics,
 			od_get_breakpoint_max_widths(),
 			od_get_url_metrics_breakpoint_sample_size(),
Index: storage/data.php
===================================================================
--- storage/data.php	(revision 3172589)
+++ storage/data.php	(working copy)
@@ -243,6 +243,9 @@
  *
  * These breakpoints appear to be used the most in media queries that affect frontend styles.
  *
+ * This array may be empty in which case there are no responsive breakpoints and all URL Metrics are collected in a
+ * single group.
+ *
  * @since 0.1.0
  * @access private
  * @link https://github.com/WordPress/gutenberg/blob/093d52cbfd3e2c140843d3fb91ad3d03330320a5/packages/base-styles/_breakpoints.scss#L11-L13
@@ -287,7 +290,8 @@
 		/**
 		 * Filters the breakpoint max widths to group URL metrics for various viewports.
 		 *
-		 * A breakpoint must be greater than zero and less than PHP_INT_MAX.
+		 * A breakpoint must be greater than zero and less than PHP_INT_MAX. This array may be empty in which case there
+		 * are no responsive breakpoints and all URL Metrics are collected in a single group.
 		 *
 		 * @since 0.1.0
 		 *
Index: storage/rest-api.php
===================================================================
--- storage/rest-api.php	(revision 3172589)
+++ storage/rest-api.php	(working copy)
@@ -102,7 +102,7 @@
 function od_handle_rest_request( WP_REST_Request $request ) {
 	$post = OD_URL_Metrics_Post_Type::get_post( $request->get_param( 'slug' ) );
 
-	$group_collection = new OD_URL_Metrics_Group_Collection(
+	$url_metric_group_collection = new OD_URL_Metric_Group_Collection(
 		$post instanceof WP_Post ? OD_URL_Metrics_Post_Type::get_url_metrics_from_post( $post ) : array(),
 		od_get_breakpoint_max_widths(),
 		od_get_url_metrics_breakpoint_sample_size(),
@@ -111,40 +111,43 @@
 
 	// Block the request if URL metrics aren't needed for the provided viewport width.
 	try {
-		$group = $group_collection->get_group_for_viewport_width(
+		$url_metric_group = $url_metric_group_collection->get_group_for_viewport_width(
 			$request->get_param( 'viewport' )['width']
 		);
 	} catch ( InvalidArgumentException $exception ) {
 		return new WP_Error( 'invalid_viewport_width', $exception->getMessage() );
 	}
-	if ( $group->is_complete() ) {
+	if ( $url_metric_group->is_complete() ) {
 		return new WP_Error(
-			'url_metrics_group_complete',
-			__( 'The URL metrics group for the provided viewport is already complete.', 'optimization-detective' ),
+			'url_metric_group_complete',
+			__( 'The URL metric group for the provided viewport is already complete.', 'optimization-detective' ),
 			array( 'status' => 403 )
 		);
 	}
 
+	$data = $request->get_json_params();
+	if ( ! is_array( $data ) ) {
+		return new WP_Error(
+			'missing_array_json_body',
+			__( 'The request body is not JSON array.', 'optimization-detective' ),
+			array( 'status' => 400 )
+		);
+	}
+
 	OD_Storage_Lock::set_lock();
 
 	try {
-		$data = $request->get_params();
-		// Remove params which are only used for the REST API request and which are not part of a URL Metric.
-		unset(
-			$data['slug'],
-			$data['nonce']
-		);
-		$data = array_merge(
-			$data,
-			array(
-				// Now supply the readonly args which were omitted from the REST API params due to being `readonly`.
-				'timestamp' => microtime( true ),
-				'uuid'      => wp_generate_uuid4(),
+		// The "strict" URL Metric class is being used here to ensure additionalProperties of all objects are disallowed.
+		$url_metric = new OD_Strict_URL_Metric(
+			array_merge(
+				$data,
+				array(
+					// Now supply the readonly args which were omitted from the REST API params due to being `readonly`.
+					'timestamp' => microtime( true ),
+					'uuid'      => wp_generate_uuid4(),
+				)
 			)
 		);
-
-		// The "strict" URL Metric class is being used here to ensure additionalProperties of all objects are disallowed.
-		$url_metric = new OD_Strict_URL_Metric( $data );
 	} catch ( OD_Data_Validation_Exception $e ) {
 		return new WP_Error(
 			'rest_invalid_param',
@@ -169,28 +172,20 @@
 	$post_id = $result;
 
 	/**
-	 * Fires whenever a URL Metric was successfully collected.
+	 * Fires whenever a URL Metric was successfully stored.
 	 *
-	 * @since 0.6.0
+	 * @since 0.7.0
 	 *
-	 * @param array $context {
-	 *     Context about the successful URL Metric collection.
-	 *
-	 *     @var int                                   $post_id
-	 *     @var WP_REST_Request<array<string, mixed>> $request
-	 *     @var OD_Strict_URL_Metric                  $url_metric
-	 *     @var OD_URL_Metrics_Group                  $group
-	 *     @var OD_URL_Metrics_Group_Collection       $group_collection
-	 * }
+	 * @param OD_URL_Metric_Store_Request_Context $context Context about the successful URL Metric collection.
 	 */
 	do_action(
-		'od_url_metric_collected',
-		array(
-			'post_id'                      => $post_id,
-			'request'                      => $request,
-			'url_metric'                   => $url_metric,
-			'url_metrics_group'            => $group,
-			'url_metrics_group_collection' => $group_collection,
+		'od_url_metric_stored',
+		new OD_URL_Metric_Store_Request_Context(
+			$request,
+			$post_id,
+			$url_metric_group_collection,
+			$url_metric_group,
+			$url_metric
 		)
 	);
 

performance-lab

Important

Stable tag change: 3.4.1 → 3.5.0

svn status:

M       includes/admin/plugins.php
M       load.php
M       readme.txt
svn diff
Index: includes/admin/plugins.php
===================================================================
--- includes/admin/plugins.php	(revision 3172589)
+++ includes/admin/plugins.php	(working copy)
@@ -19,15 +19,15 @@
  * @return array{name: string, slug: string, short_description: string, requires: string|false, requires_php: string|false, requires_plugins: string[], download_link: string, version: string}|WP_Error Array of plugin data or WP_Error if failed.
  */
 function perflab_query_plugin_info( string $plugin_slug ) {
-	$plugin = get_transient( 'perflab_plugin_info_' . $plugin_slug );
+	$transient_key = 'perflab_plugins_info';
+	$plugins       = get_transient( $transient_key );
 
-	if ( is_array( $plugin ) ) {
-		/**
-		 * Validated (mostly) plugin data.
-		 *
-		 * @var array{name: string, slug: string, short_description: string, requires: string|false, requires_php: string|false, requires_plugins: string[], download_link: string, version: string} $plugin
-		 */
-		return $plugin;
+	if ( is_array( $plugins ) ) {
+		// If the specific plugin_slug is not in the cache, return an error.
+		if ( ! isset( $plugins[ $plugin_slug ] ) ) {
+			return new WP_Error( 'plugin_not_found', __( 'Plugin not found.', 'performance-lab' ) );
+		}
+		return $plugins[ $plugin_slug ]; // Return cached plugin info if found.
 	}
 
 	$fields = array(
@@ -41,36 +41,54 @@
 		'version', // Needed by install_plugin_install_status().
 	);
 
-	$plugin = plugins_api(
-		'plugin_information',
+	// Proceed with API request since no cache hit.
+	$response = plugins_api(
+		'query_plugins',
 		array(
-			'slug'   => $plugin_slug,
-			'fields' => array_fill_keys( $fields, true ),
+			'author'   => 'wordpressdotorg',
+			'tag'      => 'performance',
+			'per_page' => 100,
+			'fields'   => array_fill_keys( $fields, true ),
 		)
 	);
 
-	if ( is_wp_error( $plugin ) ) {
-		return $plugin;
+	if ( is_wp_error( $response ) ) {
+		return new WP_Error(
+			'api_error',
+			sprintf(
+				/* translators: %s: API error message */
+				__( 'Failed to retrieve plugins data from WordPress.org API: %s', 'performance-lab' ),
+				$response->get_error_message()
+			)
+		);
 	}
 
-	if ( is_object( $plugin ) ) {
-		$plugin = (array) $plugin;
+	// Check if the response contains plugins.
+	if ( ! ( is_object( $response ) && property_exists( $response, 'plugins' ) ) ) {
+		return new WP_Error( 'no_plugins', __( 'No plugins found in the API response.', 'performance-lab' ) );
 	}
 
-	// Only store what we need.
-	$plugin = wp_array_slice_assoc( $plugin, $fields );
+	$plugins            = array();
+	$standalone_plugins = array_flip( perflab_get_standalone_plugins() );
+	foreach ( $response->plugins as $plugin_data ) {
+		if ( ! isset( $standalone_plugins[ $plugin_data['slug'] ] ) ) {
+			continue;
+		}
+		$plugins[ $plugin_data['slug'] ] = wp_array_slice_assoc( $plugin_data, $fields );
+	}
 
-	// Make sure all fields default to false in case another plugin is modifying the response from WordPress.org via the plugins_api filter.
-	$plugin = array_merge( array_fill_keys( $fields, false ), $plugin );
+	set_transient( $transient_key, $plugins, HOUR_IN_SECONDS );
 
-	set_transient( 'perflab_plugin_info_' . $plugin_slug, $plugin, HOUR_IN_SECONDS );
+	if ( ! isset( $plugins[ $plugin_slug ] ) ) {
+		return new WP_Error( 'plugin_not_found', __( 'Plugin not found.', 'performance-lab' ) );
+	}
 
 	/**
 	 * Validated (mostly) plugin data.
 	 *
-	 * @var array{name: string, slug: string, short_description: string, requires: string|false, requires_php: string|false, requires_plugins: string[], download_link: string, version: string} $plugin
+	 * @var array<string, array{name: string, slug: string, short_description: string, requires: string|false, requires_php: string|false, requires_plugins: string[], download_link: string, version: string}> $plugins
 	 */
-	return $plugin;
+	return $plugins[ $plugin_slug ];
 }
 
 /**
Index: load.php
===================================================================
--- load.php	(revision 3172589)
+++ load.php	(working copy)
@@ -5,7 +5,7 @@
  * Description: Performance plugin from the WordPress Performance Team, which is a collection of standalone performance features.
  * Requires at least: 6.5
  * Requires PHP: 7.2
- * Version: 3.4.1
+ * Version: 3.5.0
  * Author: WordPress Performance Team
  * Author URI: https://make.wordpress.org/performance/
  * License: GPLv2 or later
@@ -19,7 +19,7 @@
 	exit; // Exit if accessed directly.
 }
 
-define( 'PERFLAB_VERSION', '3.4.1' );
+define( 'PERFLAB_VERSION', '3.5.0' );
 define( 'PERFLAB_MAIN_FILE', __FILE__ );
 define( 'PERFLAB_PLUGIN_DIR_PATH', plugin_dir_path( PERFLAB_MAIN_FILE ) );
 define( 'PERFLAB_SCREEN', 'performance-lab' );
@@ -114,6 +114,10 @@
 		'speculation-rules'       => array(
 			'constant' => 'SPECULATION_RULES_VERSION',
 		),
+		'web-worker-offloading'   => array(
+			'constant'     => 'WEB_WORKER_OFFLOADING_VERSION',
+			'experimental' => true,
+		),
 		'webp-uploads'            => array(
 			'constant' => 'WEBP_UPLOADS_VERSION',
 		),
Index: readme.txt
===================================================================
--- readme.txt	(revision 3172589)
+++ readme.txt	(working copy)
@@ -2,7 +2,7 @@
 
 Contributors: wordpressdotorg
 Tested up to: 6.6
-Stable tag:   3.4.1
+Stable tag:   3.5.0
 License:      GPLv2 or later
 License URI:  https://www.gnu.org/licenses/gpl-2.0.html
 Tags:         performance, site health, measurement, optimization, diagnostics
@@ -70,6 +70,14 @@
 
 == Changelog ==
 
+= 3.5.0 =
+
+**Enhancements**
+
+* Add Web Worker Offloading to list of Performance features. ([1577](https://github.com/WordPress/performance/pull/1577))
+* Only store info for relevant standalone plugins in the transient cache. ([1573](https://github.com/WordPress/performance/pull/1573))
+* Use a single WordPress.org API request to get information for all plugins. ([1562](https://github.com/WordPress/performance/pull/1562))
+
 = 3.4.1 =
 
 **Bug Fixes**

speculation-rules

Warning

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

svn status:

M       class-plsr-url-pattern-prefixer.php
M       helper.php
M       hooks.php
M       load.php
M       readme.txt
M       settings.php
svn diff
Index: class-plsr-url-pattern-prefixer.php
===================================================================
--- class-plsr-url-pattern-prefixer.php	(revision 3172589)
+++ class-plsr-url-pattern-prefixer.php	(working copy)
@@ -35,7 +35,7 @@
 	 *                                        by the {@see PLSR_URL_Pattern_Prefixer::get_default_contexts()} method.
 	 */
 	public function __construct( array $contexts = array() ) {
-		if ( $contexts ) {
+		if ( count( $contexts ) > 0 ) {
 			$this->contexts = array_map(
 				static function ( string $str ): string {
 					return self::escape_pattern_string( trailingslashit( $str ) );
Index: helper.php
===================================================================
--- helper.php	(revision 3172589)
+++ helper.php	(working copy)
@@ -19,22 +19,11 @@
  *
  * @since 1.0.0
  *
- * @return array<string, array<int, array<string, mixed>>> Associative array of speculation rules by type.
+ * @return non-empty-array<string, array<int, array<string, mixed>>> Associative array of speculation rules by type.
  */
 function plsr_get_speculation_rules(): array {
-	$option = get_option( 'plsr_speculation_rules' );
-
-	/*
-	 * This logic is only relevant for edge-cases where the setting may not be registered,
-	 * a.k.a. defensive coding.
-	 */
-	if ( ! $option || ! is_array( $option ) ) {
-		$option = plsr_get_setting_default();
-	} else {
-		$option = array_merge( plsr_get_setting_default(), $option );
-	}
-
-	$mode      = (string) $option['mode'];
+	$option    = plsr_get_stored_setting_value();
+	$mode      = $option['mode'];
 	$eagerness = $option['eagerness'];
 
 	$prefixer = new PLSR_URL_Pattern_Prefixer();
Index: hooks.php
===================================================================
--- hooks.php	(revision 3172589)
+++ hooks.php	(working copy)
@@ -19,30 +19,10 @@
  * @since 1.0.0
  */
 function plsr_print_speculation_rules(): void {
-	$rules = plsr_get_speculation_rules();
-	if ( empty( $rules ) ) {
-		return;
-	}
-
-	// This workaround is needed for WP 6.4. See <https://core.trac.wordpress.org/ticket/60320>.
-	$needs_html5_workaround = (
-		! current_theme_supports( 'html5', 'script' ) &&
-		version_compare( (string) strtok( (string) get_bloginfo( 'version' ), '-' ), '6.4', '>=' ) &&
-		version_compare( (string) strtok( (string) get_bloginfo( 'version' ), '-' ), '6.5', '<' )
-	);
-	if ( $needs_html5_workaround ) {
-		$backup_wp_theme_features = $GLOBALS['_wp_theme_features'];
-		add_theme_support( 'html5', array( 'script' ) );
-	}
-
 	wp_print_inline_script_tag(
-		(string) wp_json_encode( $rules ),
+		(string) wp_json_encode( plsr_get_speculation_rules() ),
 		array( 'type' => 'speculationrules' )
 	);
-
-	if ( $needs_html5_workaround ) {
-		$GLOBALS['_wp_theme_features'] = $backup_wp_theme_features; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
-	}
 }
 add_action( 'wp_footer', 'plsr_print_speculation_rules' );
 
Index: load.php
===================================================================
--- load.php	(revision 3172589)
+++ load.php	(working copy)
@@ -3,7 +3,7 @@
  * 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 when hovering over links.
- * Requires at least: 6.4
+ * Requires at least: 6.5
  * Requires PHP: 7.2
  * Version: 1.3.1
  * Author: WordPress Performance Team
Index: readme.txt
===================================================================
--- readme.txt	(revision 3172589)
+++ readme.txt	(working copy)
@@ -1,13 +1,11 @@
 === Speculative Loading ===
 
-Contributors:      wordpressdotorg
-Requires at least: 6.4
-Tested up to:      6.5
-Requires PHP:      7.2
-Stable tag:        1.3.1
-License:           GPLv2 or later
-License URI:       https://www.gnu.org/licenses/gpl-2.0.html
-Tags:              performance, javascript, speculation rules, prerender, prefetch
+Contributors: wordpressdotorg
+Tested up to: 6.6
+Stable tag:   1.3.1
+License:      GPLv2 or later
+License URI:  https://www.gnu.org/licenses/gpl-2.0.html
+Tags:         performance, javascript, speculation rules, prerender, prefetch
 
 Enables browsers to speculatively prerender or prefetch pages when hovering over links.
 
Index: settings.php
===================================================================
--- settings.php	(revision 3172589)
+++ settings.php	(working copy)
@@ -16,7 +16,7 @@
  *
  * @since 1.0.0
  *
- * @return array<string, string> Associative array of `$mode => $label` pairs.
+ * @return array{ prefetch: string, prerender: string } Associative array of `$mode => $label` pairs.
  */
 function plsr_get_mode_labels(): array {
 	return array(
@@ -30,7 +30,7 @@
  *
  * @since 1.0.0
  *
- * @return array<string, string> Associative array of `$eagerness => $label` pairs.
+ * @return array{ conservative: string, moderate: string, eager: string } Associative array of `$eagerness => $label` pairs.
  */
 function plsr_get_eagerness_labels(): array {
 	return array(
@@ -45,7 +45,7 @@
  *
  * @since 1.0.0
  *
- * @return array<string, string> {
+ * @return array{ mode: 'prerender', eagerness: 'moderate' } {
  *     Default setting value.
  *
  *     @type string $mode      Mode.
@@ -60,12 +60,29 @@
 }
 
 /**
+ * Returns the stored setting value for Speculative Loading configuration.
+ *
+ * @since n.e.x.t
+ *
+ * @return array{ mode: 'prefetch'|'prerender', eagerness: 'conservative'|'moderate'|'eager' } {
+ *     Stored setting value.
+ *
+ *     @type string $mode      Mode.
+ *     @type string $eagerness Eagerness.
+ * }
+ */
+function plsr_get_stored_setting_value(): array {
+	return plsr_sanitize_setting( get_option( 'plsr_speculation_rules' ) );
+}
+
+/**
  * Sanitizes the setting for Speculative Loading configuration.
  *
  * @since 1.0.0
+ * @todo  Consider whether the JSON schema for the setting could be reused here.
  *
  * @param mixed $input Setting to sanitize.
- * @return array<string, string> {
+ * @return array{ mode: 'prefetch'|'prerender', eagerness: 'conservative'|'moderate'|'eager' } {
  *     Sanitized setting.
  *
  *     @type string $mode      Mode.
@@ -79,17 +96,14 @@
 		return $default_value;
 	}
 
-	$mode_labels      = plsr_get_mode_labels();
-	$eagerness_labels = plsr_get_eagerness_labels();
-
 	// Ensure only valid keys are present.
-	$value = array_intersect_key( $input, $default_value );
+	$value = array_intersect_key( array_merge( $default_value, $input ), $default_value );
 
-	// Set any missing or invalid values to their defaults.
-	if ( ! isset( $value['mode'] ) || ! isset( $mode_labels[ $value['mode'] ] ) ) {
+	// Constrain values to what is allowed.
+	if ( ! in_array( $value['mode'], array_keys( plsr_get_mode_labels() ), true ) ) {
 		$value['mode'] = $default_value['mode'];
 	}
-	if ( ! isset( $value['eagerness'] ) || ! isset( $eagerness_labels[ $value['eagerness'] ] ) ) {
+	if ( ! in_array( $value['eagerness'], array_keys( plsr_get_eagerness_labels() ), true ) ) {
 		$value['eagerness'] = $default_value['eagerness'];
 	}
 
@@ -113,7 +127,8 @@
 			'default'           => plsr_get_setting_default(),
 			'show_in_rest'      => array(
 				'schema' => array(
-					'properties' => array(
+					'type'                 => 'object',
+					'properties'           => array(
 						'mode'      => array(
 							'description' => __( 'Whether to prefetch or prerender URLs.', 'speculation-rules' ),
 							'type'        => 'string',
@@ -125,6 +140,7 @@
 							'enum'        => array_keys( plsr_get_eagerness_labels() ),
 						),
 					),
+					'additionalProperties' => false,
 				),
 			),
 		)
@@ -188,7 +204,7 @@
  * @since 1.0.0
  * @access private
  *
- * @param array<string, string> $args {
+ * @param array{ field: 'mode'|'eagerness', title: non-empty-string, description: non-empty-string } $args {
  *     Associative array of arguments.
  *
  *     @type string $field       The slug of the sub setting controlled by the field.
@@ -197,28 +213,24 @@
  * }
  */
 function plsr_render_settings_field( array $args ): void {
-	if ( empty( $args['field'] ) || empty( $args['title'] ) ) { // Invalid.
-		return;
-	}
+	$option = plsr_get_stored_setting_value();
 
-	$option = get_option( 'plsr_speculation_rules' );
-	if ( ! isset( $option[ $args['field'] ] ) ) { // Invalid.
-		return;
+	switch ( $args['field'] ) {
+		case 'mode':
+			$choices = plsr_get_mode_labels();
+			break;
+		case 'eagerness':
+			$choices = plsr_get_eagerness_labels();
+			break;
+		default:
+			return; // Invalid (and this case should never occur).
 	}
 
-	$value    = $option[ $args['field'] ];
-	$callback = "plsr_get_{$args['field']}_labels";
-	if ( ! is_callable( $callback ) ) {
-		return;
-	}
-	$choices = call_user_func( $callback );
-
+	$value = $option[ $args['field'] ];
 	?>
 	<fieldset>
 		<legend class="screen-reader-text"><?php echo esc_html( $args['title'] ); ?></legend>
-		<?php
-		foreach ( $choices as $slug => $label ) {
-			?>
+		<?php foreach ( $choices as $slug => $label ) : ?>
 			<p>
 				<label>
 					<input
@@ -230,17 +242,11 @@
 					<?php echo esc_html( $label ); ?>
 				</label>
 			</p>
-			<?php
-		}
+		<?php endforeach; ?>
 
-		if ( ! empty( $args['description'] ) ) {
-			?>
-			<p class="description" style="max-width: 800px;">
-				<?php echo esc_html( $args['description'] ); ?>
-			</p>
-			<?php
-		}
-		?>
+		<p class="description" style="max-width: 800px;">
+			<?php echo esc_html( $args['description'] ); ?>
+		</p>
 	</fieldset>
 	<?php
 }

web-worker-offloading

Important

Stable tag change: 0.1.0 → 0.1.1

svn status:

M       hooks.php
M       load.php
M       readme.txt
svn diff
Index: hooks.php
===================================================================
--- hooks.php	(revision 3172589)
+++ hooks.php	(working copy)
@@ -121,3 +121,16 @@
 	return $attributes;
 }
 add_filter( 'wp_inline_script_attributes', 'plwwo_filter_inline_script_attributes' );
+
+/**
+ * Displays the HTML generator meta tag for the Web Worker Offloading plugin.
+ *
+ * See {@see 'wp_head'}.
+ *
+ * @since 0.1.1
+ */
+function plwwo_render_generator_meta_tag(): void {
+	// Use the plugin slug as it is immutable.
+	echo '<meta name="generator" content="web-worker-offloading ' . esc_attr( WEB_WORKER_OFFLOADING_VERSION ) . '">' . "\n";
+}
+add_action( 'wp_head', 'plwwo_render_generator_meta_tag' );
Index: load.php
===================================================================
--- load.php	(revision 3172589)
+++ load.php	(working copy)
@@ -2,10 +2,10 @@
 /**
  * Plugin Name: Web Worker Offloading
  * Plugin URI: https://github.com/WordPress/performance/issues/176
- * Description: Offload JavaScript execution to a Web Worker.
+ * 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.5
  * Requires PHP: 7.2
- * Version: 0.1.0
+ * Version: 0.1.1
  * Author: WordPress Performance Team
  * Author URI: https://make.wordpress.org/performance/
  * License: GPLv2 or later
@@ -43,7 +43,7 @@
 	);
 }
 
-define( 'WEB_WORKER_OFFLOADING_VERSION', '0.1.0' );
+define( 'WEB_WORKER_OFFLOADING_VERSION', '0.1.1' );
 
 require_once __DIR__ . '/helper.php';
 require_once __DIR__ . '/hooks.php';
Index: readme.txt
===================================================================
--- readme.txt	(revision 3172589)
+++ readme.txt	(working copy)
@@ -2,12 +2,12 @@
 
 Contributors:      wordpressdotorg
 Tested up to:      6.6
-Stable tag:        0.1.0
+Stable tag:        0.1.1
 License:           GPLv2 or later
 License URI:       https://www.gnu.org/licenses/gpl-2.0.html
 Tags:              performance, JavaScript, web worker, partytown, analytics
 
-Offload JavaScript execution to a Web Worker.
+Offloads select JavaScript execution to a Web Worker to reduce work on the main thread and improve the Interaction to Next Paint (INP) metric.
 
 == Description ==
 
@@ -94,6 +94,12 @@
 
 == Changelog ==
 
+= 0.1.1 =
+
+**Enhancements**
+
+* Add Web Worker Offloading meta generator. ([1598](https://github.com/WordPress/performance/pull/1598))
+
 = 0.1.0 =
 
 * Initial release.

webp-uploads

Note

No changes.

@westonruter
Copy link
Member Author

@westonruter westonruter marked this pull request as ready for review October 21, 2024 04:24
@github-actions
Copy link

github-actions bot commented Oct 21, 2024

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

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

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

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

@westonruter
Copy link
Member Author

I've installed the updated plugins on my blog:

image

The Web Worker Offloading plugin is getting the generator meta tag:

<meta name="generator" content="web-worker-offloading 0.1.1">

I can confirm that posters for LCP video elements are getting preloaded, and that poster images are getting reduced from the full size. Videos are also getting lazy loaded.

I added a Slideshow block from Jetpack in the initial viewport and I confirmed that images in non-initial slides are getting fetchpriority=low.

I've also confirmed that embeds are still getting lazy-loaded. They are also getting space reserved for them to reduce layout shift.

I tried updating Optimization Detective first and confirmed that the stale versions of Image Prioritizer and Embed Optimizer both continued to work as expected.

@swissspidy
Copy link
Member

New Image Prioritizer changes work like a charm on my site too! 🎉 🚀

Doc: Add `Web Worker Offloading` in readme files
=== Embed Optimizer ===

Contributors: wordpressdotorg
Tested up to: 6.6
Copy link
Member

Choose a reason for hiding this comment

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

@westonruter Do we want to already bump this to 6.7 here and elsewhere? The next releases will be after 6.7, so would be good to already bump. We already run everything against trunk so I think it's reasonable to mark it as such.

Copy link
Member Author

Choose a reason for hiding this comment

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

Yeah, great idea

Copy link
Member Author

Choose a reason for hiding this comment

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

Done in a56c171

Co-authored-by: felixarntz <[email protected]>
Copy link
Member

@felixarntz felixarntz left a comment

Choose a reason for hiding this comment

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

Thanks @westonruter, LGTM!

@westonruter westonruter merged commit 6cc4ba5 into release/3.5.0 Oct 21, 2024
15 of 16 checks passed
@westonruter westonruter deleted the publish/3.5.0 branch October 21, 2024 16:35
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants