Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Optimize lazy-loading of IMG elements in Image Prioritizer #1261

Merged
merged 9 commits into from
Jun 3, 2024
Original file line number Diff line number Diff line change
Expand Up @@ -46,16 +46,9 @@ public function __invoke( OD_HTML_Tag_Walker $walker ): bool {
$common_lcp_element = $this->url_metrics_group_collection->get_common_lcp_element();
if ( ! is_null( $common_lcp_element ) && $xpath === $common_lcp_element['xpath'] ) {
if ( 'high' === $walker->get_attribute( 'fetchpriority' ) ) {
$walker->set_attribute( 'data-od-fetchpriority-already-added', true );
$walker->set_meta_attribute( 'fetchpriority-already-added', true );
} else {
$walker->set_attribute( 'fetchpriority', 'high' );
$walker->set_attribute( 'data-od-added-fetchpriority', true );
Copy link
Member Author

Choose a reason for hiding this comment

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

This data-od- meta attribute is now added automatically when calling set_attribute on the walker instance.

}

// Never include loading=lazy on the LCP image common across all breakpoints.
if ( 'lazy' === $walker->get_attribute( 'loading' ) ) {
$walker->set_attribute( 'data-od-removed-loading', $walker->get_attribute( 'loading' ) );
$walker->remove_attribute( 'loading' );
}
Comment on lines -55 to 52
Copy link
Member Author

Choose a reason for hiding this comment

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

This code is obsolete because the loading attribute is now removed for any element which has an non-zero intersectionRatio in any viewport group.

} elseif ( is_string( $walker->get_attribute( 'fetchpriority' ) ) && $this->url_metrics_group_collection->is_every_group_populated() ) {
/*
Expand All @@ -68,12 +61,25 @@ public function __invoke( OD_HTML_Tag_Walker $walker ): bool {
* Note also that if this is the LCP element for _some_ of the viewport groups, it will still get
* fetchpriority=high by means of the preload link (with a media query) that is added further below.
*/
$walker->set_attribute( 'data-od-removed-fetchpriority', $walker->get_attribute( 'fetchpriority' ) );
$walker->remove_attribute( 'fetchpriority' );
}

// TODO: If the image is visible (intersectionRatio!=0) in any of the URL metrics, remove loading=lazy.
// TODO: Conversely, if an image is the LCP element for one breakpoint but not another, add loading=lazy. This won't hurt performance since the image is being preloaded.
$element_max_intersection_ratio = $this->url_metrics_group_collection->get_element_max_intersection_ratio( $xpath );

// If the element was not found, we don't know if it was visible for not, so don't do anything.
if ( is_null( $element_max_intersection_ratio ) ) {
$walker->set_meta_attribute( 'unknown-tag', true ); // Mostly useful for debugging why an IMG isn't optimized.
} else {
// Otherwise, make sure visible elements omit the loading attribute, and hidden elements include loading=lazy.
$is_visible = $element_max_intersection_ratio > 0.0;
$loading = (string) $walker->get_attribute( 'loading' );
if ( $is_visible && 'lazy' === $loading ) {
$walker->remove_attribute( 'loading' );
} elseif ( ! $is_visible && 'lazy' !== $loading ) {
$walker->set_attribute( 'loading', 'lazy' );
}
}
// TODO: If an image is visible in one breakpoint but not another, add loading=lazy AND add a regular-priority preload link with media queries (unless LCP in which case it should already have a fetchpriority=high link) so that the image won't be eagerly-loaded for viewports on which it is not shown.
Copy link
Member Author

Choose a reason for hiding this comment

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

I'll be filing an issue for this. It may be a bit confusing when looking at the markup because they may well see an LCP img element which has loading=lazy, but it will still get prioritized via the high-fetchpriority preload link (with a media query). The Lighthouse audit will need to be updated to account for this case.

Copy link
Member Author

Choose a reason for hiding this comment

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

In my testing at #117 (comment) I found that adding loading=lazy to the LCP image does not degrade performance if there is a preload link with fetchpriority=high.

Copy link
Member Author

Choose a reason for hiding this comment

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

I've opened this as #1270


// If this element is the LCP (for a breakpoint group), add a preload link for it.
foreach ( $this->url_metrics_group_collection->get_groups_by_lcp_element( $xpath ) as $group ) {
Expand Down
4 changes: 2 additions & 2 deletions plugins/image-prioritizer/load.php
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
<?php
/**
* Plugin Name: Image Prioritizer
* Plugin URI: https://github.com/WordPress/performance/issues/1088
* Description: Optimizes the loading of the LCP image by leveraging client-side detection with real user metrics.
* Plugin URI: https://github.com/WordPress/performance/tree/trunk/plugins/image-prioritizer
* Description: Optimizes LCP image loading with <code>fetchpriority=high</code> and applies image lazy-loading by leveraging client-side detection with real user metrics.
* Requires at least: 6.4
* Requires PHP: 7.2
* Requires Plugins: optimization-detective
Expand Down
17 changes: 13 additions & 4 deletions plugins/image-prioritizer/readme.txt
Original file line number Diff line number Diff line change
Expand Up @@ -9,15 +9,24 @@ License: GPLv2 or later
License URI: https://www.gnu.org/licenses/gpl-2.0.html
Tags: performance, images

Optimizes the loading of the LCP image by leveraging client-side detection with real user metrics.
Optimizes LCP image loading with `fetchpriority=high` and applies image lazy-loading by leveraging client-side detection with real user metrics.

== Description ==

Optimizes the loading of the LCP image by leveraging client-side detection with real user metrics. Currently, this involves adding `fetchpriority=high` to the LCP image and adding preload links for the LCP `img` as well as any LCP `background-image`.
This plugin optimizes the loading of images which are the LCP (Largest Contentful Paint) element, including both `img` elements and elements with CSS background images (where there is a `style` attribute with an `background-image` property). Different breakpoints in a theme's responsive design may result in differing elements being the LCP element. Therefore, the LCP element for each breakpoint is captured so that high-fetchpriority preload links with media queries are added which prioritize loading the LCP image specific to the viewport of the visitor.

This plugin requires the [Optimization Detective](https://wordpress.org/plugins/optimization-detective/) plugin as a dependency.
In addition to prioritizing the loading of the LCP image, this plugin also optimizes image loading by ensuring that `loading=lazy` is omitted from any image that appears in the initial viewport for any of the breakpoints, which by default include:

TODO: Flesh out description.
1. 0-320 (small smartphone)
2. 321-480 (normal smartphone)
3. 481-576 (phablets)
4. >576 (desktop)

If an image does not appear in the initial viewport for any of these viewport groups, then `loading=lazy` is added to the `img` element.

Note that by default, URL Metrics are not gathered for administrator users, since they are not normal site visitors, and it is likely that additional elements will be present on the page which are not also shown to non-administrators.

This plugin requires the [Optimization Detective](https://wordpress.org/plugins/optimization-detective/) plugin as a dependency. Please refer to that plugin for additional background on how this plugin works as well as additional developer options.

== Installation ==

Expand Down
71 changes: 52 additions & 19 deletions plugins/image-prioritizer/tests/test-helper.php
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ public function data_provider_test_filter_tag_walker_visitors(): array {
<title>...</title>
</head>
<body>
<img data-od-xpath="/*[1][self::HTML]/*[2][self::BODY]/*[1][self::IMG]" src="https://example.com/foo.jpg" alt="Foo" width="1200" height="800" loading="lazy">
<img data-od-unknown-tag data-od-xpath="/*[1][self::HTML]/*[2][self::BODY]/*[1][self::IMG]" src="https://example.com/foo.jpg" alt="Foo" width="1200" height="800" loading="lazy">
<script type="module">/* import detect ... */</script>
</body>
</html>
Expand Down Expand Up @@ -189,7 +189,7 @@ public function data_provider_test_filter_tag_walker_visitors(): array {
',
),

'common-lcp-image-with-fully-populated-sample-data' => array(
'common-lcp-image-and-lazy-loaded-image-outside-viewport-with-fully-populated-sample-data' => array(
'set_up' => function (): void {
$slug = od_get_url_metrics_slug( od_get_normalized_query_vars() );
$sample_size = od_get_url_metrics_breakpoint_sample_size();
Expand All @@ -205,8 +205,24 @@ public function data_provider_test_filter_tag_walker_visitors(): array {
'isLCP' => true,
),
array(
'xpath' => '/*[1][self::HTML]/*[2][self::BODY]/*[2][self::IMG]',
'xpath' => '/*[1][self::HTML]/*[2][self::BODY]/*[3][self::IMG]',
'isLCP' => false,
'intersectionRatio' => 0 === $i ? 0.5 : 0.0, // Make sure that the _max_ intersection ratio is considered.
),
array(
'xpath' => '/*[1][self::HTML]/*[2][self::BODY]/*[5][self::IMG]',
'isLCP' => false,
'intersectionRatio' => 0.0,
),
array(
'xpath' => '/*[1][self::HTML]/*[2][self::BODY]/*[6][self::IMG]',
'isLCP' => false,
'intersectionRatio' => 0.0,
),
array(
'xpath' => '/*[1][self::HTML]/*[2][self::BODY]/*[7][self::IMG]',
'isLCP' => false,
'intersectionRatio' => 0.0,
),
)
)
Expand All @@ -222,7 +238,12 @@ public function data_provider_test_filter_tag_walker_visitors(): array {
</head>
<body>
<img src="https://example.com/foo.jpg" alt="Foo" width="1200" height="800" loading="lazy" srcset="https://example.com/foo-480w.jpg 480w, https://example.com/foo-800w.jpg 800w" sizes="(max-width: 600px) 480px, 800px" crossorigin="anonymous">
<img src="https://example.com/bar.jpg" alt="Bar" width="10" height="10" loading="lazy" fetchpriority="high">
<p>Pretend this is a super long paragraph that pushes the next image mostly out of the initial viewport.</p>
<img src="https://example.com/bar.jpg" alt="Bar" width="10" height="10" fetchpriority="high" loading="lazy">
<p>Now the following image is definitely outside the initial viewport.</p>
<img src="https://example.com/baz.jpg" alt="Baz" width="10" height="10" fetchpriority="high">
<img src="https://example.com/qux.jpg" alt="Qux" width="10" height="10" fetchpriority="high" loading="eager">
<img src="https://example.com/quux.jpg" alt="Quux" width="10" height="10" loading="lazy"><!-- This one is all good. -->
</body>
</html>
',
Expand All @@ -235,7 +256,12 @@ public function data_provider_test_filter_tag_walker_visitors(): array {
</head>
<body>
<img data-od-added-fetchpriority data-od-removed-loading="lazy" fetchpriority="high" src="https://example.com/foo.jpg" alt="Foo" width="1200" height="800" srcset="https://example.com/foo-480w.jpg 480w, https://example.com/foo-800w.jpg 800w" sizes="(max-width: 600px) 480px, 800px" crossorigin="anonymous">
<img data-od-removed-fetchpriority="high" src="https://example.com/bar.jpg" alt="Bar" width="10" height="10" loading="lazy" >
<p>Pretend this is a super long paragraph that pushes the next image mostly out of the initial viewport.</p>
<img data-od-removed-fetchpriority="high" data-od-removed-loading="lazy" src="https://example.com/bar.jpg" alt="Bar" width="10" height="10" >
<p>Now the following image is definitely outside the initial viewport.</p>
<img data-od-added-loading data-od-removed-fetchpriority="high" loading="lazy" src="https://example.com/baz.jpg" alt="Baz" width="10" height="10" >
<img data-od-removed-fetchpriority="high" data-od-replaced-loading="eager" src="https://example.com/qux.jpg" alt="Qux" width="10" height="10" loading="lazy">
<img src="https://example.com/quux.jpg" alt="Quux" width="10" height="10" loading="lazy"><!-- This one is all good. -->
Comment on lines -238 to +264
Copy link
Member Author

Choose a reason for hiding this comment

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

This is the key test showing how the new functionality is optimizing lazy-loading. Compare this markup with the input markup above.

</body>
</html>
',
Expand Down Expand Up @@ -286,8 +312,8 @@ public function data_provider_test_filter_tag_walker_visitors(): array {
<link data-od-added-tag rel="preload" fetchpriority="high" as="image" href="https://example.com/foo.jpg" media="screen and (min-width: 783px)">
</head>
<body>
<img data-od-xpath="/*[1][self::HTML]/*[2][self::BODY]/*[1][self::IMG]" src="https://example.com/foo.jpg" alt="Foo" width="1200" height="800" loading="lazy">
<img data-od-xpath="/*[1][self::HTML]/*[2][self::BODY]/*[2][self::IMG]" src="https://example.com/bar.jpg" alt="Bar" width="10" height="10" loading="lazy" fetchpriority="high">
<img data-od-removed-loading="lazy" data-od-xpath="/*[1][self::HTML]/*[2][self::BODY]/*[1][self::IMG]" src="https://example.com/foo.jpg" alt="Foo" width="1200" height="800" >
<img data-od-removed-loading="lazy" data-od-xpath="/*[1][self::HTML]/*[2][self::BODY]/*[2][self::IMG]" src="https://example.com/bar.jpg" alt="Bar" width="10" height="10" fetchpriority="high">
<script type="module">/* import detect ... */</script>
</body>
</html>
Expand All @@ -306,7 +332,7 @@ public function data_provider_test_filter_tag_walker_visitors(): array {
$viewport_width,
array(
array(
'xpath' => '/*[1][self::HTML]/*[2][self::BODY]/*[1][self::IMG]',
'xpath' => '/*[1][self::HTML]/*[2][self::BODY]/*[1][self::IMG]', // Note: This is intentionally not reflecting the IMG in the HTML below.
'isLCP' => true,
),
)
Expand Down Expand Up @@ -336,7 +362,7 @@ public function data_provider_test_filter_tag_walker_visitors(): array {
</head>
<body>
<script>/* Something injected with wp_body_open */</script>
<img src="https://example.com/foo.jpg" alt="Foo" width="1200" height="800">
<img data-od-unknown-tag src="https://example.com/foo.jpg" alt="Foo" width="1200" height="800">
</body>
</html>
',
Expand Down Expand Up @@ -553,6 +579,7 @@ static function () use ( $mobile_breakpoint, $tablet_breakpoint ): array {
',
),

// TODO: Eventually the images in this test should all be lazy-loaded, leaving the prioritization to the preload links.
Copy link
Member Author

Choose a reason for hiding this comment

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

'different-lcp-elements-for-non-consecutive-viewport-groups-with-missing-data-for-middle-group' => array(
'set_up' => function (): void {
OD_URL_Metrics_Post_Type::store_url_metric(
Expand All @@ -561,12 +588,14 @@ static function () use ( $mobile_breakpoint, $tablet_breakpoint ): array {
400,
array(
array(
'isLCP' => true,
'xpath' => '/*[1][self::HTML]/*[2][self::BODY]/*[1][self::IMG]',
'isLCP' => true,
'xpath' => '/*[1][self::HTML]/*[2][self::BODY]/*[1][self::IMG]',
'intersectionRatio' => 1.0,
),
array(
'isLCP' => false,
'xpath' => '/*[1][self::HTML]/*[2][self::BODY]/*[2][self::IMG]',
'isLCP' => false,
'xpath' => '/*[1][self::HTML]/*[2][self::BODY]/*[2][self::IMG]',
'intersectionRatio' => 0.0,
),
)
)
Expand All @@ -577,12 +606,14 @@ static function () use ( $mobile_breakpoint, $tablet_breakpoint ): array {
800,
array(
array(
'isLCP' => false,
'xpath' => '/*[1][self::HTML]/*[2][self::BODY]/*[1][self::IMG]',
'isLCP' => false,
'xpath' => '/*[1][self::HTML]/*[2][self::BODY]/*[1][self::IMG]',
'intersectionRatio' => 0.0,
),
array(
'isLCP' => true,
'xpath' => '/*[1][self::HTML]/*[2][self::BODY]/*[2][self::IMG]',
'isLCP' => true,
'xpath' => '/*[1][self::HTML]/*[2][self::BODY]/*[2][self::IMG]',
'intersectionRatio' => 1.0,
),
)
)
Expand All @@ -593,6 +624,7 @@ static function () use ( $mobile_breakpoint, $tablet_breakpoint ): array {
<head>
<meta charset="utf-8">
<title>...</title>
<style>/* Never show mobile and desktop logos at the same time. */</style>
</head>
<body>
<img src="https://example.com/mobile-logo.png" alt="Mobile Logo" width="600" height="600">
Expand All @@ -605,6 +637,7 @@ static function () use ( $mobile_breakpoint, $tablet_breakpoint ): array {
<head>
<meta charset="utf-8">
<title>...</title>
<style>/* Never show mobile and desktop logos at the same time. */</style>
<link data-od-added-tag rel="preload" fetchpriority="high" as="image" href="https://example.com/mobile-logo.png" media="screen and (max-width: 480px)">
<link data-od-added-tag rel="preload" fetchpriority="high" as="image" href="https://example.com/desktop-logo.png" media="screen and (min-width: 783px)">
</head>
Expand Down Expand Up @@ -803,7 +836,7 @@ static function () {
<body>
<img src="https://example.com/mobile-logo.png" alt="Mobile Logo" width="600" height="600">
<p>New paragraph since URL Metrics were captured!</p>
<img src="https://example.com/desktop-logo.png" alt="Desktop Logo" width="600" height="600">
<img data-od-unknown-tag src="https://example.com/desktop-logo.png" alt="Desktop Logo" width="600" height="600">
</body>
</html>
',
Expand All @@ -817,7 +850,7 @@ static function () {
<body>
<img data-od-xpath="/*[1][self::HTML]/*[2][self::BODY]/*[1][self::IMG]" src="https://example.com/mobile-logo.png" alt="Mobile Logo" width="600" height="600">
<p>New paragraph since URL Metrics were captured!</p>
<img data-od-xpath="/*[1][self::HTML]/*[2][self::BODY]/*[3][self::IMG]" src="https://example.com/desktop-logo.png" alt="Desktop Logo" width="600" height="600">
<img data-od-xpath="/*[1][self::HTML]/*[2][self::BODY]/*[3][self::IMG]" data-od-unknown-tag src="https://example.com/desktop-logo.png" alt="Desktop Logo" width="600" height="600">
<script type="module">/* import detect ... */</script>
</body>
</html>
Expand Down
31 changes: 29 additions & 2 deletions plugins/optimization-detective/class-od-html-tag-walker.php
Original file line number Diff line number Diff line change
Expand Up @@ -453,7 +453,29 @@ public function get_attribute( string $name ) {
* @return bool Whether an attribute value was set.
*/
public function set_attribute( string $name, $value ): bool {
return $this->processor->set_attribute( $name, $value );
$existing_value = $this->processor->get_attribute( $name );
$result = $this->processor->set_attribute( $name, $value );
if ( $result ) {
if ( is_string( $existing_value ) ) {
$this->set_meta_attribute( "replaced-{$name}", $existing_value );
} else {
$this->set_meta_attribute( "added-{$name}", true );
}
}
return $result;
}

/**
* Sets a meta attribute.
*
* All meta attributes are prefixed with 'data-od-'.
*
* @param string $name Meta attribute name.
* @param string|true $value Value.
* @return bool Whether an attribute was set.
*/
public function set_meta_attribute( string $name, $value ): bool {
Copy link
Member

Choose a reason for hiding this comment

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

Not sure I love the term "meta" here, but can't think of a better term

return $this->processor->set_attribute( "data-od-{$name}", $value );
}

/**
Expand All @@ -469,7 +491,12 @@ public function set_attribute( string $name, $value ): bool {
* @return bool Whether an attribute was removed.
*/
public function remove_attribute( string $name ): bool {
return $this->processor->remove_attribute( $name );
$old_value = $this->processor->get_attribute( $name );
$result = $this->processor->remove_attribute( $name );
if ( $result ) {
$this->set_meta_attribute( "removed-{$name}", is_string( $old_value ) ? $old_value : true );
}
return $result;
}

/**
Expand Down
Loading
Loading