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

Compute responsive sizes attribute based on the width from the boundingClientRect in captured URL Metrics #1840

Merged
merged 13 commits into from
Feb 8, 2025
Merged
Show file tree
Hide file tree
Changes from 12 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -146,21 +146,44 @@ private function process_img( OD_HTML_Tag_Processor $processor, OD_Tag_Visitor_C
$processor->remove_attribute( 'fetchpriority' );
}

// Ensure that sizes=auto is set properly.
$sizes = $processor->get_attribute( 'sizes' );
if ( is_string( $sizes ) ) {
// Ensure that sizes is set properly when it is a responsive image (it has a srcset attribute).
if ( is_string( $processor->get_attribute( 'srcset' ) ) ) {
felixarntz marked this conversation as resolved.
Show resolved Hide resolved
$sizes = $processor->get_attribute( 'sizes' );
if ( ! is_string( $sizes ) ) {
$sizes = '';
}

$is_lazy = 'lazy' === $this->get_attribute_value( $processor, 'loading' );
$has_auto = $this->sizes_attribute_includes_valid_auto( $sizes );

if ( $is_lazy && ! $has_auto ) {
$processor->set_attribute( 'sizes', "auto, $sizes" );
$new_sizes = 'auto';
if ( '' !== trim( $sizes, " \t\f\r\n" ) ) {
$new_sizes .= ', ';
}
$sizes = $new_sizes . $sizes;
} elseif ( ! $is_lazy && $has_auto ) {
// Remove auto from the beginning of the list.
$processor->set_attribute(
'sizes',
(string) preg_replace( '/^[ \t\f\r\n]*auto[ \t\f\r\n]*(,[ \t\f\r\n]*)?/i', '', $sizes )
);
$sizes = (string) preg_replace( '/^[ \t\f\r\n]*auto[ \t\f\r\n]*(,[ \t\f\r\n]*)?/i', '', $sizes );
}

// Compute more accurate sizes when it isn't lazy-loaded and sizes=auto isn't taking care of it.
if ( ! $is_lazy ) {
$computed_sizes = $this->compute_sizes( $context );
if ( count( $computed_sizes ) > 0 ) {
$new_sizes = join( ', ', $computed_sizes );

// Preserve the original sizes as a fallback when URL Metrics are missing from one or more viewport group.
// Note that when all groups are populated, the media features will span all possible viewport widths from
// zero to infinity, so there is no need to include the original sizes since they will never match.
if ( '' !== $sizes && ! $context->url_metric_group_collection->is_every_group_populated() ) {
$new_sizes .= ", $sizes";
}
$sizes = $new_sizes;
}
}

$processor->set_attribute( 'sizes', $sizes );
}

$parent_tag = $this->get_parent_tag_name( $context );
Expand Down Expand Up @@ -385,4 +408,38 @@ private function sizes_attribute_includes_valid_auto( string $sizes_attr ): bool
return 'auto' === $sizes_attr || str_starts_with( $sizes_attr, 'auto,' );
}
}

/**
* Computes responsive sizes for the current element based on its boundingClientRect width captured in URL Metrics.
*
* @since n.e.x.t
*
* @param OD_Tag_Visitor_Context $context Context.
* @return non-empty-string[] Computed sizes.
*/
private function compute_sizes( OD_Tag_Visitor_Context $context ): array {
$sizes = array();

$xpath = $context->processor->get_xpath();
foreach ( $context->url_metric_group_collection as $group ) {
// Obtain the maximum width that the image appears among all URL Metrics collected for this viewport group.
$element_max_width = 0;
foreach ( $group->get_xpath_elements_map()[ $xpath ] ?? array() as $element ) {
$element_max_width = max( $element_max_width, $element->get_bounding_client_rect()['width'] );
}

// Use the maximum width as the size for image in this breakpoint.
if ( $element_max_width > 0 ) {
$size = sprintf( '%dpx', $element_max_width );
$media_feature = od_generate_media_query( $group->get_minimum_viewport_width(), $group->get_maximum_viewport_width() );
if ( null !== $media_feature ) {
// Note: The null case only happens when a site has filtered od_breakpoint_max_widths to be an empty array, meaning there is only one viewport group.
$size = "$media_feature $size";
}
$sizes[] = $size;
}
}

return $sizes;
}
}
6 changes: 4 additions & 2 deletions plugins/image-prioritizer/readme.txt
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ License: GPLv2 or later
License URI: https://www.gnu.org/licenses/gpl-2.0.html
Tags: performance, optimization, image, lcp, lazy-load

Prioritizes the loading of images and videos based on how visible they are to actual visitors; adds fetchpriority and applies lazy loading.
Prioritizes the loading of images and videos based on how they appear to actual visitors: adds fetchpriority, preloads, lazy-loads, and sets sizes.

== Description ==

Expand All @@ -27,7 +27,9 @@ The current optimizations include:
1. Apply lazy loading to `IMG` tags based on whether they appear in any breakpoint’s initial viewport.
2. Implement lazy loading of CSS background images added via inline `style` attributes.
3. Lazy-load `VIDEO` tags by setting the appropriate attributes based on whether they appear in the initial viewport. If a `VIDEO` is the LCP element, it gets `preload=auto`; if it is in an initial viewport, the `preload=metadata` default is left; if it is not in an initial viewport, it gets `preload=none`. Lazy-loaded videos also get initial `preload`, `autoplay`, and `poster` attributes restored when the `VIDEO` is going to enter the viewport.
5. Ensure that [`sizes=auto`](https://make.wordpress.org/core/2024/10/18/auto-sizes-for-lazy-loaded-images-in-wordpress-6-7/) is added to all lazy-loaded `IMG` elements.
5. Responsive image sizes:
1. Ensure [`sizes=auto`](https://make.wordpress.org/core/2024/10/18/auto-sizes-for-lazy-loaded-images-in-wordpress-6-7/) is added to all lazy-loaded `IMG` elements.
Copy link
Member

Choose a reason for hiding this comment

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

I know this text isn't new, but might be worth clarifying why that's important. Based on how it's currently phrased here, people might argue "But WordPress Core already does this!".

So maybe this can be rephrased to clarify that it's about ensuring that sizes=auto is applied correctly after Image Prioritizer has improved/fixed lazy-loading of images as done by WordPress.

Since the sizes=auto part isn't really a feature this plugin does better than WordPress (it's the lazy-loading portion it does better than WordPress, but not how it applies sizes=auto), I would put the point of computing the correct sizes above this, as it's a more powerful feature of the plugin.

Copy link
Member Author

Choose a reason for hiding this comment

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

Like so?

Suggested change
1. Ensure [`sizes=auto`](https://make.wordpress.org/core/2024/10/18/auto-sizes-for-lazy-loaded-images-in-wordpress-6-7/) is added to all lazy-loaded `IMG` elements.
1. Ensure [`sizes=auto`](https://make.wordpress.org/core/2024/10/18/auto-sizes-for-lazy-loaded-images-in-wordpress-6-7/) is set on `IMG` tags after setting correct lazy-loading (above).

Copy link
Member

@felixarntz felixarntz Feb 8, 2025

Choose a reason for hiding this comment

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

Yeah, plus moving it to #2 in the list as mentioned :)

Copy link
Member Author

Choose a reason for hiding this comment

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

Ah yes! Done: 4d0251a

2. Compute the `sizes` attribute using the widths of an image collected from URL Metrics for each breakpoint (when not lazy-loaded since then handled by `sizes=auto`).
6. Reduce the size of the `poster` image of a `VIDEO` from full size to the size appropriate for the maximum width of the video (on desktop).

**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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,6 @@ static function () use ( $breakpoint_max_widths ) {
}
);

$outside_viewport_rect = array_merge(
$test_case->get_sample_dom_rect(),
array(
'top' => 100000,
)
);

foreach ( $breakpoint_max_widths as $non_desktop_viewport_width ) {
for ( $i = 0; $i < $sample_size; $i++ ) {
OD_URL_Metrics_Post_Type::store_url_metric(
Expand All @@ -29,8 +22,7 @@ static function () use ( $breakpoint_max_widths ) {
'xpath' => '/HTML/BODY/DIV[@id=\'page\']/*[2][self::DIV]',
'isLCP' => false,
'intersectionRatio' => 0.0,
'intersectionRect' => $outside_viewport_rect,
'boundingClientRect' => $outside_viewport_rect,
'boundingClientRect' => array( 'top' => 100000 ),
),
),
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,6 @@ static function () use ( $breakpoint_max_widths ) {
}
);

$outside_viewport_rect = array_merge(
$test_case->get_sample_dom_rect(),
array(
'top' => 100000,
)
);

foreach ( $breakpoint_max_widths as $non_desktop_viewport_width ) {
OD_URL_Metrics_Post_Type::store_url_metric(
od_get_url_metrics_slug( od_get_normalized_query_vars() ),
Expand All @@ -27,8 +20,7 @@ static function () use ( $breakpoint_max_widths ) {
'xpath' => '/HTML/BODY/DIV[@id=\'page\']/*[2][self::DIV]',
'isLCP' => false,
'intersectionRatio' => 0.0,
'intersectionRect' => $outside_viewport_rect,
'boundingClientRect' => $outside_viewport_rect,
'boundingClientRect' => array( 'top' => 100000 ),
),
),
)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,12 +1,5 @@
<?php
return static function ( Test_Image_Prioritizer_Helper $test_case ): void {
$outside_viewport_rect = array_merge(
$test_case->get_sample_dom_rect(),
array(
'top' => 100000,
)
);

$test_case->populate_url_metrics(
array(
array(
Expand All @@ -17,15 +10,13 @@
'xpath' => '/HTML/BODY/DIV[@id=\'page\']/*[3][self::DIV]',
'isLCP' => false,
'intersectionRatio' => 0.0,
'intersectionRect' => $outside_viewport_rect,
'boundingClientRect' => $outside_viewport_rect,
'boundingClientRect' => array( 'top' => 100000 ),
),
array(
'xpath' => '/HTML/BODY/DIV[@id=\'page\']/*[4][self::DIV]',
'isLCP' => false,
'intersectionRatio' => 0.0,
'intersectionRect' => $outside_viewport_rect,
'boundingClientRect' => $outside_viewport_rect,
'boundingClientRect' => array( 'top' => 100000 ),
),
)
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,9 @@
<img src="https://example.com/foo3.jpg" alt="Foo" width="1200" height="800" fetchpriority="high" srcset="https://example.com/foo3-480w.jpg 480w, https://example.com/foo3-800w.jpg 800w" sizes="(max-width: 600px) 480px, 800px">
</div>
<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">
<img src="https://example.com/bar.jpg" alt="Bar" width="1024" height="768" fetchpriority="high" loading="lazy" srcset="https://example.com/bar-300w.jpg 300w, https://example.com/bar-480w.jpg 480w, https://example.com/bar-800w.jpg 800w, https://example.com/bar-900w.jpg 900w, https://example.com/bar-1000w.jpg 1000w" sizes="(max-width: 1024px) 100vw, 1024px">
<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/baz.jpg" alt="Baz" width="3000" height="1500" fetchpriority="high" srcset="https://example.com/baz-300w.jpg 300w, https://example.com/baz-480w.jpg 480w, https://example.com/baz-800w.jpg 800w, https://example.com/baz-900w.jpg 900w, https://example.com/baz-1000w.jpg 1000w, https://example.com/baz-1500w.jpg 1500w, https://example.com/baz-2000w.jpg 2000w, https://example.com/baz-2500w.jpg 2500w" sizes="(max-width: 3000px) 100vw, 3000px">
<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. -->
</div>
Expand Down

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
@@ -1,13 +1,7 @@
<?php
return static function ( Test_Image_Prioritizer_Helper $test_case ): void {
$slug = od_get_url_metrics_slug( od_get_normalized_query_vars() );
$sample_size = od_get_url_metrics_breakpoint_sample_size();
$outside_viewport_rect = array_merge(
$test_case->get_sample_dom_rect(),
array(
'top' => 100000,
)
);
$slug = od_get_url_metrics_slug( od_get_normalized_query_vars() );
$sample_size = od_get_url_metrics_breakpoint_sample_size();
foreach ( array_merge( od_get_breakpoint_max_widths(), array( 1000 ) ) as $viewport_width ) {
for ( $i = 0; $i < $sample_size; $i++ ) {
OD_URL_Metrics_Post_Type::store_url_metric(
Expand All @@ -31,31 +25,31 @@
'intersectionRatio' => 0.0, // Subsequent carousel slide.
),
array(
'xpath' => '/HTML/BODY/DIV[@id=\'page\']/*[3][self::IMG]',
'isLCP' => false,
'intersectionRatio' => 0 === $i ? 0.5 : 0.0, // Make sure that the _max_ intersection ratio is considered.
'xpath' => '/HTML/BODY/DIV[@id=\'page\']/*[3][self::IMG]',
'isLCP' => false,
'intersectionRatio' => 0 === $i ? 0.5 : 0.0, // Make sure that the _max_ intersection ratio is considered.
'boundingClientRect' => array(
'width' => $viewport_width - 10,
),
),
// All are outside all initial viewports.
array(
'xpath' => '/HTML/BODY/DIV[@id=\'page\']/*[5][self::IMG]',
'isLCP' => false,
'intersectionRatio' => 0.0,
'intersectionRect' => $outside_viewport_rect,
Copy link
Member Author

Choose a reason for hiding this comment

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

Note that the intersectionRect now automatically is populated to be zeroed out when the interectionRatio is 0. Previously it was not making sense to have a DOMRect containing non-zero values when the intersectionRatio was zero.

'boundingClientRect' => $outside_viewport_rect,
'boundingClientRect' => array( 'top' => 100000 ),
),
array(
'xpath' => '/HTML/BODY/DIV[@id=\'page\']/*[6][self::IMG]',
'isLCP' => false,
'intersectionRatio' => 0.0,
'intersectionRect' => $outside_viewport_rect,
'boundingClientRect' => $outside_viewport_rect,
'boundingClientRect' => array( 'top' => 100000 ),
),
array(
'xpath' => '/HTML/BODY/DIV[@id=\'page\']/*[7][self::IMG]',
'isLCP' => false,
'intersectionRatio' => 0.0,
'intersectionRect' => $outside_viewport_rect,
'boundingClientRect' => $outside_viewport_rect,
'boundingClientRect' => array( 'top' => 100000 ),
),
),
)
Expand Down

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading