diff --git a/plugins/embed-optimizer/class-embed-optimizer-tag-visitor.php b/plugins/embed-optimizer/class-embed-optimizer-tag-visitor.php index aa968d7bf0..f4b98b14ae 100644 --- a/plugins/embed-optimizer/class-embed-optimizer-tag-visitor.php +++ b/plugins/embed-optimizer/class-embed-optimizer-tag-visitor.php @@ -26,9 +26,58 @@ final class Embed_Optimizer_Tag_Visitor { */ protected $added_lazy_script = false; + /** + * Determines whether the processor is currently at a figure.wp-block-embed tag. + * + * @since n.e.x.t + * + * @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 n.e.x.t + * + * @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: + * + *
+ *
+ * + * + *
+ *
+ * + * 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. @@ -36,16 +85,23 @@ final class Embed_Optimizer_Tag_Visitor { */ public function __invoke( OD_Tag_Visitor_Context $context ): bool { $processor = $context->processor; - if ( ! ( - 'FIGURE' === $processor->get_tag() - && - true === $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_metric_group_collection->get_element_max_intersection_ratio( $processor->get_xpath() ); + $this->reduce_layout_shifts( $context ); + $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. @@ -119,6 +175,83 @@ public function __invoke( OD_Tag_Visitor_Context $context ): bool { $this->added_lazy_script = true; } - return 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 . + */ + return false; + } + + /** + * Gets the XPath for the embed wrapper DIV which is the sole child of the embed block FIGURE. + * + * @since n.e.x.t + * + * @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 n.e.x.t + * + * @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 with. + * + * @var array $minimums + */ + $minimums = array(); + + $denormalized_elements = $context->url_metric_group_collection->get_all_denormalized_elements()[ $embed_wrapper_xpath ] ?? array(); + foreach ( $denormalized_elements as list( $group, $url_metric, $element ) ) { + if ( ! isset( $element['resizedBoundingClientRect'] ) ) { + continue; + } + $group_min_width = $group->get_minimum_viewport_width(); + if ( ! isset( $minimums[ $group_min_width ] ) ) { + $minimums[ $group_min_width ] = array( + 'group' => $group, + 'height' => $element['resizedBoundingClientRect']['height'], + ); + } else { + $minimums[ $group_min_width ]['height'] = min( + $minimums[ $group_min_width ]['height'], + $element['resizedBoundingClientRect']['height'] + ); + } + } + + // 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'] + ); + } + + $processor->append_head_html( sprintf( "\n", join( "\n", $style_rules ) ) ); + } } } diff --git a/plugins/embed-optimizer/detect.js b/plugins/embed-optimizer/detect.js new file mode 100644 index 0000000000..9a5f93e71d --- /dev/null +++ b/plugins/embed-optimizer/detect.js @@ -0,0 +1,124 @@ +/** + * Embed Optimizer module for Optimization Detective + * + * When a URL metric is being collected by Optimization Detective, this module adds a ResizeObserver to keep track of + * the changed heights for embed blocks. This data is extended/amended onto the element data of the pending URL metric + * when it is submitted for storage. + */ + +const consoleLogPrefix = '[Embed Optimizer]'; + +/** + * @typedef {import("../optimization-detective/types.d.ts").ElementData} ElementMetrics + * @typedef {import("../optimization-detective/types.d.ts").URLMetric} URLMetric + * @typedef {import("../optimization-detective/types.d.ts").Extension} Extension + * @typedef {import("../optimization-detective/types.d.ts").InitializeCallback} InitializeCallback + * @typedef {import("../optimization-detective/types.d.ts").InitializeArgs} InitializeArgs + * @typedef {import("../optimization-detective/types.d.ts").FinalizeArgs} FinalizeArgs + * @typedef {import("../optimization-detective/types.d.ts").FinalizeCallback} FinalizeCallback + * @typedef {import("../optimization-detective/types.d.ts").ExtendedElementData} ExtendedElementData + */ + +/** + * Logs a message. + * + * @param {...*} message + */ +function log( ...message ) { + // eslint-disable-next-line no-console + console.log( consoleLogPrefix, ...message ); +} + +/** + * Logs an error. + * + * @param {...*} message + */ +function error( ...message ) { + // eslint-disable-next-line no-console + console.error( consoleLogPrefix, ...message ); +} + +/** + * Embed element heights. + * + * @type {Map} + */ +const loadedElementContentRects = new Map(); + +/** + * Initializes extension. + * + * @type {InitializeCallback} + * @param {InitializeArgs} args Args. + */ +export function initialize( { isDebug } ) { + /** @type NodeListOf */ + const embedWrappers = document.querySelectorAll( + '.wp-block-embed > .wp-block-embed__wrapper[data-od-xpath]' + ); + + for ( const embedWrapper of embedWrappers ) { + monitorEmbedWrapperForResizes( embedWrapper, isDebug ); + } + + if ( isDebug ) { + log( 'Loaded embed content rects:', loadedElementContentRects ); + } +} + +/** + * Finalizes extension. + * + * @type {FinalizeCallback} + * @param {FinalizeArgs} args Args. + */ +export async function finalize( { + isDebug, + getElementData, + extendElementData, +} ) { + for ( const [ xpath, domRect ] of loadedElementContentRects.entries() ) { + try { + extendElementData( xpath, { + resizedBoundingClientRect: domRect, + } ); + if ( isDebug ) { + const elementData = getElementData( xpath ); + log( + `boundingClientRect for ${ xpath } resized:`, + elementData.boundingClientRect, + '=>', + domRect + ); + } + } catch ( err ) { + error( + `Failed to extend element data for ${ xpath } with resizedBoundingClientRect:`, + domRect, + err + ); + } + } +} + +/** + * Monitors embed wrapper for resizes. + * + * @param {HTMLDivElement} embedWrapper Embed wrapper DIV. + * @param {boolean} isDebug Whether debug. + */ +function monitorEmbedWrapperForResizes( embedWrapper, isDebug ) { + if ( ! ( 'odXpath' in embedWrapper.dataset ) ) { + throw new Error( 'Embed wrapper missing data-od-xpath attribute.' ); + } + const xpath = embedWrapper.dataset.odXpath; + const observer = new ResizeObserver( ( entries ) => { + const [ entry ] = entries; + loadedElementContentRects.set( xpath, entry.contentRect ); + if ( isDebug ) { + log( `Resized element ${ xpath }:`, entry.contentRect ); + } + } ); + observer.observe( embedWrapper, { box: 'content-box' } ); +} diff --git a/plugins/embed-optimizer/hooks.php b/plugins/embed-optimizer/hooks.php index 0e4e9e39fe..9f9624a105 100644 --- a/plugins/embed-optimizer/hooks.php +++ b/plugins/embed-optimizer/hooks.php @@ -18,14 +18,53 @@ 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 n.e.x.t + */ +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 n.e.x.t + * + * @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. * @@ -40,17 +79,85 @@ function embed_optimizer_register_tag_visitors( OD_Tag_Visitor_Registry $registr } /** - * Filter the oEmbed HTML. + * 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 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 n.e.x.t + * + * @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 n.e.x.t + * + * @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' ); diff --git a/plugins/embed-optimizer/load.php b/plugins/embed-optimizer/load.php index 1bca76100f..002cf97877 100644 --- a/plugins/embed-optimizer/load.php +++ b/plugins/embed-optimizer/load.php @@ -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-alpha * Author: WordPress Performance Team * Author URI: https://make.wordpress.org/performance/ * License: GPLv2 or later @@ -43,9 +43,14 @@ static function ( string $global_var_name, string $version, Closure $load ): voi } }; - // 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 @@ static function ( string $global_var_name, string $version, Closure $load ): voi } )( 'embed_optimizer_pending_plugin', - '0.2.0', + '0.3.0-alpha', static function ( string $version ): void { + if ( defined( 'EMBED_OPTIMIZER_VERSION' ) ) { + return; + } define( 'EMBED_OPTIMIZER_VERSION', $version ); diff --git a/plugins/embed-optimizer/tests/test-cases/nested-figure-embed.php b/plugins/embed-optimizer/tests/test-cases/nested-figure-embed.php index d96f9eb6ff..c9ae145167 100644 --- a/plugins/embed-optimizer/tests/test-cases/nested-figure-embed.php +++ b/plugins/embed-optimizer/tests/test-cases/nested-figure-embed.php @@ -1,12 +1,24 @@ static function ( Test_Embed_Optimizer_Optimization_Detective $test_case ): void { + $rect = array( + 'width' => 500.1, + 'height' => 500.2, + 'x' => 100.3, + 'y' => 100.4, + 'top' => 0.1, + 'right' => 0.2, + 'bottom' => 0.3, + 'left' => 0.4, + ); + $test_case->populate_url_metrics( array( array( - 'xpath' => '/*[1][self::HTML]/*[2][self::BODY]/*[1][self::FIGURE]', - 'isLCP' => false, - 'intersectionRatio' => 1, + 'xpath' => '/*[1][self::HTML]/*[2][self::BODY]/*[1][self::FIGURE]/*[1][self::DIV]', + 'isLCP' => false, + 'intersectionRatio' => 1, + 'resizedBoundingClientRect' => array_merge( $rect, array( 'height' => 500 ) ), ), array( 'xpath' => '/*[1][self::HTML]/*[2][self::BODY]/*[1][self::FIGURE]/*[1][self::DIV]/*[1][self::VIDEO]', @@ -14,9 +26,10 @@ 'intersectionRatio' => 1, ), array( - 'xpath' => '/*[1][self::HTML]/*[2][self::BODY]/*[2][self::FIGURE]', - 'isLCP' => false, - 'intersectionRatio' => 0, + 'xpath' => '/*[1][self::HTML]/*[2][self::BODY]/*[2][self::FIGURE]/*[1][self::DIV]', + 'isLCP' => false, + 'intersectionRatio' => 0, + 'resizedBoundingClientRect' => array_merge( $rect, array( 'height' => 654 ) ), ), array( 'xpath' => '/*[1][self::HTML]/*[2][self::BODY]/*[2][self::FIGURE]/*[1][self::DIV]/*[1][self::FIGURE]/*[2][self::VIDEO]', @@ -74,12 +87,12 @@ static function ( OD_Tag_Visitor_Context $context ) use ( $test_case ): bool { ... -
+
-
+

So I heard you like FIGURE?

@@ -98,16 +111,28 @@ static function ( OD_Tag_Visitor_Context $context ) use ( $test_case ): bool { ... + + -
-
+
+
-
-
+
+

So I heard you like FIGURE?

diff --git a/plugins/embed-optimizer/tests/test-cases/single-spotify-embed-outside-viewport-with-subsequent-script.php b/plugins/embed-optimizer/tests/test-cases/single-spotify-embed-outside-viewport-with-subsequent-script.php index 2867ff1303..6e2e483cfe 100644 --- a/plugins/embed-optimizer/tests/test-cases/single-spotify-embed-outside-viewport-with-subsequent-script.php +++ b/plugins/embed-optimizer/tests/test-cases/single-spotify-embed-outside-viewport-with-subsequent-script.php @@ -1,12 +1,25 @@ static function ( Test_Embed_Optimizer_Optimization_Detective $test_case ): void { + + $rect = array( + 'width' => 500.1, + 'height' => 500.2, + 'x' => 100.3, + 'y' => 100.4, + 'top' => 0.1, + 'right' => 0.2, + 'bottom' => 0.3, + 'left' => 0.4, + ); + $test_case->populate_url_metrics( array( array( - 'xpath' => '/*[1][self::HTML]/*[2][self::BODY]/*[1][self::FIGURE]', - 'isLCP' => false, - 'intersectionRatio' => 0, + 'xpath' => '/*[1][self::HTML]/*[2][self::BODY]/*[1][self::FIGURE]/*[1][self::DIV]', + 'isLCP' => false, + 'intersectionRatio' => 0, + 'resizedBoundingClientRect' => array_merge( $rect, array( 'height' => 500 ) ), ), ), false @@ -33,10 +46,16 @@ ... + -
-
+
+
diff --git a/plugins/embed-optimizer/tests/test-cases/single-twitter-embed-inside-viewport-without-resized-data.php b/plugins/embed-optimizer/tests/test-cases/single-twitter-embed-inside-viewport-without-resized-data.php new file mode 100644 index 0000000000..1cd9bb40fb --- /dev/null +++ b/plugins/embed-optimizer/tests/test-cases/single-twitter-embed-inside-viewport-without-resized-data.php @@ -0,0 +1,59 @@ + static function ( Test_Embed_Optimizer_Optimization_Detective $test_case ): void { + $rect = array( + 'width' => 500.1, + 'height' => 500.2, + 'x' => 100.3, + 'y' => 100.4, + 'top' => 0.1, + 'right' => 0.2, + 'bottom' => 0.3, + 'left' => 0.4, + ); + $test_case->populate_url_metrics( + array( + array( + 'xpath' => '/*[1][self::HTML]/*[2][self::BODY]/*[1][self::FIGURE]/*[1][self::DIV]', + 'isLCP' => true, + 'intersectionRatio' => 1, + // Intentionally omitting resizedBoundingClientRect here to test behavior when data isn't supplied. + ), + ) + ); + }, + 'buffer' => ' + + + + ... + + +
+
+ + +
+
+ + + ', + 'expected' => ' + + + + ... + + + + +
+
+ + +
+
+ + + ', +); diff --git a/plugins/embed-optimizer/tests/test-cases/single-twitter-embed-inside-viewport.php b/plugins/embed-optimizer/tests/test-cases/single-twitter-embed-inside-viewport.php index 1336dcfcbd..63968e9758 100644 --- a/plugins/embed-optimizer/tests/test-cases/single-twitter-embed-inside-viewport.php +++ b/plugins/embed-optimizer/tests/test-cases/single-twitter-embed-inside-viewport.php @@ -1,12 +1,23 @@ static function ( Test_Embed_Optimizer_Optimization_Detective $test_case ): void { + $rect = array( + 'width' => 500.1, + 'height' => 500.2, + 'x' => 100.3, + 'y' => 100.4, + 'top' => 0.1, + 'right' => 0.2, + 'bottom' => 0.3, + 'left' => 0.4, + ); $test_case->populate_url_metrics( array( array( - 'xpath' => '/*[1][self::HTML]/*[2][self::BODY]/*[1][self::FIGURE]', - 'isLCP' => true, - 'intersectionRatio' => 1, + 'xpath' => '/*[1][self::HTML]/*[2][self::BODY]/*[1][self::FIGURE]/*[1][self::DIV]', + 'isLCP' => true, + 'intersectionRatio' => 1, + 'resizedBoundingClientRect' => array_merge( $rect, array( 'height' => 500 ) ), ), ) ); @@ -32,11 +43,17 @@ ... + -
+
diff --git a/plugins/embed-optimizer/tests/test-cases/single-twitter-embed-outside-viewport.php b/plugins/embed-optimizer/tests/test-cases/single-twitter-embed-outside-viewport.php index 3da5a29fac..770c8de3e2 100644 --- a/plugins/embed-optimizer/tests/test-cases/single-twitter-embed-outside-viewport.php +++ b/plugins/embed-optimizer/tests/test-cases/single-twitter-embed-outside-viewport.php @@ -1,12 +1,23 @@ static function ( Test_Embed_Optimizer_Optimization_Detective $test_case ): void { + $rect = array( + 'width' => 500.1, + 'height' => 500.2, + 'x' => 100.3, + 'y' => 100.4, + 'top' => 0.1, + 'right' => 0.2, + 'bottom' => 0.3, + 'left' => 0.4, + ); $test_case->populate_url_metrics( array( array( - 'xpath' => '/*[1][self::HTML]/*[2][self::BODY]/*[1][self::FIGURE]', - 'isLCP' => false, - 'intersectionRatio' => 0, + 'xpath' => '/*[1][self::HTML]/*[2][self::BODY]/*[1][self::FIGURE]/*[1][self::DIV]', + 'isLCP' => false, + 'intersectionRatio' => 0, + 'resizedBoundingClientRect' => array_merge( $rect, array( 'height' => 500 ) ), ), ) ); @@ -32,9 +43,15 @@ ... + -
+
diff --git a/plugins/embed-optimizer/tests/test-cases/single-wordpress-tv-embed-inside-viewport.php b/plugins/embed-optimizer/tests/test-cases/single-wordpress-tv-embed-inside-viewport.php index 5992973bf4..24f56c8e4b 100644 --- a/plugins/embed-optimizer/tests/test-cases/single-wordpress-tv-embed-inside-viewport.php +++ b/plugins/embed-optimizer/tests/test-cases/single-wordpress-tv-embed-inside-viewport.php @@ -1,12 +1,23 @@ static function ( Test_Embed_Optimizer_Optimization_Detective $test_case ): void { + $rect = array( + 'width' => 500.1, + 'height' => 500.2, + 'x' => 100.3, + 'y' => 100.4, + 'top' => 0.1, + 'right' => 0.2, + 'bottom' => 0.3, + 'left' => 0.4, + ); $test_case->populate_url_metrics( array( array( - 'xpath' => '/*[1][self::HTML]/*[2][self::BODY]/*[1][self::FIGURE]', - 'isLCP' => true, - 'intersectionRatio' => 1, + 'xpath' => '/*[1][self::HTML]/*[2][self::BODY]/*[1][self::FIGURE]/*[1][self::DIV]', + 'isLCP' => true, + 'intersectionRatio' => 1, + 'resizedBoundingClientRect' => array_merge( $rect, array( 'height' => 500 ) ), ), ), false @@ -33,14 +44,20 @@ ... + -
-
+
+
diff --git a/plugins/embed-optimizer/tests/test-cases/single-wordpress-tv-embed-outside-viewport.php b/plugins/embed-optimizer/tests/test-cases/single-wordpress-tv-embed-outside-viewport.php index a48ed16b11..b7255a6b81 100644 --- a/plugins/embed-optimizer/tests/test-cases/single-wordpress-tv-embed-outside-viewport.php +++ b/plugins/embed-optimizer/tests/test-cases/single-wordpress-tv-embed-outside-viewport.php @@ -1,12 +1,23 @@ static function ( Test_Embed_Optimizer_Optimization_Detective $test_case ): void { + $rect = array( + 'width' => 500.1, + 'height' => 500.2, + 'x' => 100.3, + 'y' => 100.4, + 'top' => 0.1, + 'right' => 0.2, + 'bottom' => 0.3, + 'left' => 0.4, + ); $test_case->populate_url_metrics( array( array( - 'xpath' => '/*[1][self::HTML]/*[2][self::BODY]/*[1][self::FIGURE]', - 'isLCP' => false, - 'intersectionRatio' => 0, + 'xpath' => '/*[1][self::HTML]/*[2][self::BODY]/*[1][self::FIGURE]/*[1][self::DIV]', + 'isLCP' => false, + 'intersectionRatio' => 0, + 'resizedBoundingClientRect' => array_merge( $rect, array( 'height' => 500 ) ), ), ), false @@ -33,10 +44,16 @@ ... + -
-
+
+
diff --git a/plugins/embed-optimizer/tests/test-cases/single-youtube-embed-inside-viewport.php b/plugins/embed-optimizer/tests/test-cases/single-youtube-embed-inside-viewport.php index c4ed8b770c..78dcf667a9 100644 --- a/plugins/embed-optimizer/tests/test-cases/single-youtube-embed-inside-viewport.php +++ b/plugins/embed-optimizer/tests/test-cases/single-youtube-embed-inside-viewport.php @@ -1,12 +1,23 @@ static function ( Test_Embed_Optimizer_Optimization_Detective $test_case ): void { + $rect = array( + 'width' => 500.1, + 'height' => 500.2, + 'x' => 100.3, + 'y' => 100.4, + 'top' => 0.1, + 'right' => 0.2, + 'bottom' => 0.3, + 'left' => 0.4, + ); $test_case->populate_url_metrics( array( array( - 'xpath' => '/*[1][self::HTML]/*[2][self::BODY]/*[1][self::FIGURE]', - 'isLCP' => true, - 'intersectionRatio' => 1, + 'xpath' => '/*[1][self::HTML]/*[2][self::BODY]/*[1][self::FIGURE]/*[1][self::DIV]', + 'isLCP' => true, + 'intersectionRatio' => 1, + 'resizedBoundingClientRect' => array_merge( $rect, array( 'height' => 500 ) ), ), ) ); @@ -31,11 +42,17 @@ ... + -
+
diff --git a/plugins/embed-optimizer/tests/test-cases/single-youtube-embed-outside-viewport.php b/plugins/embed-optimizer/tests/test-cases/single-youtube-embed-outside-viewport.php index 85c53e81bf..f95d503763 100644 --- a/plugins/embed-optimizer/tests/test-cases/single-youtube-embed-outside-viewport.php +++ b/plugins/embed-optimizer/tests/test-cases/single-youtube-embed-outside-viewport.php @@ -1,12 +1,23 @@ static function ( Test_Embed_Optimizer_Optimization_Detective $test_case ): void { + $rect = array( + 'width' => 500.1, + 'height' => 500.2, + 'x' => 100.3, + 'y' => 100.4, + 'top' => 0.1, + 'right' => 0.2, + 'bottom' => 0.3, + 'left' => 0.4, + ); $test_case->populate_url_metrics( array( array( - 'xpath' => '/*[1][self::HTML]/*[2][self::BODY]/*[1][self::FIGURE]', - 'isLCP' => false, - 'intersectionRatio' => 0, + 'xpath' => '/*[1][self::HTML]/*[2][self::BODY]/*[1][self::FIGURE]/*[1][self::DIV]', + 'isLCP' => false, + 'intersectionRatio' => 0, + 'resizedBoundingClientRect' => array_merge( $rect, array( 'height' => 500 ) ), ), ) ); @@ -31,9 +42,15 @@ ... + -
+
diff --git a/plugins/embed-optimizer/tests/test-cases/too-many-bookmarks.php b/plugins/embed-optimizer/tests/test-cases/too-many-bookmarks.php index 76df5b30e6..696f16c603 100644 --- a/plugins/embed-optimizer/tests/test-cases/too-many-bookmarks.php +++ b/plugins/embed-optimizer/tests/test-cases/too-many-bookmarks.php @@ -59,8 +59,8 @@ static function ( OD_Tag_Visitor_Context $context ) use ( $test_case ): bool { ... -
-
+
+
diff --git a/plugins/embed-optimizer/tests/test-hooks.php b/plugins/embed-optimizer/tests/test-hooks.php index 0e6b223d6c..b9b55d8e70 100644 --- a/plugins/embed-optimizer/tests/test-hooks.php +++ b/plugins/embed-optimizer/tests/test-hooks.php @@ -10,28 +10,95 @@ class Test_Embed_Optimizer_Hooks extends WP_UnitTestCase { /** * @covers ::embed_optimizer_add_hooks */ - public function test_hooks(): void { + public function test_embed_optimizer_add_hooks(): void { + remove_all_actions( 'od_init' ); + remove_all_actions( 'wp_head' ); + remove_all_actions( 'wp_loaded' ); embed_optimizer_add_hooks(); - if ( defined( 'OPTIMIZATION_DETECTIVE_VERSION' ) ) { - $this->assertFalse( has_filter( 'embed_oembed_html', 'embed_optimizer_filter_oembed_html' ) ); - } else { - $this->assertSame( 10, has_filter( 'embed_oembed_html', 'embed_optimizer_filter_oembed_html' ) ); - } + $this->assertSame( 10, has_action( 'od_init', 'embed_optimizer_init_optimization_detective' ) ); $this->assertSame( 10, has_action( 'wp_head', 'embed_optimizer_render_generator' ) ); + $this->assertSame( 10, has_action( 'wp_loaded', 'embed_optimizer_add_non_optimization_detective_hooks' ) ); + } + + /** + * @return array> + */ + public function data_provider_to_test_embed_optimizer_add_non_optimization_detective_hooks(): array { + return array( + 'without_optimization_detective' => array( + 'set_up' => static function (): void {}, + 'expected' => 10, + ), + 'with_optimization_detective' => array( + 'set_up' => static function (): void { + add_action( 'od_register_tag_visitors', 'embed_optimizer_register_tag_visitors' ); + }, + 'expected' => false, + ), + ); + } + + /** + * @dataProvider data_provider_to_test_embed_optimizer_add_non_optimization_detective_hooks + * @covers ::embed_optimizer_add_non_optimization_detective_hooks + * + * @param Closure $set_up Set up. + * @param int|false $expected Expected. + */ + public function test_embed_optimizer_add_non_optimization_detective_hooks( Closure $set_up, $expected ): void { + remove_all_filters( 'embed_oembed_html' ); + remove_all_actions( 'od_register_tag_visitors' ); + $set_up(); + embed_optimizer_add_non_optimization_detective_hooks(); + $this->assertSame( $expected, has_filter( 'embed_oembed_html', 'embed_optimizer_filter_oembed_html_to_lazy_load' ) ); + } + + /** + * @return array> + */ + public function data_provider_to_test_embed_optimizer_init_optimization_detective(): array { + return array( + 'with_old_version' => array( + 'version' => '0.5.0', + 'expected' => false, + ), + 'with_new_version' => array( + 'version' => '0.7.0', + 'expected' => true, + ), + ); + } + + /** + * @covers ::embed_optimizer_init_optimization_detective + * @dataProvider data_provider_to_test_embed_optimizer_init_optimization_detective + */ + public function test_embed_optimizer_init_optimization_detective( string $version, bool $expected ): void { + remove_all_actions( 'admin_notices' ); + remove_all_actions( 'od_register_tag_visitors' ); + remove_all_filters( 'embed_oembed_html' ); + remove_all_filters( 'od_url_metric_schema_element_item_additional_properties' ); + + embed_optimizer_init_optimization_detective( $version ); + + $this->assertSame( ! $expected, has_action( 'admin_notices' ) ); + $this->assertSame( $expected ? 10 : false, has_action( 'od_register_tag_visitors', 'embed_optimizer_register_tag_visitors' ) ); + $this->assertSame( $expected ? 10 : false, has_action( 'embed_oembed_html', 'embed_optimizer_filter_oembed_html_to_detect_embed_presence' ) ); + $this->assertSame( $expected ? 10 : false, has_filter( 'od_url_metric_schema_element_item_additional_properties', 'embed_optimizer_add_element_item_schema_properties' ) ); } /** * Test that the oEmbed HTML is filtered. * - * @covers ::embed_optimizer_filter_oembed_html + * @covers ::embed_optimizer_filter_oembed_html_to_lazy_load * @covers ::embed_optimizer_update_markup * @dataProvider get_data_to_test_filter_oembed_html_data */ - public function test_embed_optimizer_filter_oembed_html( string $html, string $expected_html = null, bool $expected_lazy_script = false ): void { + public function test_embed_optimizer_filter_oembed_html_to_lazy_load( string $html, string $expected_html = null, bool $expected_lazy_script = false ): void { if ( null === $expected_html ) { $expected_html = $html; // No change. } - $this->assertEquals( $expected_html, embed_optimizer_filter_oembed_html( $html ) ); + $this->assertEquals( $expected_html, embed_optimizer_filter_oembed_html_to_lazy_load( $html ) ); $this->assertSame( $expected_lazy_script ? 10 : false, has_action( 'wp_footer', 'embed_optimizer_lazy_load_scripts' ) ); } diff --git a/plugins/embed-optimizer/tests/test-optimization-detective.php b/plugins/embed-optimizer/tests/test-optimization-detective.php index eed257784c..0355864f66 100644 --- a/plugins/embed-optimizer/tests/test-optimization-detective.php +++ b/plugins/embed-optimizer/tests/test-optimization-detective.php @@ -32,6 +32,46 @@ public function test_embed_optimizer_register_tag_visitors(): void { $this->assertInstanceOf( Embed_Optimizer_Tag_Visitor::class, $registry->get_registered( 'embeds' ) ); } + + /** + * Tests embed_optimizer_add_element_item_schema_properties(). + * + * @covers ::embed_optimizer_add_element_item_schema_properties + */ + public function test_embed_optimizer_add_element_item_schema_properties(): void { + $props = embed_optimizer_add_element_item_schema_properties( array( 'foo' => array() ) ); + $this->assertArrayHasKey( 'foo', $props ); + $this->assertArrayHasKey( 'resizedBoundingClientRect', $props ); + $this->assertArrayHasKey( 'properties', $props['resizedBoundingClientRect'] ); + } + + /** + * Tests embed_optimizer_filter_extension_module_urls(). + * + * @covers ::embed_optimizer_filter_extension_module_urls + */ + public function test_embed_optimizer_filter_extension_module_urls(): void { + $urls = embed_optimizer_filter_extension_module_urls( null ); + $this->assertCount( 1, $urls ); + $this->assertStringContainsString( 'detect', $urls[0] ); + + $urls = embed_optimizer_filter_extension_module_urls( array( 'foo.js' ) ); + $this->assertCount( 2, $urls ); + $this->assertStringContainsString( 'foo.js', $urls[0] ); + $this->assertStringContainsString( 'detect', $urls[1] ); + } + + /** + * Tests embed_optimizer_filter_oembed_html_to_detect_embed_presence(). + * + * @covers ::embed_optimizer_filter_oembed_html_to_detect_embed_presence + */ + public function test_embed_optimizer_filter_oembed_html_to_detect_embed_presence(): void { + $this->assertFalse( has_filter( 'od_extension_module_urls', 'embed_optimizer_filter_extension_module_urls' ) ); + $this->assertSame( '...', embed_optimizer_filter_oembed_html_to_detect_embed_presence( '...' ) ); + $this->assertSame( 10, has_filter( 'od_extension_module_urls', 'embed_optimizer_filter_extension_module_urls' ) ); + } + /** * Data provider. * diff --git a/plugins/image-prioritizer/class-image-prioritizer-img-tag-visitor.php b/plugins/image-prioritizer/class-image-prioritizer-img-tag-visitor.php index 7884de1b5b..80e88075f8 100644 --- a/plugins/image-prioritizer/class-image-prioritizer-img-tag-visitor.php +++ b/plugins/image-prioritizer/class-image-prioritizer-img-tag-visitor.php @@ -69,9 +69,6 @@ public function __invoke( OD_Tag_Visitor_Context $context ): bool { } elseif ( is_string( $processor->get_attribute( 'fetchpriority' ) ) && - // Temporary condition in case someone updates Image Prioritizer without also updating Optimization Detective. - method_exists( $context->url_metric_group_collection, 'is_any_group_populated' ) - && $context->url_metric_group_collection->is_any_group_populated() ) { /* diff --git a/plugins/image-prioritizer/helper.php b/plugins/image-prioritizer/helper.php index e214b2c673..33b8983fc8 100644 --- a/plugins/image-prioritizer/helper.php +++ b/plugins/image-prioritizer/helper.php @@ -10,6 +10,41 @@ exit; // Exit if accessed directly. } +/** + * Initializes Image Prioritizer when Optimization Detective has loaded. + * + * @since n.e.x.t + * + * @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'; + + 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. * diff --git a/plugins/image-prioritizer/hooks.php b/plugins/image-prioritizer/hooks.php index 6ec73410c3..62d2fd3158 100644 --- a/plugins/image-prioritizer/hooks.php +++ b/plugins/image-prioritizer/hooks.php @@ -10,6 +10,4 @@ exit; // Exit if accessed directly. } -add_action( 'wp_head', 'image_prioritizer_render_generator_meta_tag' ); - -add_action( 'od_register_tag_visitors', 'image_prioritizer_register_tag_visitors' ); +add_action( 'od_init', 'image_prioritizer_init' ); diff --git a/plugins/image-prioritizer/load.php b/plugins/image-prioritizer/load.php index 97b9983ee2..8c1d4cfd06 100644 --- a/plugins/image-prioritizer/load.php +++ b/plugins/image-prioritizer/load.php @@ -6,7 +6,7 @@ * Requires at least: 6.5 * Requires PHP: 7.2 * Requires Plugins: optimization-detective - * Version: 0.1.4 + * Version: 0.1.5-alpha * Author: WordPress Performance Team * Author URI: https://make.wordpress.org/performance/ * License: GPLv2 or later @@ -44,9 +44,14 @@ static function ( string $global_var_name, string $version, Closure $load ): voi } }; - // 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,19 +71,14 @@ static function ( string $global_var_name, string $version, Closure $load ): voi } )( 'image_prioritizer_pending_plugin', - '0.1.4', + '0.1.5-alpha', static function ( string $version ): void { - - // Define the constant. if ( defined( 'IMAGE_PRIORITIZER_VERSION' ) ) { return; } 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'; } diff --git a/plugins/image-prioritizer/tests/test-helper.php b/plugins/image-prioritizer/tests/test-helper.php index 708c062a53..c58bdaff07 100644 --- a/plugins/image-prioritizer/tests/test-helper.php +++ b/plugins/image-prioritizer/tests/test-helper.php @@ -10,6 +10,38 @@ class Test_Image_Prioritizer_Helper extends WP_UnitTestCase { use Optimization_Detective_Test_Helpers; + /** + * @return array> + */ + public function data_provider_to_test_image_prioritizer_init(): array { + return array( + 'with_old_version' => array( + 'version' => '0.5.0', + 'expected' => false, + ), + 'with_new_version' => array( + 'version' => '0.7.0', + 'expected' => true, + ), + ); + } + + /** + * @covers ::image_prioritizer_init + * @dataProvider data_provider_to_test_image_prioritizer_init + */ + public function test_image_prioritizer_init( string $version, bool $expected ): void { + remove_all_actions( 'admin_notices' ); + remove_all_actions( 'wp_head' ); + remove_all_actions( 'od_register_tag_visitors' ); + + image_prioritizer_init( $version ); + + $this->assertSame( ! $expected, has_action( 'admin_notices' ) ); + $this->assertSame( $expected ? 10 : false, has_action( 'wp_head', 'image_prioritizer_render_generator_meta_tag' ) ); + $this->assertSame( $expected ? 10 : false, has_action( 'od_register_tag_visitors', 'image_prioritizer_register_tag_visitors' ) ); + } + /** * Test printing the meta generator tag. * diff --git a/plugins/optimization-detective/class-od-html-tag-processor.php b/plugins/optimization-detective/class-od-html-tag-processor.php index 3996dfdf8a..a1673fd5da 100644 --- a/plugins/optimization-detective/class-od-html-tag-processor.php +++ b/plugins/optimization-detective/class-od-html-tag-processor.php @@ -178,6 +178,15 @@ final class OD_HTML_Tag_Processor extends WP_HTML_Tag_Processor { */ private $buffered_text_replacements = array(); + /** + * Whether the end of the document was reached. + * + * @since n.e.x.t + * @see self::next_token() + * @var bool + */ + private $reached_end_of_document = false; + /** * Count for the number of times that the cursor was moved. * @@ -263,6 +272,9 @@ public function next_token(): bool { 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; } @@ -559,11 +571,25 @@ public function append_body_html( string $html ): void { /** * 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 ) { diff --git a/plugins/optimization-detective/class-od-link-collection.php b/plugins/optimization-detective/class-od-link-collection.php index b1fbfacb6c..49b8669ed4 100644 --- a/plugins/optimization-detective/class-od-link-collection.php +++ b/plugins/optimization-detective/class-od-link-collection.php @@ -192,20 +192,13 @@ static function ( array $carry, array $link ): array { // 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']; }, diff --git a/plugins/optimization-detective/class-od-url-metric-group-collection.php b/plugins/optimization-detective/class-od-url-metric-group-collection.php index 506b7b0f2a..9943991ca1 100644 --- a/plugins/optimization-detective/class-od-url-metric-group-collection.php +++ b/plugins/optimization-detective/class-od-url-metric-group-collection.php @@ -80,7 +80,8 @@ final class OD_URL_Metric_Group_Collection implements Countable, IteratorAggrega * is_every_group_complete?: bool, * get_groups_by_lcp_element?: array, * get_common_lcp_element?: ElementData|null, - * get_all_element_max_intersection_ratios?: array + * get_all_element_max_intersection_ratios?: array, + * get_all_denormalized_elements?: array>, * } */ private $result_cache = array(); @@ -159,6 +160,8 @@ public function __construct( array $url_metrics, array $breakpoints, int $sample /** * Clear result cache. + * + * @since 0.3.0 */ public function clear_cache(): void { $this->result_cache = array(); @@ -167,6 +170,8 @@ public function clear_cache(): void { /** * Create groups. * + * @since 0.1.0 + * * @phpstan-return non-empty-array * * @return OD_URL_Metric_Group[] Groups. @@ -187,6 +192,7 @@ private function create_groups(): array { * * Once a group reaches the sample size, the oldest URL metric is pushed out. * + * @since 0.1.0 * @throws InvalidArgumentException If there is no group available to add a URL metric to. * * @param OD_URL_Metric $new_url_metric New URL metric. @@ -206,6 +212,7 @@ public function add_url_metric( OD_URL_Metric $new_url_metric ): void { /** * Gets group for viewport width. * + * @since 0.1.0 * @throws InvalidArgumentException When there is no group for the provided viewport width. This would only happen if a negative width is provided. * * @param int $viewport_width Viewport width. @@ -240,6 +247,8 @@ public function get_group_for_viewport_width( int $viewport_width ): OD_URL_Metr /** * Checks whether any group is populated with at least one URL metric. * + * @since 0.5.0 + * * @return bool Whether at least one group has some URL metrics. */ public function is_any_group_populated(): bool { @@ -268,6 +277,7 @@ public function is_any_group_populated(): bool { * should be contrasted with the `is_every_group_complete()` * method below. * + * @since 0.1.0 * @see OD_URL_Metric_Group_Collection::is_every_group_complete() * * @return bool Whether all groups have some URL metrics. @@ -293,6 +303,7 @@ public function is_every_group_populated(): bool { /** * Checks whether every group is complete. * + * @since 0.1.0 * @see OD_URL_Metric_Group::is_complete() * * @return bool Whether all groups are complete. @@ -319,6 +330,7 @@ public function is_every_group_complete(): bool { /** * Gets the groups with the provided LCP element XPath. * + * @since 0.3.0 * @see OD_URL_Metric_Group::get_lcp_element() * * @param string $xpath XPath for LCP element. @@ -348,6 +360,8 @@ public function get_groups_by_lcp_element( string $xpath ): array { /** * Gets common LCP element. * + * @since 0.3.0 + * * @return ElementData|null */ public function get_common_lcp_element(): ?array { @@ -396,34 +410,61 @@ public function get_common_lcp_element(): ?array { } /** - * Gets the max intersection ratios of all elements across all groups and their captured URL metrics. + * Gets all elements from all URL metrics from all groups keyed by the elements' XPaths. * - * @return array Keys are XPaths and values are the intersection ratios. + * This is an O(n^3) function so its results must be cached. This being said, the number of groups should be 4 (one + * more than the default number of breakpoints) and the number of URL metrics for each group should be 3 + * (the default sample size). Therefore, given the number (n) of visited elements on the page this will only + * end up running n*4*3 times. + * + * @todo Should there be an OD_Element class which has a $url_metric property which then in turn has a $group property. Then this would only need to return array. + * @since n.e.x.t + * + * @return array> Keys are XPaths and values are arrays of tuples consisting of the group, URL metric, and element data. */ - public function get_all_element_max_intersection_ratios(): array { + public function get_all_denormalized_elements(): array { if ( array_key_exists( __FUNCTION__, $this->result_cache ) ) { return $this->result_cache[ __FUNCTION__ ]; } $result = ( function () { - $element_max_intersection_ratios = array(); - - /* - * O(n^3) my! Yes. This is why the result is cached. This being said, the number of groups should be 4 (one - * more than the default number of breakpoints) and the number of URL metrics for each group should be 3 - * (the default sample size). Therefore, given the number (n) of visited elements on the page this will only - * end up running n*4*3 times. - */ + $all_denormalized_elements = array(); foreach ( $this->groups as $group ) { foreach ( $group as $url_metric ) { foreach ( $url_metric->get_elements() as $element ) { - $element_max_intersection_ratios[ $element['xpath'] ] = array_key_exists( $element['xpath'], $element_max_intersection_ratios ) - ? max( $element_max_intersection_ratios[ $element['xpath'] ], $element['intersectionRatio'] ) - : $element['intersectionRatio']; + $all_denormalized_elements[ $element['xpath'] ][] = array( $group, $url_metric, $element ); } } } - return $element_max_intersection_ratios; + return $all_denormalized_elements; + } )(); + + $this->result_cache[ __FUNCTION__ ] = $result; + return $result; + } + + /** + * Gets the max intersection ratios of all elements across all groups and their captured URL metrics. + * + * @since 0.3.0 + * + * @return array Keys are XPaths and values are the intersection ratios. + */ + public function get_all_element_max_intersection_ratios(): array { + if ( array_key_exists( __FUNCTION__, $this->result_cache ) ) { + return $this->result_cache[ __FUNCTION__ ]; + } + + $result = ( function () { + $elements_max_intersection_ratios = array(); + foreach ( $this->get_all_denormalized_elements() as $xpath => $denormalized_elements ) { + $element_intersection_ratios = array(); + foreach ( $denormalized_elements as list( $group, $url_metric, $element ) ) { + $element_intersection_ratios[] = $element['intersectionRatio']; + } + $elements_max_intersection_ratios[ $xpath ] = (float) max( $element_intersection_ratios ); + } + return $elements_max_intersection_ratios; } )(); $this->result_cache[ __FUNCTION__ ] = $result; @@ -433,6 +474,8 @@ public function get_all_element_max_intersection_ratios(): array { /** * Gets the max intersection ratio of an element across all groups and their captured URL metrics. * + * @since 0.3.0 + * * @param string $xpath XPath for the element. * @return float|null Max intersection ratio of null if tag is unknown (not captured). */ @@ -443,6 +486,8 @@ public function get_element_max_intersection_ratio( string $xpath ): ?float { /** * Gets URL metrics from all groups flattened into one list. * + * @since 0.1.0 + * * @return OD_URL_Metric[] All URL metrics. */ public function get_flattened_url_metrics(): array { @@ -460,6 +505,8 @@ public function get_flattened_url_metrics(): array { /** * Returns an iterator for the groups of URL metrics. * + * @since 0.1.0 + * * @return ArrayIterator Array iterator for OD_URL_Metric_Group instances. */ public function getIterator(): ArrayIterator { @@ -469,6 +516,8 @@ public function getIterator(): ArrayIterator { /** * Counts the URL metric groups in the collection. * + * @since 0.1.0 + * * @return int<0, max> Group count. */ public function count(): int { diff --git a/plugins/optimization-detective/class-od-url-metric-group.php b/plugins/optimization-detective/class-od-url-metric-group.php index 091872cf98..1b9b51315c 100644 --- a/plugins/optimization-detective/class-od-url-metric-group.php +++ b/plugins/optimization-detective/class-od-url-metric-group.php @@ -145,6 +145,7 @@ public function __construct( array $url_metrics, int $minimum_viewport_width, in /** * Gets the minimum possible viewport width (inclusive). * + * @todo Eliminate in favor of readonly public property. * @return int<0, max> Minimum viewport width. */ public function get_minimum_viewport_width(): int { @@ -154,6 +155,7 @@ public function get_minimum_viewport_width(): int { /** * Gets the maximum possible viewport width (inclusive). * + * @todo Eliminate in favor of readonly public property. * @return int<1, max> Minimum viewport width. */ public function get_maximum_viewport_width(): int { diff --git a/plugins/optimization-detective/detect.js b/plugins/optimization-detective/detect.js index 72433be5cc..3f04404118 100644 --- a/plugins/optimization-detective/detect.js +++ b/plugins/optimization-detective/detect.js @@ -1,4 +1,12 @@ -/** @typedef {import("web-vitals").LCPMetric} LCPMetric */ +/** + * @typedef {import("web-vitals").LCPMetric} LCPMetric + * @typedef {import("./types.d.ts").ElementData} ElementData + * @typedef {import("./types.d.ts").URLMetric} URLMetric + * @typedef {import("./types.d.ts").URLMetricGroupStatus} URLMetricGroupStatus + * @typedef {import("./types.d.ts").Extension} Extension + * @typedef {import("./types.d.ts").ExtendedRootData} ExtendedRootData + * @typedef {import("./types.d.ts").ExtendedElementData} ExtendedElementData + */ const win = window; const doc = win.document; @@ -33,7 +41,7 @@ function isStorageLocked( currentTime, storageLockTTL ) { } /** - * Set the storage lock. + * Sets the storage lock. * * @param {number} currentTime - Current time in milliseconds. */ @@ -47,7 +55,7 @@ function setStorageLock( currentTime ) { } /** - * Log a message. + * Logs a message. * * @param {...*} message */ @@ -57,7 +65,7 @@ function log( ...message ) { } /** - * Log a warning. + * Logs a warning. * * @param {...*} message */ @@ -67,7 +75,7 @@ function warn( ...message ) { } /** - * Log an error. + * Logs an error. * * @param {...*} message */ @@ -76,31 +84,6 @@ function error( ...message ) { console.error( consoleLogPrefix, ...message ); } -/** - * @typedef {Object} ElementMetrics - * @property {boolean} isLCP - Whether it is the LCP candidate. - * @property {boolean} isLCPCandidate - Whether it is among the LCP candidates. - * @property {string} xpath - XPath. - * @property {number} intersectionRatio - Intersection ratio. - * @property {DOMRectReadOnly} intersectionRect - Intersection rectangle. - * @property {DOMRectReadOnly} boundingClientRect - Bounding client rectangle. - */ - -/** - * @typedef {Object} URLMetric - * @property {string} url - URL of the page. - * @property {Object} viewport - Viewport. - * @property {number} viewport.width - Viewport width. - * @property {number} viewport.height - Viewport height. - * @property {ElementMetrics[]} elements - Metrics for the elements observed on the page. - */ - -/** - * @typedef {Object} URLMetricGroupStatus - * @property {number} minimumViewportWidth - Minimum viewport width. - * @property {boolean} complete - Whether viewport group is complete. - */ - /** * Checks whether the URL metric(s) for the provided viewport width is needed. * @@ -129,12 +112,129 @@ function getCurrentTime() { return Date.now(); } +/** + * Recursively freezes an object to prevent mutation. + * + * @param {Object} obj Object to recursively freeze. + */ +function recursiveFreeze( obj ) { + for ( const prop of Object.getOwnPropertyNames( obj ) ) { + const value = obj[ prop ]; + if ( null !== value && typeof value === 'object' ) { + recursiveFreeze( value ); + } + } + Object.freeze( obj ); +} + +/** + * URL metric being assembled for submission. + * + * @type {URLMetric} + */ +let urlMetric; + +/** + * Reserved root property keys. + * + * @see {URLMetric} + * @see {ExtendedElementData} + * @type {Set} + */ +const reservedRootPropertyKeys = new Set( [ 'url', 'viewport', 'elements' ] ); + +/** + * Gets root URL Metric data. + * + * @return {URLMetric} URL Metric. + */ +function getRootData() { + const immutableUrlMetric = structuredClone( urlMetric ); + recursiveFreeze( immutableUrlMetric ); + return immutableUrlMetric; +} + +/** + * Extends root URL metric data. + * + * @param {ExtendedRootData} properties + */ +function extendRootData( properties ) { + for ( const key of Object.getOwnPropertyNames( properties ) ) { + if ( reservedRootPropertyKeys.has( key ) ) { + throw new Error( `Disallowed setting of key '${ key }' on root.` ); + } + } + Object.assign( urlMetric, properties ); +} + +/** + * Mapping of XPath to element data. + * + * @type {Map} + */ +const elementsByXPath = new Map(); + +/** + * Reserved element property keys. + * + * @see {ElementData} + * @see {ExtendedRootData} + * @type {Set} + */ +const reservedElementPropertyKeys = new Set( [ + 'isLCP', + 'isLCPCandidate', + 'xpath', + 'intersectionRatio', + 'intersectionRect', + 'boundingClientRect', +] ); + +/** + * Gets element data. + * + * @param {string} xpath XPath. + * @return {ElementData|null} Element data, or null if no element for the XPath exists. + */ +function getElementData( xpath ) { + const elementData = elementsByXPath.get( xpath ); + if ( elementData ) { + const cloned = structuredClone( elementData ); + recursiveFreeze( cloned ); + return cloned; + } + return null; +} + +/** + * Extends element data. + * + * @param {string} xpath XPath. + * @param {ExtendedElementData} properties Properties. + */ +function extendElementData( xpath, properties ) { + if ( ! elementsByXPath.has( xpath ) ) { + throw new Error( `Unknown element with XPath: ${ xpath }` ); + } + for ( const key of Object.getOwnPropertyNames( properties ) ) { + if ( reservedElementPropertyKeys.has( key ) ) { + throw new Error( + `Disallowed setting of key '${ key }' on element.` + ); + } + } + const elementData = elementsByXPath.get( xpath ); + Object.assign( elementData, properties ); +} + /** * Detects the LCP element, loaded images, client viewport and store for future optimizations. * * @param {Object} args Args. * @param {number} args.serveTime The serve time of the page in milliseconds from PHP via `microtime( true ) * 1000`. * @param {number} args.detectionTimeWindow The number of milliseconds between now and when the page was first generated in which detection should proceed. + * @param {string[]} args.extensionModuleUrls URLs for extension script modules to import. * @param {number} args.minViewportAspectRatio Minimum aspect ratio allowed for the viewport. * @param {number} args.maxViewportAspectRatio Maximum aspect ratio allowed for the viewport. * @param {boolean} args.isDebug Whether to show debug messages. @@ -154,6 +254,7 @@ export default async function detect( { minViewportAspectRatio, maxViewportAspectRatio, isDebug, + extensionModuleUrls, restApiEndpoint, restApiNonce, currentUrl, @@ -227,6 +328,7 @@ export default async function detect( { } ); } + // TODO: Does this make sense here? Should it be moved up above the isViewportNeeded condition? // As an alternative to this, the od_print_detection_script() function can short-circuit if the // od_is_url_metric_storage_locked() function returns true. However, the downside with that is page caching could // result in metrics missed from being gathered when a user navigates around a site and primes the page cache. @@ -237,6 +339,7 @@ export default async function detect( { return; } + // TODO: Does this make sense here? // Prevent detection when page is not scrolled to the initial viewport. if ( doc.documentElement.scrollTop > 0 ) { if ( isDebug ) { @@ -251,9 +354,28 @@ export default async function detect( { log( 'Proceeding with detection' ); } + /** @type {Map} */ + const extensions = new Map(); + for ( const extensionModuleUrl of extensionModuleUrls ) { + try { + /** @type {Extension} */ + const extension = await import( extensionModuleUrl ); + extensions.set( extensionModuleUrl, extension ); + // TODO: There should to be a way to pass additional args into the module. Perhaps extensionModuleUrls should be a mapping of URLs to args. It's important to pass webVitalsLibrarySrc to the extension so that onLCP, onCLS, or onINP can be obtained. + if ( extension.initialize instanceof Function ) { + extension.initialize( { isDebug } ); + } + } catch ( err ) { + error( + `Failed to initialize extension '${ extensionModuleUrl }':`, + err + ); + } + } + const breadcrumbedElements = doc.body.querySelectorAll( '[data-od-xpath]' ); - /** @type {Map} */ + /** @type {Map} */ const breadcrumbedElementsMap = new Map( [ ...breadcrumbedElements ].map( /** @@ -314,7 +436,7 @@ export default async function detect( { // Obtain at least one LCP candidate. More may be reported before the page finishes loading. await new Promise( ( resolve ) => { onLCP( - ( metric ) => { + ( /** @type LCPMetric */ metric ) => { lcpMetricCandidates.push( metric ); resolve(); }, @@ -333,8 +455,7 @@ export default async function detect( { log( 'Detection is stopping.' ); } - /** @type {URLMetric} */ - const urlMetric = { + urlMetric = { url: currentUrl, viewport: { width: win.innerWidth, @@ -357,8 +478,8 @@ export default async function detect( { const isLCP = elementIntersection.target === lcpMetric?.entries[ 0 ]?.element; - /** @type {ElementMetrics} */ - const elementMetrics = { + /** @type {ElementData} */ + const elementData = { isLCP, isLCPCandidate: !! lcpMetricCandidates.find( ( lcpMetricCandidate ) => @@ -371,49 +492,73 @@ export default async function detect( { boundingClientRect: elementIntersection.boundingClientRect, }; - urlMetric.elements.push( elementMetrics ); + urlMetric.elements.push( elementData ); + elementsByXPath.set( elementData.xpath, elementData ); } if ( isDebug ) { log( 'Current URL metric:', urlMetric ); } - // Yield to main before sending data to server to further break up task. + // Wait for the page to be hidden. await new Promise( ( resolve ) => { - setTimeout( resolve, 0 ); - } ); - - try { - const restUrl = new URL( restApiEndpoint ); - restUrl.searchParams.append( 'slug', urlMetricSlug ); - restUrl.searchParams.append( 'nonce', urlMetricNonce ); - const response = await fetch( restUrl, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-WP-Nonce': restApiNonce, + win.addEventListener( 'pagehide', resolve, { once: true } ); + win.addEventListener( 'pageswap', resolve, { once: true } ); + doc.addEventListener( + 'visibilitychange', + () => { + if ( document.visibilityState === 'hidden' ) { + // TODO: This will fire even when switching tabs. + resolve(); + } }, - body: JSON.stringify( urlMetric ), - } ); - - if ( response.status === 200 ) { - setStorageLock( getCurrentTime() ); - } + { once: true } + ); + } ); - if ( isDebug ) { - const body = await response.json(); - if ( response.status === 200 ) { - log( 'Response:', body ); - } else { - error( 'Failure:', body ); + if ( extensions.size > 0 ) { + for ( const [ + extensionModuleUrl, + extension, + ] of extensions.entries() ) { + if ( extension.finalize instanceof Function ) { + try { + await extension.finalize( { + isDebug, + getRootData, + getElementData, + extendElementData, + extendRootData, + } ); + } catch ( err ) { + error( + `Unable to finalize module '${ extensionModuleUrl }':`, + err + ); + } } } - } catch ( err ) { - if ( isDebug ) { - error( err ); - } } + // Even though the server may reject the REST API request, we still have to set the storage lock + // because we can't look at the response when sending a beacon. + setStorageLock( getCurrentTime() ); + + if ( isDebug ) { + log( 'Sending URL metric:', urlMetric ); + } + + const url = new URL( restApiEndpoint ); + url.searchParams.set( '_wpnonce', restApiNonce ); + url.searchParams.set( 'slug', urlMetricSlug ); + url.searchParams.set( 'nonce', urlMetricNonce ); + navigator.sendBeacon( + url, + new Blob( [ JSON.stringify( urlMetric ) ], { + type: 'application/json', + } ) + ); + // Clean up. breadcrumbedElementsMap.clear(); } diff --git a/plugins/optimization-detective/detection.php b/plugins/optimization-detective/detection.php index 1a1ca55639..97f1236326 100644 --- a/plugins/optimization-detective/detection.php +++ b/plugins/optimization-detective/detection.php @@ -38,6 +38,15 @@ function od_get_detection_script( string $slug, OD_URL_Metric_Group_Collection $ $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 n.e.x.t + * + * @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. @@ -45,6 +54,7 @@ function od_get_detection_script( string $slug, OD_URL_Metric_Group_Collection $ '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, diff --git a/plugins/optimization-detective/helper.php b/plugins/optimization-detective/helper.php index 553d55cb11..c8b95f050e 100644 --- a/plugins/optimization-detective/helper.php +++ b/plugins/optimization-detective/helper.php @@ -10,6 +10,49 @@ exit; // Exit if accessed directly. } +/** + * Initializes extensions for Optimization Detective. + * + * @since n.e.x.t + */ +function od_initialize_extensions(): void { + /** + * Fires when extensions to Optimization Detective can be loaded and initialized. + * + * @since n.e.x.t + * + * @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 n.e.x.t + * + * @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 n.e.x.t' ); + 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. * diff --git a/plugins/optimization-detective/hooks.php b/plugins/optimization-detective/hooks.php index 6b6feb924d..c0f94d148c 100644 --- a/plugins/optimization-detective/hooks.php +++ b/plugins/optimization-detective/hooks.php @@ -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' ); diff --git a/plugins/optimization-detective/load.php b/plugins/optimization-detective/load.php index a5f241f273..fbffedf72c 100644 --- a/plugins/optimization-detective/load.php +++ b/plugins/optimization-detective/load.php @@ -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-alpha * Author: WordPress Performance Team * Author URI: https://make.wordpress.org/performance/ * License: GPLv2 or later @@ -43,9 +43,14 @@ static function ( string $global_var_name, string $version, Closure $load ): voi } }; - // 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 @@ static function ( string $global_var_name, string $version, Closure $load ): voi } )( 'optimization_detective_pending_plugin', - '0.6.0', + '0.7.0-alpha', static function ( string $version ): void { - - // Define the constant. if ( defined( 'OPTIMIZATION_DETECTIVE_VERSION' ) ) { return; } diff --git a/plugins/optimization-detective/tests/test-class-od-html-tag-processor.php b/plugins/optimization-detective/tests/test-class-od-html-tag-processor.php index c16402b1ee..1f8870a31a 100644 --- a/plugins/optimization-detective/tests/test-class-od-html-tag-processor.php +++ b/plugins/optimization-detective/tests/test-class-od-html-tag-processor.php @@ -338,80 +338,15 @@ public function test_next_tag_with_query(): void { $p->next_tag( array( 'tag_name' => 'HTML' ) ); } - /** - * Test append_head_html(). - * - * @covers ::append_head_html - */ - public function test_append_head_html(): void { - $html = ' - - - - - - - -

Hello World

- - - '; - $processor = new OD_HTML_Tag_Processor( $html ); - $early_injected = ''; - $late_injected = ''; - $processor->append_head_html( $early_injected ); - - $saw_head = false; - while ( $processor->next_open_tag() ) { - $tag = $processor->get_tag(); - if ( 'HEAD' === $tag ) { - $saw_head = true; - } - } - $this->assertTrue( $saw_head ); - - $processor->append_head_html( $late_injected ); - $expected = " - - - - - {$early_injected}{$late_injected} - - -

Hello World

- - - "; - - $this->assertSame( $expected, $processor->get_updated_html() ); - - $later_injected = ''; - $processor->append_head_html( $later_injected ); - - $expected = " - - - - - {$early_injected}{$late_injected}{$later_injected} - - -

Hello World

- - - "; - $this->assertSame( $expected, $processor->get_updated_html() ); - } - /** * Test both append_head_html() and append_body_html(). * * @covers ::append_head_html * @covers ::append_body_html + * @covers ::get_updated_html */ public function test_append_head_and_body_html(): void { - $html = ' + $html = ' @@ -425,36 +360,53 @@ public function test_append_head_and_body_html(): void { '; - $head_injected = ''; - $body_injected = ''; - $processor = new OD_HTML_Tag_Processor( $html ); + $head_injected = ''; + $body_injected = ''; + $later_head_injected = ''; + $processor = new OD_HTML_Tag_Processor( $html ); + + $processor->append_head_html( $head_injected ); + $processor->append_body_html( $body_injected ); $saw_head = false; $saw_body = false; + $did_seek = false; while ( $processor->next_open_tag() ) { + $this->assertStringNotContainsString( $head_injected, $processor->get_updated_html(), 'Only expecting end-of-head injection once document was finalized.' ); + $this->assertStringNotContainsString( $body_injected, $processor->get_updated_html(), 'Only expecting end-of-body injection once document was finalized.' ); $tag = $processor->get_tag(); if ( 'HEAD' === $tag ) { $saw_head = true; } elseif ( 'BODY' === $tag ) { $saw_body = true; + $this->assertTrue( $processor->set_bookmark( 'cuerpo' ) ); + } + if ( ! $did_seek && 'H1' === $tag ) { + $processor->append_head_html( '' ); + $processor->append_body_html( '' ); + $this->assertTrue( $processor->seek( 'cuerpo' ) ); + $did_seek = true; } } + $this->assertTrue( $did_seek ); $this->assertTrue( $saw_head ); $this->assertTrue( $saw_body ); + $this->assertStringContainsString( $head_injected, $processor->get_updated_html(), 'Only expecting end-of-head injection once document was finalized.' ); + $this->assertStringContainsString( $body_injected, $processor->get_updated_html(), 'Only expecting end-of-body injection once document was finalized.' ); + + $processor->append_head_html( $later_head_injected ); - $processor->append_head_html( $head_injected ); - $processor->append_body_html( $body_injected ); $expected = " - {$head_injected} + {$head_injected}{$later_head_injected}

Hello World

- {$body_injected} + {$body_injected} "; diff --git a/plugins/optimization-detective/tests/test-class-od-url-metrics-group-collection.php b/plugins/optimization-detective/tests/test-class-od-url-metrics-group-collection.php index f221e2925d..2b4b3329c3 100644 --- a/plugins/optimization-detective/tests/test-class-od-url-metrics-group-collection.php +++ b/plugins/optimization-detective/tests/test-class-od-url-metrics-group-collection.php @@ -661,7 +661,15 @@ public function data_provider_element_max_intersection_ratios(): array { }; return array( - 'one-element-sample-size-one' => array( + 'one-element-one-group' => array( + 'url_metrics' => array( + $get_sample_url_metric( 600, $xpath1, 0.5 ), + ), + 'expected' => array( + $xpath1 => 0.5, + ), + ), + 'one-element-three-groups-of-one' => array( 'url_metrics' => array( $get_sample_url_metric( 400, $xpath1, 0.0 ), $get_sample_url_metric( 600, $xpath1, 0.5 ), @@ -671,7 +679,7 @@ public function data_provider_element_max_intersection_ratios(): array { $xpath1 => 1.0, ), ), - 'three-elements-sample-size-two' => array( + 'three-elements-sample-size-two' => array( 'url_metrics' => array( // Group 1. $get_sample_url_metric( 400, $xpath1, 0.0 ), @@ -689,7 +697,7 @@ public function data_provider_element_max_intersection_ratios(): array { $xpath3 => 0.6, ), ), - 'no-url-metrics' => array( + 'no-url-metrics' => array( 'url_metrics' => array(), 'expected' => array(), ), @@ -698,10 +706,11 @@ public function data_provider_element_max_intersection_ratios(): array { } /** - * Test get_all_element_max_intersection_ratios() and get_element_max_intersection_ratio(). + * Test get_all_element_max_intersection_ratios(), get_element_max_intersection_ratio(), and get_all_denormalized_elements(). * * @covers ::get_all_element_max_intersection_ratios * @covers ::get_element_max_intersection_ratio + * @covers ::get_all_denormalized_elements * * @dataProvider data_provider_element_max_intersection_ratios * @@ -718,6 +727,97 @@ public function test_get_all_element_max_intersection_ratios( array $url_metrics foreach ( $expected as $expected_xpath => $expected_max_ratio ) { $this->assertSame( $expected_max_ratio, $group_collection->get_element_max_intersection_ratio( $expected_xpath ) ); } + + // Check get_all_denormalized_elements. + $all_denormalized_elements = $group_collection->get_all_denormalized_elements(); + $xpath_counts = array(); + foreach ( $url_metrics as $url_metric ) { + foreach ( $url_metric->get_elements() as $element ) { + if ( ! isset( $xpath_counts[ $element['xpath'] ] ) ) { + $xpath_counts[ $element['xpath'] ] = 0; + } + $xpath_counts[ $element['xpath'] ] += 1; + } + } + $this->assertCount( count( $xpath_counts ), $all_denormalized_elements ); + foreach ( $all_denormalized_elements as $xpath => $denormalized_elements ) { + foreach ( $denormalized_elements as list( $group, $url_metric, $element ) ) { + $this->assertContains( $url_metric, iterator_to_array( $group ) ); + $this->assertContains( $element, $url_metric->get_elements() ); + $this->assertInstanceOf( OD_URL_Metric_Group::class, $group ); + $this->assertInstanceOf( OD_URL_Metric::class, $url_metric ); + $this->assertIsArray( $element ); + $this->assertSame( $xpath, $element['xpath'] ); + } + } + } + + /** + * Data provider. + * + * @return array + */ + public function data_provider_element_minimum_heights(): array { + $xpath1 = '/*[0][self::HTML]/*[1][self::BODY]/*[0][self::IMG]/*[1]'; + $xpath2 = '/*[0][self::HTML]/*[1][self::BODY]/*[0][self::IMG]/*[2]'; + $xpath3 = '/*[0][self::HTML]/*[1][self::BODY]/*[0][self::IMG]/*[3]'; + + $get_sample_url_metric = function ( int $viewport_width, string $lcp_element_xpath, float $element_height ): OD_URL_Metric { + return $this->get_sample_url_metric( + array( + 'viewport_width' => $viewport_width, + 'element' => array( + 'isLCP' => true, + 'xpath' => $lcp_element_xpath, + 'intersectionRect' => array_merge( + $this->get_sample_dom_rect(), + array( 'height' => $element_height ) + ), + 'boundingClientRect' => array_merge( + $this->get_sample_dom_rect(), + array( 'height' => $element_height ) + ), + ), + ) + ); + }; + + return array( + 'one-element-sample-size-one' => array( + 'url_metrics' => array( + $get_sample_url_metric( 400, $xpath1, 480 ), + $get_sample_url_metric( 600, $xpath1, 240 ), + $get_sample_url_metric( 800, $xpath1, 768 ), + ), + 'expected' => array( + $xpath1 => 240.0, + ), + ), + 'three-elements-sample-size-two' => array( + 'url_metrics' => array( + // Group 1. + $get_sample_url_metric( 400, $xpath1, 400 ), + $get_sample_url_metric( 400, $xpath1, 600 ), + // Group 2. + $get_sample_url_metric( 600, $xpath2, 100.1 ), + $get_sample_url_metric( 600, $xpath2, 100.2 ), + $get_sample_url_metric( 600, $xpath2, 100.05 ), + // Group 3. + $get_sample_url_metric( 800, $xpath3, 500 ), + $get_sample_url_metric( 800, $xpath3, 500 ), + ), + 'expected' => array( + $xpath1 => 400.0, + $xpath2 => 100.05, + $xpath3 => 500.0, + ), + ), + 'no-url-metrics' => array( + 'url_metrics' => array(), + 'expected' => array(), + ), + + ); } /** diff --git a/plugins/optimization-detective/tests/test-detection.php b/plugins/optimization-detective/tests/test-detection.php index 4439041a72..887a10b080 100644 --- a/plugins/optimization-detective/tests/test-detection.php +++ b/plugins/optimization-detective/tests/test-detection.php @@ -19,6 +19,7 @@ public function data_provider_od_get_detection_script(): array { 'expected_exports' => array( 'detectionTimeWindow' => 5000, 'storageLockTTL' => MINUTE_IN_SECONDS, + 'extensionModuleUrls' => array(), ), ), 'filtered' => array( @@ -35,10 +36,18 @@ static function (): int { return HOUR_IN_SECONDS; } ); + add_filter( + 'od_extension_module_urls', + static function ( array $urls ): array { + $urls[] = home_url( '/my-extension.js', 'https' ); + return $urls; + } + ); }, 'expected_exports' => array( 'detectionTimeWindow' => 2500, 'storageLockTTL' => HOUR_IN_SECONDS, + 'extensionModuleUrls' => array( home_url( '/my-extension.js', 'https' ) ), ), ), ); diff --git a/plugins/optimization-detective/tests/test-helper.php b/plugins/optimization-detective/tests/test-helper.php index af824a3631..c28dfdd306 100644 --- a/plugins/optimization-detective/tests/test-helper.php +++ b/plugins/optimization-detective/tests/test-helper.php @@ -7,6 +7,80 @@ class Test_OD_Helper extends WP_UnitTestCase { + /** + * @covers ::od_initialize_extensions + */ + public function test_od_initialize_extensions(): void { + unset( $GLOBALS['wp_actions']['od_init'] ); + $passed_version = null; + add_action( + 'od_init', + static function ( string $version ) use ( &$passed_version ): void { + $passed_version = $version; + } + ); + od_initialize_extensions(); + $this->assertSame( 1, did_action( 'od_init' ) ); + $this->assertSame( OPTIMIZATION_DETECTIVE_VERSION, $passed_version ); + } + + /** + * @return array> + */ + public function data_to_test_od_generate_media_query(): array { + return array( + 'mobile' => array( + 'min_width' => 0, + 'max_width' => 320, + 'expected' => '(max-width: 320px)', + ), + 'mobile_alt' => array( + 'min_width' => null, + 'max_width' => 320, + 'expected' => '(max-width: 320px)', + ), + 'tablet' => array( + 'min_width' => 321, + 'max_width' => 600, + 'expected' => '(min-width: 321px) and (max-width: 600px)', + ), + 'desktop' => array( + 'min_width' => 601, + 'max_width' => PHP_INT_MAX, + 'expected' => '(min-width: 601px)', + ), + 'desktop_alt' => array( + 'min_width' => 601, + 'max_width' => null, + 'expected' => '(min-width: 601px)', + ), + 'no_widths' => array( + 'min_width' => null, + 'max_width' => null, + 'expected' => null, + ), + 'bad_widths' => array( + 'min_width' => 1000, + 'max_width' => 10, + 'expected' => null, + 'incorrect_usage' => 'od_generate_media_query', + ), + ); + } + + /** + * Test generating media query. + * + * @dataProvider data_to_test_od_generate_media_query + * @covers ::od_generate_media_query + */ + public function test_od_generate_media_query( ?int $min_width, ?int $max_width, ?string $expected, ?string $incorrect_usage = null ): void { + if ( null !== $incorrect_usage ) { + $this->setExpectedIncorrectUsage( $incorrect_usage ); + } + $this->assertSame( $expected, od_generate_media_query( $min_width, $max_width ) ); + } + /** * Test printing the meta generator tag. * diff --git a/plugins/optimization-detective/types.d.ts b/plugins/optimization-detective/types.d.ts new file mode 100644 index 0000000000..50b36356f8 --- /dev/null +++ b/plugins/optimization-detective/types.d.ts @@ -0,0 +1,51 @@ + +// h/t https://stackoverflow.com/a/59801602/93579 +type ExcludeProps = { [k: string]: any } & { [K in keyof T]?: never } + +export interface ElementData { + isLCP: boolean; + isLCPCandidate: boolean; + xpath: string; + intersectionRatio: number; + intersectionRect: DOMRectReadOnly; + boundingClientRect: DOMRectReadOnly; +} + +export type ExtendedElementData = ExcludeProps + +export interface URLMetric { + url: string; + viewport: { + width: number; + height: number; + }; + elements: ElementData[]; +} + +export type ExtendedRootData = ExcludeProps + +export interface URLMetricGroupStatus { + minimumViewportWidth: number; + complete: boolean; +} + +export type InitializeArgs = { + readonly isDebug: boolean, +}; + +export type InitializeCallback = ( args: InitializeArgs ) => void; + +export type FinalizeArgs = { + readonly getRootData: () => URLMetric, + readonly extendRootData: ( properties: ExtendedRootData ) => void, + readonly getElementData: ( xpath: string ) => ElementData|null, + readonly extendElementData: (xpath: string, properties: ExtendedElementData ) => void, + readonly isDebug: boolean, +}; + +export type FinalizeCallback = ( args: FinalizeArgs ) => Promise; + +export interface Extension { + initialize?: InitializeCallback; + finalize?: FinalizeCallback; +} diff --git a/tests/class-optimization-detective-test-helpers.php b/tests/class-optimization-detective-test-helpers.php index ba273a3e89..8b8a1cf56e 100644 --- a/tests/class-optimization-detective-test-helpers.php +++ b/tests/class-optimization-detective-test-helpers.php @@ -50,8 +50,8 @@ public function populate_url_metrics( array $elements, bool $complete = true ): */ public function get_sample_dom_rect(): array { return array( - 'width' => 100.1, - 'height' => 100.2, + 'width' => 500.1, + 'height' => 500.2, 'x' => 100.3, 'y' => 100.4, 'top' => 0.1,