diff --git a/plugins/embed-optimizer/detect.js b/plugins/embed-optimizer/detect.js index 1aab3d083..38068387b 100644 --- a/plugins/embed-optimizer/detect.js +++ b/plugins/embed-optimizer/detect.js @@ -51,7 +51,7 @@ const loadedElementContentRects = new Map(); * @type {InitializeCallback} * @param {InitializeArgs} args Args. */ -export function initialize( { isDebug } ) { +export async function initialize( { isDebug } ) { /** @type NodeListOf */ const embedWrappers = document.querySelectorAll( '.wp-block-embed > .wp-block-embed__wrapper[data-od-xpath]' diff --git a/plugins/image-prioritizer/class-image-prioritizer-background-image-styled-tag-visitor.php b/plugins/image-prioritizer/class-image-prioritizer-background-image-styled-tag-visitor.php index a778f867d..c6570d4d2 100644 --- a/plugins/image-prioritizer/class-image-prioritizer-background-image-styled-tag-visitor.php +++ b/plugins/image-prioritizer/class-image-prioritizer-background-image-styled-tag-visitor.php @@ -14,6 +14,13 @@ /** * Tag visitor that optimizes elements with background-image styles. * + * @phpstan-type LcpElementExternalBackgroundImage array{ + * url: non-empty-string, + * tag: non-empty-string, + * id: string|null, + * class: string|null, + * } + * * @since 0.1.0 * @access private */ @@ -35,6 +42,14 @@ final class Image_Prioritizer_Background_Image_Styled_Tag_Visitor extends Image_ */ private $added_lazy_assets = false; + /** + * Tuples of URL Metric group and the common LCP element external background image. + * + * @since n.e.x.t + * @var array + */ + private $group_common_lcp_element_external_background_images; + /** * Visits a tag. * @@ -65,6 +80,7 @@ public function __invoke( OD_Tag_Visitor_Context $context ): bool { } if ( is_null( $background_image_url ) ) { + $this->maybe_preload_external_lcp_background_image( $context ); return false; } @@ -72,19 +88,7 @@ public function __invoke( OD_Tag_Visitor_Context $context ): bool { // If this element is the LCP (for a breakpoint group), add a preload link for it. foreach ( $context->url_metric_group_collection->get_groups_by_lcp_element( $xpath ) as $group ) { - $link_attributes = array( - 'rel' => 'preload', - 'fetchpriority' => 'high', - 'as' => 'image', - 'href' => $background_image_url, - 'media' => 'screen', - ); - - $context->link_collection->add_link( - $link_attributes, - $group->get_minimum_viewport_width(), - $group->get_maximum_viewport_width() - ); + $this->add_image_preload_link( $context->link_collection, $group, $background_image_url ); } $this->lazy_load_bg_images( $context ); @@ -92,6 +96,112 @@ public function __invoke( OD_Tag_Visitor_Context $context ): bool { return true; } + /** + * Gets the common LCP element external background image for a URL Metric group. + * + * @since n.e.x.t + * + * @param OD_URL_Metric_Group $group Group. + * @return LcpElementExternalBackgroundImage|null + */ + private function get_common_lcp_element_external_background_image( OD_URL_Metric_Group $group ): ?array { + + // If the group is not fully populated, we don't have enough URL Metrics to reliably know whether the background image is consistent across page loads. + // This is intentionally not using $group->is_complete() because we still will use stale URL Metrics in the calculation. + if ( $group->count() !== $group->get_sample_size() ) { + return null; + } + + $previous_lcp_element_external_background_image = null; + foreach ( $group as $url_metric ) { + /** + * Stored data. + * + * @var LcpElementExternalBackgroundImage|null $lcp_element_external_background_image + */ + $lcp_element_external_background_image = $url_metric->get( 'lcpElementExternalBackgroundImage' ); + if ( ! is_array( $lcp_element_external_background_image ) ) { + return null; + } + if ( null !== $previous_lcp_element_external_background_image && $previous_lcp_element_external_background_image !== $lcp_element_external_background_image ) { + return null; + } + $previous_lcp_element_external_background_image = $lcp_element_external_background_image; + } + + return $previous_lcp_element_external_background_image; + } + + /** + * Maybe preloads external background image. + * + * @since n.e.x.t + * + * @param OD_Tag_Visitor_Context $context Context. + */ + private function maybe_preload_external_lcp_background_image( OD_Tag_Visitor_Context $context ): void { + // Gather the tuples of URL Metric group and the common LCP element external background image. + // Note the groups of URL Metrics do not change across invocations, we just need to compute this once for all. + if ( ! is_array( $this->group_common_lcp_element_external_background_images ) ) { + $this->group_common_lcp_element_external_background_images = array(); + foreach ( $context->url_metric_group_collection as $group ) { + $common = $this->get_common_lcp_element_external_background_image( $group ); + if ( is_array( $common ) ) { + $this->group_common_lcp_element_external_background_images[] = array( $group, $common ); + } + } + } + + // There are no common LCP background images, so abort. + if ( count( $this->group_common_lcp_element_external_background_images ) === 0 ) { + return; + } + + $processor = $context->processor; + $tag_name = strtoupper( (string) $processor->get_tag() ); + foreach ( array_keys( $this->group_common_lcp_element_external_background_images ) as $i ) { + list( $group, $common ) = $this->group_common_lcp_element_external_background_images[ $i ]; + if ( + // Note that the browser may send a lower-case tag name in the case of XHTML or embedded SVG/MathML, but + // the HTML Tag Processor is currently normalizing to all upper-case. The HTML Processor on the other + // hand may return the expected case. + strtoupper( $common['tag'] ) === $tag_name + && + $processor->get_attribute( 'id' ) === $common['id'] // May be checking equality with null. + && + $processor->get_attribute( 'class' ) === $common['class'] // May be checking equality with null. + ) { + $this->add_image_preload_link( $context->link_collection, $group, $common['url'] ); + + // Now that the preload link has been added, eliminate the entry to stop looking for it while iterating over the rest of the document. + unset( $this->group_common_lcp_element_external_background_images[ $i ] ); + } + } + } + + /** + * Adds an image preload link for the group. + * + * @since n.e.x.t + * + * @param OD_Link_Collection $link_collection Link collection. + * @param OD_URL_Metric_Group $group URL Metric group. + * @param non-empty-string $url Image URL. + */ + private function add_image_preload_link( OD_Link_Collection $link_collection, OD_URL_Metric_Group $group, string $url ): void { + $link_collection->add_link( + array( + 'rel' => 'preload', + 'fetchpriority' => 'high', + 'as' => 'image', + 'href' => $url, + 'media' => 'screen', + ), + $group->get_minimum_viewport_width(), + $group->get_maximum_viewport_width() + ); + } + /** * Optimizes an element with a background image based on whether it is displayed in any initial viewport. * diff --git a/plugins/image-prioritizer/detect.js b/plugins/image-prioritizer/detect.js new file mode 100644 index 000000000..6a73074c2 --- /dev/null +++ b/plugins/image-prioritizer/detect.js @@ -0,0 +1,227 @@ +/** + * Image Prioritizer module for Optimization Detective + * + * This extension to Optimization Detective captures the LCP element's CSS background image which is not defined with + * an inline style attribute but rather in either an external stylesheet loaded with a LINK tag or by stylesheet in + * a STYLE element. The URL for this LCP background image and the tag's name, ID, and class are all amended to the + * stored URL Metric so that a responsive preload link with fetchpriority=high will be added for that background image + * once a URL Metric group is fully populated with URL Metrics that all agree on that being the LCP image, and if the + * document has a tag with the same name, ID, and class. + */ + +const consoleLogPrefix = '[Image Prioritizer]'; + +/** + * Detected LCP external background image candidates. + * + * @type {Array<{ + * url: string, + * tag: string, + * id: string|null, + * class: string|null, + * }>} + */ +const externalBackgroundImages = []; + +/** + * @typedef {import("web-vitals").LCPMetric} LCPMetric + * @typedef {import("../optimization-detective/types.ts").InitializeCallback} InitializeCallback + * @typedef {import("../optimization-detective/types.ts").InitializeArgs} InitializeArgs + * @typedef {import("../optimization-detective/types.ts").FinalizeArgs} FinalizeArgs + * @typedef {import("../optimization-detective/types.ts").FinalizeCallback} FinalizeCallback + */ + +/** + * Logs a message. + * + * @since n.e.x.t + * + * @param {...*} message + */ +function log( ...message ) { + // eslint-disable-next-line no-console + console.log( consoleLogPrefix, ...message ); +} + +/** + * Logs a warning. + * + * @since n.e.x.t + * + * @param {...*} message + */ +function warn( ...message ) { + // eslint-disable-next-line no-console + console.warn( consoleLogPrefix, ...message ); +} + +/** + * Initializes extension. + * + * @since n.e.x.t + * + * @type {InitializeCallback} + * @param {InitializeArgs} args Args. + */ +export async function initialize( { isDebug, onLCP } ) { + onLCP( + ( metric ) => { + handleLCPMetric( metric, isDebug ); + }, + { + // This avoids needing to click to finalize LCP candidate. While this is helpful for testing, it also + // ensures that we always get an LCP candidate reported. Otherwise, the callback may never fire if the + // user never does a click or keydown, per . + reportAllChanges: true, + } + ); +} + +/** + * Gets the performance resource entry for a given URL. + * + * @since n.e.x.t + * + * @param {string} url - Resource URL. + * @return {PerformanceResourceTiming|null} Resource entry or null. + */ +function getPerformanceResourceByURL( url ) { + const entries = + /** @type PerformanceResourceTiming[] */ performance.getEntriesByType( + 'resource' + ); + for ( const entry of entries ) { + if ( entry.name === url ) { + return entry; + } + } + return null; +} + +/** + * Handles a new LCP metric being reported. + * + * @since n.e.x.t + * + * @param {LCPMetric} metric - LCP Metric. + * @param {boolean} isDebug - Whether in debug mode. + */ +function handleLCPMetric( metric, isDebug ) { + for ( const entry of metric.entries ) { + // Look only for LCP entries that have a URL and a corresponding element which is not an IMG or VIDEO. + if ( + ! entry.url || + ! ( entry.element instanceof HTMLElement ) || + entry.element instanceof HTMLImageElement || + entry.element instanceof HTMLVideoElement + ) { + continue; + } + + // Always ignore data: URLs. + if ( entry.url.startsWith( 'data:' ) ) { + continue; + } + + // Skip elements that have the background image defined inline. + // These are handled by Image_Prioritizer_Background_Image_Styled_Tag_Visitor. + if ( entry.element.style.backgroundImage ) { + continue; + } + + // Now only consider proceeding with the URL if its loading was initiated with stylesheet or preload link. + const resourceEntry = getPerformanceResourceByURL( entry.url ); + if ( + ! resourceEntry || + ! [ 'css', 'link' ].includes( resourceEntry.initiatorType ) + ) { + if ( isDebug ) { + warn( + `Skipped considering URL (${ entry.url }) due to unexpected performance resource timing entry:`, + resourceEntry + ); + } + return; + } + + // Skip URLs that are excessively long. This is the maxLength defined in image_prioritizer_add_element_item_schema_properties(). + if ( entry.url.length > 500 ) { + if ( isDebug ) { + log( `Skipping very long URL: ${ entry.url }` ); + } + return; + } + + // Also skip Custom Elements which have excessively long tag names. This is the maxLength defined in image_prioritizer_add_element_item_schema_properties(). + if ( entry.element.tagName.length > 100 ) { + if ( isDebug ) { + log( + `Skipping very long tag name: ${ entry.element.tagName }` + ); + } + return; + } + + // Note that getAttribute() is used instead of properties so that null can be returned in case of an absent attribute. + // The maxLengths are defined in image_prioritizer_add_element_item_schema_properties(). + const id = entry.element.getAttribute( 'id' ); + if ( typeof id === 'string' && id.length > 100 ) { + if ( isDebug ) { + log( `Skipping very long ID: ${ id }` ); + } + return; + } + const className = entry.element.getAttribute( 'class' ); + if ( typeof className === 'string' && className.length > 500 ) { + if ( isDebug ) { + log( `Skipping very long className: ${ className }` ); + } + return; + } + + // The id and className allow the tag visitor to detect whether the element is still in the document. + // This is used instead of having a full XPath which is likely not available since the tag visitor would not + // know to return true for this element since it has no awareness of which elements have external backgrounds. + const externalBackgroundImage = { + url: entry.url, + tag: entry.element.tagName, + id, + class: className, + }; + + if ( isDebug ) { + log( + 'Detected external LCP background image:', + externalBackgroundImage + ); + } + + externalBackgroundImages.push( externalBackgroundImage ); + } +} + +/** + * Finalizes extension. + * + * @since n.e.x.t + * + * @type {FinalizeCallback} + * @param {FinalizeArgs} args Args. + */ +export async function finalize( { extendRootData, isDebug } ) { + if ( externalBackgroundImages.length === 0 ) { + return; + } + + // Get the last detected external background image which is going to be for the LCP element (or very likely will be). + const lcpElementExternalBackgroundImage = externalBackgroundImages.pop(); + + if ( isDebug ) { + log( + 'Sending external background image for LCP element:', + lcpElementExternalBackgroundImage + ); + } + + extendRootData( { lcpElementExternalBackgroundImage } ); +} diff --git a/plugins/image-prioritizer/helper.php b/plugins/image-prioritizer/helper.php index a62677c54..1651dcf55 100644 --- a/plugins/image-prioritizer/helper.php +++ b/plugins/image-prioritizer/helper.php @@ -18,7 +18,7 @@ * @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'; + $required_od_version = '0.9.0'; if ( ! version_compare( (string) strtok( $optimization_detective_version, '-' ), $required_od_version, '>=' ) ) { add_action( 'admin_notices', @@ -77,6 +77,66 @@ function image_prioritizer_register_tag_visitors( OD_Tag_Visitor_Registry $regis $registry->register( 'image-prioritizer/video', $video_visitor ); } +/** + * Filters the list of Optimization Detective extension module URLs to include the extension for Image Prioritizer. + * + * @since n.e.x.t + * + * @param string[]|mixed $extension_module_urls Extension module URLs. + * @return string[] Extension module URLs. + */ +function image_prioritizer_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', IMAGE_PRIORITIZER_VERSION, plugin_dir_url( __FILE__ ) . image_prioritizer_get_asset_path( 'detect.js' ) ); + return $extension_module_urls; +} + +/** + * Filters additional properties for the element item schema for Optimization Detective. + * + * @since n.e.x.t + * + * @param array $additional_properties Additional properties. + * @return array Additional properties. + */ +function image_prioritizer_add_element_item_schema_properties( array $additional_properties ): array { + $additional_properties['lcpElementExternalBackgroundImage'] = array( + 'type' => 'object', + 'properties' => array( + 'url' => array( + 'type' => 'string', + 'format' => 'uri', // Note: This is excessively lax, as it is used exclusively in rest_sanitize_value_from_schema() and not in rest_validate_value_from_schema(). + 'pattern' => '^https?://', + 'required' => true, + 'maxLength' => 500, // Image URLs can be quite long. + ), + 'tag' => array( + 'type' => 'string', + 'required' => true, + 'minLength' => 1, + // The longest HTML tag name is 10 characters (BLOCKQUOTE and FIGCAPTION), but SVG tag names can be longer + // (e.g. feComponentTransfer). This maxLength accounts for possible Custom Elements that are even longer, + // although the longest known Custom Element from HTTP Archive is 32 characters. See data from . + 'maxLength' => 100, + 'pattern' => '^[a-zA-Z0-9\-]+\z', // Technically emoji can be allowed in a custom element's tag name, but this is not supported here. + ), + 'id' => array( + 'type' => array( 'string', 'null' ), + 'maxLength' => 100, // A reasonable upper-bound length for a long ID. + 'required' => true, + ), + 'class' => array( + 'type' => array( 'string', 'null' ), + 'maxLength' => 500, // There can be a ton of class names on an element. + 'required' => true, + ), + ), + ); + return $additional_properties; +} + /** * Gets the path to a script or stylesheet. * diff --git a/plugins/image-prioritizer/hooks.php b/plugins/image-prioritizer/hooks.php index 62d2fd315..7587e9e67 100644 --- a/plugins/image-prioritizer/hooks.php +++ b/plugins/image-prioritizer/hooks.php @@ -11,3 +11,5 @@ } add_action( 'od_init', 'image_prioritizer_init' ); +add_filter( 'od_extension_module_urls', 'image_prioritizer_filter_extension_module_urls' ); +add_filter( 'od_url_metric_schema_root_additional_properties', 'image_prioritizer_add_element_item_schema_properties' ); diff --git a/plugins/image-prioritizer/tests/test-cases/lcp-element-external-background-image-complete-samples-but-element-absent.php b/plugins/image-prioritizer/tests/test-cases/lcp-element-external-background-image-complete-samples-but-element-absent.php new file mode 100644 index 000000000..f4047e739 --- /dev/null +++ b/plugins/image-prioritizer/tests/test-cases/lcp-element-external-background-image-complete-samples-but-element-absent.php @@ -0,0 +1,74 @@ + static function ( Test_Image_Prioritizer_Helper $test_case ): void { + add_filter( + 'od_breakpoint_max_widths', + static function () { + return array( 480, 600, 782 ); + } + ); + + $slug = od_get_url_metrics_slug( od_get_normalized_query_vars() ); + $sample_size = od_get_url_metrics_breakpoint_sample_size(); + + $bg_images = array( + 'https://example.com/mobile.jpg', + 'https://example.com/tablet.jpg', + 'https://example.com/phablet.jpg', + 'https://example.com/desktop.jpg', + ); + + // Fully populate all viewport groups, but for all except desktop record that the LCP element had a different tag, id, or class. + foreach ( array_merge( od_get_breakpoint_max_widths(), array( 1000 ) ) as $i => $viewport_width ) { + for ( $j = 0; $j < $sample_size; $j++ ) { + OD_URL_Metrics_Post_Type::store_url_metric( + $slug, + $test_case->get_sample_url_metric( + array( + 'viewport_width' => $viewport_width, + 'elements' => array(), + 'extended_root' => array( + 'lcpElementExternalBackgroundImage' => array( + 'url' => $bg_images[ $i ], + 'tag' => 0 === $i ? 'DIV' : 'HEADER', + 'id' => 1 === $i ? 'foo' : 'masthead', + 'class' => 2 === $i ? 'bar' : 'banner', + ), + ), + ) + ) + ); + } + } + }, + 'buffer' => ' + + + + ... + + + + + + + ', + 'expected' => ' + + + + ... + + + + + + + + ', +); diff --git a/plugins/image-prioritizer/tests/test-cases/lcp-element-external-background-image-present-in-document-and-fully-populated-samples.php b/plugins/image-prioritizer/tests/test-cases/lcp-element-external-background-image-present-in-document-and-fully-populated-samples.php new file mode 100644 index 000000000..d73c475ec --- /dev/null +++ b/plugins/image-prioritizer/tests/test-cases/lcp-element-external-background-image-present-in-document-and-fully-populated-samples.php @@ -0,0 +1,77 @@ + static function ( Test_Image_Prioritizer_Helper $test_case ): void { + add_filter( + 'od_breakpoint_max_widths', + static function () { + return array( 480, 600, 782 ); + } + ); + + $slug = od_get_url_metrics_slug( od_get_normalized_query_vars() ); + $sample_size = od_get_url_metrics_breakpoint_sample_size(); + + $bg_images = array( + 'https://example.com/mobile.jpg', + 'https://example.com/tablet.jpg', + 'https://example.com/phablet.jpg', + 'https://example.com/desktop.jpg', + ); + + // Fully populate all viewport groups. + foreach ( array_merge( od_get_breakpoint_max_widths(), array( 1000 ) ) as $i => $viewport_width ) { + for ( $j = 0; $j < $sample_size; $j++ ) { + OD_URL_Metrics_Post_Type::store_url_metric( + $slug, + $test_case->get_sample_url_metric( + array( + 'viewport_width' => $viewport_width, + 'elements' => array(), + 'extended_root' => array( + 'lcpElementExternalBackgroundImage' => array( + 'url' => $bg_images[ $i ], + 'tag' => 'HEADER', + 'id' => 'masthead', + 'class' => 'banner', + ), + ), + ) + ) + ); + } + } + }, + 'buffer' => ' + + + + ... + + + + + + + ', + 'expected' => ' + + + + ... + + + + + + + + + + + ', +); diff --git a/plugins/image-prioritizer/tests/test-cases/lcp-element-external-background-image-present-in-document-and-partially-populated-samples.php b/plugins/image-prioritizer/tests/test-cases/lcp-element-external-background-image-present-in-document-and-partially-populated-samples.php new file mode 100644 index 000000000..c967d5e9c --- /dev/null +++ b/plugins/image-prioritizer/tests/test-cases/lcp-element-external-background-image-present-in-document-and-partially-populated-samples.php @@ -0,0 +1,101 @@ + static function ( Test_Image_Prioritizer_Helper $test_case ): void { + add_filter( + 'od_breakpoint_max_widths', + static function () { + return array( 480, 600, 782 ); + } + ); + + $slug = od_get_url_metrics_slug( od_get_normalized_query_vars() ); + $sample_size = od_get_url_metrics_breakpoint_sample_size(); + + $bg_images = array( + 'https://example.com/mobile.jpg', + 'https://example.com/tablet.jpg', + 'https://example.com/phablet.jpg', + 'https://example.com/desktop.jpg', + ); + + $viewport_sample_sizes = array( + $sample_size, + $sample_size - 1, + 0, + $sample_size, + ); + + // Partially populate all viewport groups. + foreach ( array_merge( od_get_breakpoint_max_widths(), array( 1000 ) ) as $i => $viewport_width ) { + for ( $j = 0; $j < $viewport_sample_sizes[ $i ]; $j++ ) { + OD_URL_Metrics_Post_Type::store_url_metric( + $slug, + $test_case->get_sample_url_metric( + array( + 'viewport_width' => $viewport_width, + 'elements' => array(), + 'extended_root' => array( + 'lcpElementExternalBackgroundImage' => array( + 'url' => $bg_images[ $i ], + 'tag' => 'HEADER', + 'id' => 'masthead', + 'class' => 'banner', + ), + ), + ) + ) + ); + } + } + + // Store one more URL metric for desktop which has a different background image. + OD_URL_Metrics_Post_Type::store_url_metric( + $slug, + $test_case->get_sample_url_metric( + array( + 'viewport_width' => 1000, + 'elements' => array(), + 'extended_root' => array( + 'lcpElementExternalBackgroundImage' => array( + 'url' => 'https://example.com/desktop-alt.jpg', + 'tag' => 'HEADER', + 'id' => 'masthead', + 'class' => 'banner', + ), + ), + ) + ) + ); + }, + 'buffer' => ' + + + + ... + + + + + + + ', + 'expected' => ' + + + + ... + + + + + + + + + ', +); diff --git a/plugins/image-prioritizer/tests/test-helper.php b/plugins/image-prioritizer/tests/test-helper.php index b6054fbb1..180a2d807 100644 --- a/plugins/image-prioritizer/tests/test-helper.php +++ b/plugins/image-prioritizer/tests/test-helper.php @@ -33,7 +33,7 @@ public function data_provider_to_test_image_prioritizer_init(): array { 'expected' => false, ), 'with_new_version' => array( - 'version' => '0.7.0', + 'version' => '99.0.0', 'expected' => true, ), ); @@ -84,7 +84,7 @@ public function data_provider_test_filter_tag_visitors(): array { } /** - * Test image_prioritizer_register_tag_visitors(). + * Test end-to-end. * * @covers ::image_prioritizer_register_tag_visitors * @covers Image_Prioritizer_Tag_Visitor @@ -97,7 +97,7 @@ public function data_provider_test_filter_tag_visitors(): array { * @param callable|string $buffer Content before. * @param callable|string $expected Expected content after. */ - public function test_image_prioritizer_register_tag_visitors( callable $set_up, $buffer, $expected ): void { + public function test_end_to_end( callable $set_up, $buffer, $expected ): void { $set_up( $this, $this::factory() ); $buffer = is_string( $buffer ) ? $buffer : $buffer(); @@ -219,7 +219,7 @@ public function data_provider_test_auto_sizes(): array { * @dataProvider data_provider_test_auto_sizes * @phpstan-param array{ xpath: string, isLCP: bool, intersectionRatio: int } $element_metrics */ - public function test_auto_sizes( array $element_metrics, string $buffer, string $expected ): void { + public function test_auto_sizes_end_to_end( array $element_metrics, string $buffer, string $expected ): void { $this->populate_url_metrics( array( $element_metrics ) ); $html_start_doc = '...'; @@ -236,30 +236,264 @@ public function test_auto_sizes( array $element_metrics, string $buffer, string ); } + /** + * Test image_prioritizer_register_tag_visitors. + * + * @covers ::image_prioritizer_register_tag_visitors + */ + public function test_image_prioritizer_register_tag_visitors(): void { + $registry = new OD_Tag_Visitor_Registry(); + image_prioritizer_register_tag_visitors( $registry ); + $this->assertTrue( $registry->is_registered( 'image-prioritizer/img' ) ); + $this->assertTrue( $registry->is_registered( 'image-prioritizer/background-image' ) ); + $this->assertTrue( $registry->is_registered( 'image-prioritizer/video' ) ); + } + + /** + * Test image_prioritizer_filter_extension_module_urls. + * + * @covers ::image_prioritizer_filter_extension_module_urls + */ + public function test_image_prioritizer_filter_extension_module_urls(): void { + $initial_modules = array( + home_url( '/module.js' ), + ); + $filtered_modules = image_prioritizer_filter_extension_module_urls( $initial_modules ); + $this->assertCount( 2, $filtered_modules ); + $this->assertSame( $initial_modules[0], $filtered_modules[0] ); + $this->assertStringContainsString( 'detect.', $filtered_modules[1] ); + } + + /** + * Test image_prioritizer_add_element_item_schema_properties. + * + * @covers ::image_prioritizer_add_element_item_schema_properties + */ + public function test_image_prioritizer_add_element_item_schema_properties(): void { + $initial_schema = array( + 'foo' => array( + 'type' => 'string', + ), + ); + $filtered_schema = image_prioritizer_add_element_item_schema_properties( $initial_schema ); + $this->assertCount( 2, $filtered_schema ); + $this->assertArrayHasKey( 'foo', $filtered_schema ); + $this->assertArrayHasKey( 'lcpElementExternalBackgroundImage', $filtered_schema ); + $this->assertSame( 'object', $filtered_schema['lcpElementExternalBackgroundImage']['type'] ); + $this->assertSameSets( array( 'url', 'id', 'tag', 'class' ), array_keys( $filtered_schema['lcpElementExternalBackgroundImage']['properties'] ) ); + } + + /** + * @return array + */ + public function data_provider_for_test_image_prioritizer_add_element_item_schema_properties_inputs(): array { + return array( + 'bad_type' => array( + 'input_value' => 'not_an_object', + 'expected_exception' => 'OD_URL_Metric[lcpElementExternalBackgroundImage] is not of type object.', + 'output_value' => null, + ), + 'missing_props' => array( + 'input_value' => array(), + 'expected_exception' => 'url is a required property of OD_URL_Metric[lcpElementExternalBackgroundImage].', + 'output_value' => null, + ), + 'bad_url_protocol' => array( + 'input_value' => array( + 'url' => 'javascript:alert(1)', + 'tag' => 'DIV', + 'id' => null, + 'class' => null, + ), + 'expected_exception' => 'OD_URL_Metric[lcpElementExternalBackgroundImage][url] does not match pattern ^https?://.', + 'output_value' => null, + ), + 'bad_url_format' => array( + 'input_value' => array( + 'url' => 'https://not a valid URL!!!', + 'tag' => 'DIV', + 'id' => null, + 'class' => null, + ), + 'expected_exception' => null, + 'output_value' => array( + 'url' => 'https://not%20a%20valid%20URL!!!', // This is due to sanitize_url() being used in core. More validation is needed. + 'tag' => 'DIV', + 'id' => null, + 'class' => null, + ), + ), + 'bad_url_length' => array( + 'input_value' => array( + 'url' => 'https://example.com/' . str_repeat( 'a', 501 ), + 'tag' => 'DIV', + 'id' => null, + 'class' => null, + ), + 'expected_exception' => 'OD_URL_Metric[lcpElementExternalBackgroundImage][url] must be at most 500 characters long.', + 'output_value' => null, + ), + 'bad_null_tag' => array( + 'input_value' => array( + 'url' => 'https://example.com/', + 'tag' => null, + 'id' => null, + 'class' => null, + ), + 'expected_exception' => 'OD_URL_Metric[lcpElementExternalBackgroundImage][tag] is not of type string.', + 'output_value' => null, + ), + 'bad_format_tag' => array( + 'input_value' => array( + 'url' => 'https://example.com/', + 'tag' => 'bad tag name!!', + 'id' => null, + 'class' => null, + ), + 'expected_exception' => 'OD_URL_Metric[lcpElementExternalBackgroundImage][tag] does not match pattern ^[a-zA-Z0-9\-]+\z.', + 'output_value' => null, + ), + 'bad_length_tag' => array( + 'input_value' => array( + 'url' => 'https://example.com/', + 'tag' => str_repeat( 'a', 101 ), + 'id' => null, + 'class' => null, + ), + 'expected_exception' => 'OD_URL_Metric[lcpElementExternalBackgroundImage][tag] must be at most 100 characters long.', + 'output_value' => null, + ), + 'bad_type_id' => array( + 'input_value' => array( + 'url' => 'https://example.com/', + 'tag' => 'DIV', + 'id' => array( 'bad' ), + 'class' => null, + ), + 'expected_exception' => 'OD_URL_Metric[lcpElementExternalBackgroundImage][id] is not of type string,null.', + 'output_value' => null, + ), + 'bad_length_id' => array( + 'input_value' => array( + 'url' => 'https://example.com/', + 'tag' => 'DIV', + 'id' => str_repeat( 'a', 101 ), + 'class' => null, + ), + 'expected_exception' => 'OD_URL_Metric[lcpElementExternalBackgroundImage][id] must be at most 100 characters long.', + 'output_value' => null, + ), + 'bad_type_class' => array( + 'input_value' => array( + 'url' => 'https://example.com/', + 'tag' => 'DIV', + 'id' => 'main', + 'class' => array( 'bad' ), + ), + 'expected_exception' => 'OD_URL_Metric[lcpElementExternalBackgroundImage][class] is not of type string,null.', + 'output_value' => null, + ), + 'bad_length_class' => array( + 'input_value' => array( + 'url' => 'https://example.com/', + 'tag' => 'DIV', + 'id' => 'main', + 'class' => str_repeat( 'a', 501 ), + ), + 'expected_exception' => 'OD_URL_Metric[lcpElementExternalBackgroundImage][class] must be at most 500 characters long.', + 'output_value' => null, + ), + 'ok_minimal' => array( + 'input_value' => array( + 'url' => 'https://example.com/bg.jpg', + 'tag' => 'DIV', + 'id' => null, + 'class' => null, + ), + 'expected_exception' => null, + 'output_value' => array( + 'url' => 'https://example.com/bg.jpg', + 'tag' => 'DIV', + 'id' => null, + 'class' => null, + ), + ), + 'ok_maximal' => array( + 'input_value' => array( + 'url' => 'https://example.com/' . str_repeat( 'a', 476 ) . '.jpg', + 'tag' => str_repeat( 'a', 100 ), + 'id' => str_repeat( 'b', 100 ), + 'class' => str_repeat( 'c', 500 ), + ), + 'expected_exception' => null, + 'output_value' => array( + 'url' => 'https://example.com/' . str_repeat( 'a', 476 ) . '.jpg', + 'tag' => str_repeat( 'a', 100 ), + 'id' => str_repeat( 'b', 100 ), + 'class' => str_repeat( 'c', 500 ), + ), + ), + ); + } + + /** + * Test image_prioritizer_add_element_item_schema_properties for various inputs. + * + * @covers ::image_prioritizer_add_element_item_schema_properties + * + * @dataProvider data_provider_for_test_image_prioritizer_add_element_item_schema_properties_inputs + * + * @param mixed $input_value Input value. + * @param string|null $expected_exception Expected exception message. + * @param array|null $output_value Output value. + */ + public function test_image_prioritizer_add_element_item_schema_properties_inputs( $input_value, ?string $expected_exception, ?array $output_value ): void { + $data = $this->get_sample_url_metric( array() )->jsonSerialize(); + $data['lcpElementExternalBackgroundImage'] = $input_value; + $exception_message = null; + try { + $url_metric = new OD_URL_Metric( $data ); + } catch ( OD_Data_Validation_Exception $e ) { + $exception_message = $e->getMessage(); + } + + $this->assertSame( + $expected_exception, + $exception_message, + isset( $url_metric ) ? 'Data: ' . wp_json_encode( $url_metric->jsonSerialize(), JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES ) : '' + ); + if ( isset( $url_metric ) ) { + $this->assertSame( $output_value, $url_metric->jsonSerialize()['lcpElementExternalBackgroundImage'] ); + } + } + /** * Test image_prioritizer_get_video_lazy_load_script. * * @covers ::image_prioritizer_get_video_lazy_load_script + * @covers ::image_prioritizer_get_asset_path */ public function test_image_prioritizer_get_video_lazy_load_script(): void { - $this->assertGreaterThan( 0, strlen( image_prioritizer_get_video_lazy_load_script() ) ); + $this->assertStringContainsString( 'new IntersectionObserver', image_prioritizer_get_video_lazy_load_script() ); } /** * Test image_prioritizer_get_lazy_load_bg_image_script. * * @covers ::image_prioritizer_get_lazy_load_bg_image_script + * @covers ::image_prioritizer_get_asset_path */ public function test_image_prioritizer_get_lazy_load_bg_image_script(): void { - $this->assertGreaterThan( 0, strlen( image_prioritizer_get_lazy_load_bg_image_script() ) ); + $this->assertStringContainsString( 'new IntersectionObserver', image_prioritizer_get_lazy_load_bg_image_script() ); } /** * Test image_prioritizer_get_lazy_load_bg_image_stylesheet. * * @covers ::image_prioritizer_get_lazy_load_bg_image_stylesheet + * @covers ::image_prioritizer_get_asset_path */ public function test_image_prioritizer_get_lazy_load_bg_image_stylesheet(): void { - $this->assertGreaterThan( 0, strlen( image_prioritizer_get_lazy_load_bg_image_stylesheet() ) ); + $this->assertStringContainsString( '.od-lazy-bg-image', image_prioritizer_get_lazy_load_bg_image_stylesheet() ); } } diff --git a/plugins/image-prioritizer/tests/test-hooks.php b/plugins/image-prioritizer/tests/test-hooks.php new file mode 100644 index 000000000..740c71a3d --- /dev/null +++ b/plugins/image-prioritizer/tests/test-hooks.php @@ -0,0 +1,18 @@ +assertEquals( 10, has_action( 'od_init', 'image_prioritizer_init' ) ); + $this->assertEquals( 10, has_filter( 'od_extension_module_urls', 'image_prioritizer_filter_extension_module_urls' ) ); + $this->assertEquals( 10, has_filter( 'od_url_metric_schema_root_additional_properties', 'image_prioritizer_add_element_item_schema_properties' ) ); + } +} diff --git a/plugins/optimization-detective/class-od-url-metric-group.php b/plugins/optimization-detective/class-od-url-metric-group.php index 9f026f9ff..b32f4b8b6 100644 --- a/plugins/optimization-detective/class-od-url-metric-group.php +++ b/plugins/optimization-detective/class-od-url-metric-group.php @@ -24,6 +24,8 @@ final class OD_URL_Metric_Group implements IteratorAggregate, Countable, JsonSer /** * URL Metrics. * + * @since 0.1.0 + * * @var OD_URL_Metric[] */ private $url_metrics; @@ -31,6 +33,8 @@ final class OD_URL_Metric_Group implements IteratorAggregate, Countable, JsonSer /** * Minimum possible viewport width for the group (inclusive). * + * @since 0.1.0 + * * @var int * @phpstan-var 0|positive-int */ @@ -39,6 +43,8 @@ final class OD_URL_Metric_Group implements IteratorAggregate, Countable, JsonSer /** * Maximum possible viewport width for the group (inclusive). * + * @since 0.1.0 + * * @var int * @phpstan-var positive-int */ @@ -47,6 +53,8 @@ final class OD_URL_Metric_Group implements IteratorAggregate, Countable, JsonSer /** * Sample size for URL Metrics for a given breakpoint. * + * @since 0.1.0 + * * @var int * @phpstan-var positive-int */ @@ -55,6 +63,8 @@ final class OD_URL_Metric_Group implements IteratorAggregate, Countable, JsonSer /** * Freshness age (TTL) for a given URL Metric. * + * @since 0.1.0 + * * @var int * @phpstan-var 0|positive-int */ @@ -63,6 +73,8 @@ final class OD_URL_Metric_Group implements IteratorAggregate, Countable, JsonSer /** * Collection that this instance belongs to. * + * @since 0.3.0 + * * @var OD_URL_Metric_Group_Collection */ private $collection; @@ -70,6 +82,8 @@ final class OD_URL_Metric_Group implements IteratorAggregate, Countable, JsonSer /** * Result cache. * + * @since 0.3.0 + * * @var array{ * get_lcp_element?: OD_Element|null, * is_complete?: bool, @@ -145,6 +159,8 @@ public function __construct( array $url_metrics, int $minimum_viewport_width, in /** * Gets the minimum possible viewport width (inclusive). * + * @since 0.1.0 + * * @todo Eliminate in favor of readonly public property. * @return int<0, max> Minimum viewport width. */ @@ -155,6 +171,8 @@ public function get_minimum_viewport_width(): int { /** * Gets the maximum possible viewport width (inclusive). * + * @since 0.1.0 + * * @todo Eliminate in favor of readonly public property. * @return int<1, max> Minimum viewport width. */ @@ -163,7 +181,35 @@ public function get_maximum_viewport_width(): int { } /** - * Checks whether the provided viewport width is within the minimum/maximum range for + * Gets the sample size for URL Metrics for a given breakpoint. + * + * @since n.e.x.t + * + * @todo Eliminate in favor of readonly public property. + * @phpstan-return positive-int + * @return int Sample size. + */ + public function get_sample_size(): int { + return $this->sample_size; + } + + /** + * Gets the freshness age (TTL) for a given URL Metric. + * + * @since n.e.x.t + * + * @todo Eliminate in favor of readonly public property. + * @phpstan-return 0|positive-int + * @return int Freshness age. + */ + public function get_freshness_ttl(): int { + return $this->freshness_ttl; + } + + /** + * Checks whether the provided viewport width is within the minimum/maximum range for. + * + * @since 0.1.0 * * @param int $viewport_width Viewport width. * @return bool Whether the viewport width is in range. @@ -178,6 +224,8 @@ public function is_viewport_width_in_range( int $viewport_width ): bool { /** * Adds a URL Metric to the group. * + * @since 0.1.0 + * * @throws InvalidArgumentException If the viewport width of the URL Metric is not within the min/max bounds of the group. * * @param OD_URL_Metric $url_metric URL Metric. @@ -217,6 +265,7 @@ static function ( OD_URL_Metric $a, OD_URL_Metric $b ): int { * A group is complete if it has the full sample size of URL Metrics * and all of these URL Metrics are fresh. * + * @since 0.1.0 * @since n.e.x.t If the current environment's generated ETag does not match the URL Metric's ETag, the URL Metric is considered stale. * * @return bool Whether complete. @@ -258,6 +307,8 @@ public function is_complete(): bool { /** * Gets the LCP element in the viewport group. * + * @since 0.3.0 + * * @return OD_Element|null LCP element data or null if not available, either because there are no URL Metrics or * the LCP element type is not supported. */ @@ -401,6 +452,8 @@ public function get_element_max_intersection_ratio( string $xpath ): ?float { /** * Returns an iterator for the URL Metrics in the group. * + * @since 0.1.0 + * * @return ArrayIterator ArrayIterator for OD_URL_Metric instances. */ public function getIterator(): ArrayIterator { @@ -410,6 +463,8 @@ public function getIterator(): ArrayIterator { /** * Counts the URL Metrics in the group. * + * @since 0.1.0 + * * @return int<0, max> URL Metric count. */ public function count(): int { diff --git a/plugins/optimization-detective/detect.js b/plugins/optimization-detective/detect.js index 40d91375c..fe4518c9e 100644 --- a/plugins/optimization-detective/detect.js +++ b/plugins/optimization-detective/detect.js @@ -1,6 +1,11 @@ /** * @typedef {import("web-vitals").LCPMetric} LCPMetric * @typedef {import("./types.ts").ElementData} ElementData + * @typedef {import("./types.ts").OnTTFBFunction} OnTTFBFunction + * @typedef {import("./types.ts").OnFCPFunction} OnFCPFunction + * @typedef {import("./types.ts").OnLCPFunction} OnLCPFunction + * @typedef {import("./types.ts").OnINPFunction} OnINPFunction + * @typedef {import("./types.ts").OnCLSFunction} OnCLSFunction * @typedef {import("./types.ts").URLMetric} URLMetric * @typedef {import("./types.ts").URLMetricGroupStatus} URLMetricGroupStatus * @typedef {import("./types.ts").Extension} Extension @@ -335,6 +340,14 @@ export default async function detect( { { once: true } ); + const { + /** @type OnTTFBFunction */ onTTFB, + /** @type OnFCPFunction */ onFCP, + /** @type OnLCPFunction */ onLCP, + /** @type OnINPFunction */ onINP, + /** @type OnCLSFunction */ onCLS, + } = await import( webVitalsLibrarySrc ); + // TODO: Does this make sense here? // Prevent detection when page is not scrolled to the initial viewport. if ( doc.documentElement.scrollTop > 0 ) { @@ -352,23 +365,57 @@ export default async function detect( { /** @type {Map} */ const extensions = new Map(); + + /** @type {Promise[]} */ + const extensionInitializePromises = []; + + /** @type {string[]} */ + const initializingExtensionModuleUrls = []; + for ( const extensionModuleUrl of extensionModuleUrls ) { try { /** @type {Extension} */ const extension = await import( extensionModuleUrl ); extensions.set( extensionModuleUrl, extension ); - // TODO: There should to be a way to pass additional args into the module. Perhaps extensionModuleUrls should be a mapping of URLs to args. It's important to pass webVitalsLibrarySrc to the extension so that onLCP, onCLS, or onINP can be obtained. + // TODO: There should to be a way to pass additional args into the module. Perhaps extensionModuleUrls should be a mapping of URLs to args. if ( extension.initialize instanceof Function ) { - extension.initialize( { isDebug } ); + const initializePromise = extension.initialize( { + isDebug, + onTTFB, + onFCP, + onLCP, + onINP, + onCLS, + } ); + if ( initializePromise instanceof Promise ) { + extensionInitializePromises.push( initializePromise ); + initializingExtensionModuleUrls.push( extensionModuleUrl ); + } } } catch ( err ) { error( - `Failed to initialize extension '${ extensionModuleUrl }':`, + `Failed to start initializing extension '${ extensionModuleUrl }':`, err ); } } + // Wait for all extensions to finish initializing. + const settledInitializePromises = await Promise.allSettled( + extensionInitializePromises + ); + for ( const [ + i, + settledInitializePromise, + ] of settledInitializePromises.entries() ) { + if ( settledInitializePromise.status === 'rejected' ) { + error( + `Failed to initialize extension '${ initializingExtensionModuleUrls[ i ] }':`, + settledInitializePromise.reason + ); + } + } + const breadcrumbedElements = doc.body.querySelectorAll( '[data-od-xpath]' ); /** @type {Map} */ @@ -424,8 +471,6 @@ export default async function detect( { } ); } - const { onLCP } = await import( webVitalsLibrarySrc ); - /** @type {LCPMetric[]} */ const lcpMetricCandidates = []; @@ -529,27 +574,55 @@ export default async function detect( { } if ( extensions.size > 0 ) { + /** @type {Promise[]} */ + const extensionFinalizePromises = []; + + /** @type {string[]} */ + const finalizingExtensionModuleUrls = []; + for ( const [ extensionModuleUrl, extension, ] of extensions.entries() ) { if ( extension.finalize instanceof Function ) { try { - await extension.finalize( { + const finalizePromise = extension.finalize( { isDebug, getRootData, getElementData, extendElementData, extendRootData, } ); + if ( finalizePromise instanceof Promise ) { + extensionFinalizePromises.push( finalizePromise ); + finalizingExtensionModuleUrls.push( + extensionModuleUrl + ); + } } catch ( err ) { error( - `Unable to finalize module '${ extensionModuleUrl }':`, + `Unable to start finalizing extension '${ extensionModuleUrl }':`, err ); } } } + + // Wait for all extensions to finish finalizing. + const settledFinalizePromises = await Promise.allSettled( + extensionFinalizePromises + ); + for ( const [ + i, + settledFinalizePromise, + ] of settledFinalizePromises.entries() ) { + if ( settledFinalizePromise.status === 'rejected' ) { + error( + `Failed to finalize extension '${ finalizingExtensionModuleUrls[ i ] }':`, + settledFinalizePromise.reason + ); + } + } } // Even though the server may reject the REST API request, we still have to set the storage lock diff --git a/plugins/optimization-detective/storage/rest-api.php b/plugins/optimization-detective/storage/rest-api.php index 4de890f96..38f3c9dda 100644 --- a/plugins/optimization-detective/storage/rest-api.php +++ b/plugins/optimization-detective/storage/rest-api.php @@ -94,7 +94,7 @@ function od_register_endpoint(): void { return new WP_Error( 'url_metric_storage_locked', __( 'URL Metric storage is presently locked for the current IP.', 'optimization-detective' ), - array( 'status' => 403 ) + array( 'status' => 403 ) // TODO: Consider 423 Locked status code. ); } return true; @@ -163,6 +163,7 @@ function od_handle_rest_request( WP_REST_Request $request ) { $request->get_param( 'viewport' )['width'] ); } catch ( InvalidArgumentException $exception ) { + // Note: This should never happen because an exception only occurs if a viewport width is less than zero, and the JSON Schema enforces that the viewport.width have a minimum of zero. return new WP_Error( 'invalid_viewport_width', $exception->getMessage() ); } if ( $url_metric_group->is_complete() ) { diff --git a/plugins/optimization-detective/tests/test-class-od-url-metrics-group.php b/plugins/optimization-detective/tests/test-class-od-url-metrics-group.php index 2b1538c7a..2eee0717a 100644 --- a/plugins/optimization-detective/tests/test-class-od-url-metrics-group.php +++ b/plugins/optimization-detective/tests/test-class-od-url-metrics-group.php @@ -95,6 +95,8 @@ public function data_provider_test_construction(): array { * @covers ::__construct * @covers ::get_minimum_viewport_width * @covers ::get_maximum_viewport_width + * @covers ::get_sample_size + * @covers ::get_freshness_ttl * @covers ::getIterator * @covers ::count * @@ -121,6 +123,8 @@ public function test_construction( array $url_metrics, int $minimum_viewport_wid $this->assertCount( count( $url_metrics ), $group ); $this->assertSame( $minimum_viewport_width, $group->get_minimum_viewport_width() ); $this->assertSame( $maximum_viewport_width, $group->get_maximum_viewport_width() ); + $this->assertSame( $sample_size, $group->get_sample_size() ); + $this->assertSame( $freshness_ttl, $group->get_freshness_ttl() ); $this->assertCount( count( $url_metrics ), $group ); $this->assertSame( $url_metrics, iterator_to_array( $group ) ); } diff --git a/plugins/optimization-detective/types.ts b/plugins/optimization-detective/types.ts index fc4e375b6..d92c53214 100644 --- a/plugins/optimization-detective/types.ts +++ b/plugins/optimization-detective/types.ts @@ -1,6 +1,8 @@ // h/t https://stackoverflow.com/a/59801602/93579 type ExcludeProps< T > = { [ k: string ]: any } & { [ K in keyof T ]?: never }; +import { onTTFB, onFCP, onLCP, onINP, onCLS } from 'web-vitals'; + export interface ElementData { isLCP: boolean; isLCPCandidate: boolean; @@ -28,11 +30,22 @@ export interface URLMetricGroupStatus { complete: boolean; } +export type OnTTFBFunction = typeof onTTFB; +export type OnFCPFunction = typeof onFCP; +export type OnLCPFunction = typeof onLCP; +export type OnINPFunction = typeof onINP; +export type OnCLSFunction = typeof onCLS; + export type InitializeArgs = { readonly isDebug: boolean; + readonly onTTFB: OnTTFBFunction; + readonly onFCP: OnFCPFunction; + readonly onLCP: OnLCPFunction; + readonly onINP: OnINPFunction; + readonly onCLS: OnCLSFunction; }; -export type InitializeCallback = ( args: InitializeArgs ) => void; +export type InitializeCallback = ( args: InitializeArgs ) => Promise< void >; export type FinalizeArgs = { readonly getRootData: () => URLMetric; diff --git a/tests/class-optimization-detective-test-helpers.php b/tests/class-optimization-detective-test-helpers.php index c7924480a..0ddda0adb 100644 --- a/tests/class-optimization-detective-test-helpers.php +++ b/tests/class-optimization-detective-test-helpers.php @@ -86,6 +86,7 @@ public function get_sample_url_metric( array $params ): OD_URL_Metric { 'viewport_width' => 480, 'elements' => array(), 'timestamp' => microtime( true ), + 'extended_root' => array(), ), $params ); @@ -94,7 +95,7 @@ public function get_sample_url_metric( array $params ): OD_URL_Metric { $params['elements'][] = $params['element']; } - return new OD_URL_Metric( + $data = array_merge( array( 'etag' => $params['etag'], 'url' => $params['url'], @@ -118,8 +119,10 @@ function ( array $element ): array { }, $params['elements'] ), - ) + ), + $params['extended_root'] ); + return new OD_URL_Metric( $data ); } /** diff --git a/webpack.config.js b/webpack.config.js index 47744964d..faaaa3267 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -132,6 +132,10 @@ const imagePrioritizer = ( env ) => { plugins: [ new CopyWebpackPlugin( { patterns: [ + { + from: `${ pluginDir }/detect.js`, + to: `${ pluginDir }/detect.min.js`, + }, { from: `${ pluginDir }/lazy-load-video.js`, to: `${ pluginDir }/lazy-load-video.min.js`,