Skip to content

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

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

Merged
merged 13 commits into from
Feb 8, 2025
Merged
Show file tree
Hide file tree
Changes from 7 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 @@ -44,6 +44,38 @@ public function __invoke( OD_Tag_Visitor_Context $context ): bool {
return false;
}

/**
* 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 ) {
$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'] );
}

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 ) {
$size = "$media_feature $size";
}
$sizes[] = $size;
}
}

return $sizes;
}

/**
* Process an IMG element.
*
Expand Down Expand Up @@ -146,21 +178,40 @@ 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' ) ) ) {
$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 );
if ( '' !== $sizes ) {
Copy link
Member Author

@westonruter westonruter Feb 4, 2025

Choose a reason for hiding this comment

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

Should this be preserving the original sizes at the end? It can result in some conditions that are never matched. For example, if the original sizes attribute contains (max-width: 600px) 480px, 800px and then we prefix this value with:

(max-width: 480px) 432px, 
(min-width: 480px and max-width: 600px) 540px, 
(min-width: 600px and max-width: 782px) 704px, 
(min-width: 782px) 900px

Then the original conditions will never apply because (min-width: 782px) 900px is matched first. The only scenario where they could apply is if there aren't URL Metrics gathered for some of the viewport widths, and in this case leaving the original in place would serve as a fallback. Otherwise, we could remove it if all viewport groups are populated:

Suggested change
if ( '' !== $sizes ) {
if ( '' !== $sizes && ! $context->url_metric_group_collection->is_every_group_populated() ) {

But it doesn't seem to hurt to keep the original at the end.

Copy link
Member

Choose a reason for hiding this comment

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

Wouldn't the media queries generated by Image Prioritizer always cover all viewport ranges? I don't see a reason to include the original sizes if they do. Of course if only some viewport groups are populated, it would be better to have the original sizes included.

That said, I think it would be safer to only alter the sizes attribute anyway when there's sufficient samples for all viewport groups. With the media queries it includes and the original sizes media queries, I'm wary of conflicts. Since we don't want to make anything worse but only better, I would be in favor to start with only computing sizes if all viewport groups are populated.

Copy link
Member Author

@westonruter westonruter Feb 7, 2025

Choose a reason for hiding this comment

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

They would only include viewport ranges for collected URL Metrics. So if nobody gets phablet or tablet traffic, then those viewport ranges would be missing. Nevertheless, the media features computed here include the minimum and the maximum. So let's say that sizes attribute is originally (max-width: 600px) 480px, 800px from WordPress and then we prepend (width <= 480px) 400px, (width > 872px) 800px since we only have URL metrics for mobile and desktop. When these are combined with the original sizes, we get:

(width <= 480px) 400px, (width > 782px) 800px, (max-width: 600px) 480px, 800px

This means that, since sizes are processed left to right:

  • Mobile viewports will get an image sized at 400px.
  • Desktop viewports will get an image sized at 800px.
  • Tablet and phablet visitors will fall back to the original sizes: (max-width: 600px) 480px, 800px, which means it will be no worse than WordPress currently does by default.

Since processing happens left-to-right, and the first condition matched is used, then there won't be a conflict.

Copy link
Member

Choose a reason for hiding this comment

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

Thanks for clarifying, that makes sense. May be worth adding an inline comment to mention that because of every viewport group providing minimum and maximum sizes, any viewport groups that are missing would simply still behave like before.

I like the idea of leaving out the original sizes value though if all viewport groups are populated, because then the original sizes would have no value at all and just look confusing in the frontend. If that's straightforward, let's add it.

Copy link
Member Author

Choose a reason for hiding this comment

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

Thanks for clarifying, that makes sense. May be worth adding an inline comment to mention that because of every viewport group providing minimum and maximum sizes, any viewport groups that are missing would simply still behave like before.

Done in d2b9a5c

I like the idea of leaving out the original sizes value though if all viewport groups are populated, because then the original sizes would have no value at all and just look confusing in the frontend. If that's straightforward, let's add it.

Done in 93961fa. (Note my original suggestion above was incorrect in that I forgot to include the negation for the second condition, which I've now fixed in my suggestion.)

Note how now the common-lcp-image-and-lazy-loaded-image-outside-viewport-with-fully-populated-sample-data test case will omit the original sizes since all viewports are populated:

<img data-od-removed-loading="lazy" data-od-replaced-fetchpriority="low" data-od-replaced-sizes="(max-width: 600px) 480px, 800px" src="https://example.com/foo1.jpg" alt="Foo" width="1200" height="800" fetchpriority="high" srcset="https://example.com/foo1-480w.jpg 480w, https://example.com/foo1-800w.jpg 800w" sizes="(width &lt;= 480px) 432px, (480px &lt; width &lt;= 600px) 540px, (600px &lt; width &lt;= 782px) 703px, (782px &lt; width) 900px" crossorigin="anonymous">

Compare this with the only-mobile-and-desktop-groups-are-populated test case which retains the original sizes as a fallback since the phablet and tablet URL Metrics are absent:

<img data-od-removed-fetchpriority="high" data-od-replaced-sizes="(max-width: 1200px) 100vw, 1200px" data-od-xpath="/HTML/BODY/DIV[@id=&#039;page&#039;]/*[2][self::MAIN]/*[2][self::ARTICLE]/*[2][self::FIGURE]/*[1][self::IMG]" src="https://example.com/featured-image.jpg" width="1200" height="600" alt="Featured Image" class="attachment-post-thumbnail size-post-thumbnail wp-post-image" srcset="https://example.com/featured-image-1200.jpg 1200w, https://example.com/featured-image-600.jpg 600w, https://example.com/featured-image-300.jpg 300w" sizes="(width &lt;= 480px) 360px, (782px &lt; width) 720px, (max-width: 1200px) 100vw, 1200px">

$new_sizes .= ", $sizes";
}
$sizes = $new_sizes;
}
}

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

$parent_tag = $this->get_parent_tag_name( $context );
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.

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

// Populate the mobile and desktop viewport groups only.
foreach ( array( 400, 800 ) as $viewport_width ) {
for ( $i = 0; $i < $sample_size; $i++ ) {
Expand All @@ -35,8 +28,7 @@ static function () {
'xpath' => '/HTML/BODY/DIV[@id=\'page\']/*[2][self::MAIN]/*[4][self::DIV]',
'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
Loading