-
Notifications
You must be signed in to change notification settings - Fork 107
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
Add preload links LCP picture elements #1707
Add preload links LCP picture elements #1707
Conversation
The following accounts have interacted with this PR and/or linked issues. I will continue to update these lists as activity occurs. You can also manually ask me to refresh this list by adding the If you're merging code through a pull request on GitHub, copy and paste the following into the bottom of the merge commit message.
To understand the WordPress project's expectations around crediting contributors, please review the Contributor Attribution page in the Core Handbook. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks for working on this!
/** | ||
* Flag to indicate whether we're within a `<picture>` element. | ||
* | ||
* @var bool | ||
*/ | ||
private $is_within_picture = false; | ||
|
||
/** | ||
* Collected `<source>` elements within the current `<picture>`. | ||
* | ||
* @var array<int, array{srcset?: string|true|null,sizes?: string|true|null,type?: string|true|null,media?: string|true|null,crossorigin?: string|true|null}> | ||
*/ | ||
private $collected_sources = array(); | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Do these need to be member variables? They're only ever used in one method, so wouldn't they make more sense as just regular variables?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Oh, well I see it's because you're relying on this being invoked multiple times, so you're wanting to persist this state across multiple tag visits.
// Opening <picture> tag. | ||
$this->is_within_picture = true; | ||
$this->collected_sources = array(); | ||
} else { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actually, this condition should never occur because in the optimization loop it only ever visits open tags:
} while ( $processor->next_open_tag() ); |
$this->collected_sources[] = array( | ||
'srcset' => $processor->get_attribute( 'srcset' ), | ||
'sizes' => $processor->get_attribute( 'sizes' ), | ||
'type' => $processor->get_attribute( 'type' ), | ||
'media' => $processor->get_attribute( 'media' ), | ||
'crossorigin' => $this->get_attribute_value( $processor, 'crossorigin' ), | ||
); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think this needs some additional logic. We need to prevent a scenario where there is a PICTURE
with the first SOURCE
being an AVIF image and then the second being a WebP, with the third being a JPEG fallback. We must only ever add a preload link for the first SOURCE
because otherwise if a browser supports AVIF and WebP then they could end up preloading both but only the first would be used.
See comments:
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Hi can you please complete this comment it seems to be cutoff.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I have identified different cases which we need to handle.
- Picture element with multiple
source
tags withoutmedia
attribute.
- Select first
source
- Picture element with multiple
source
tags withmedia
attribute.
- Add preload link for every
source
- Picture element with no
source
tags.
- Add preload link using
img
tagssrcset
- Picture element with multiple
source
but some havemedia
attribute and some don't havemedia
attribute.
- How should we handle this case?
Did I miss some cases?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Oops. Sorry for the poor comment. Not sure how that happened.
- Picture element with multiple
source
tags withoutmedia
attribute.
And naturally with type
attributes.
2. Picture element with multiple
source
tags withmedia
attribute.
And without type
attributes.
3. Picture element with no
source
tags.
This would be an error scenario. A PICTURE tag without a SOURCE is not valid. So just do nothing in this case.
4. Picture element with multiple
source
but some havemedia
attribute and some don't havemedia
attribute.
I think this would be an unusual case. I suggest we skip this scenario.
In fact, I suggest we may want to skip handling PICTURE tags that have SOURCE tags with media
attributes entirely. This greatly complicate things, since we'd need to make sure that only one of the SOURCE tags ever get preloaded, but this would require computing some negated media query or something. As I recall, when a PICTURE decides which SOURCE to use, it will iterate over the SOURCE tags until it arrives at the first one that has a matching media
and has a supported type
. But this first-match logic is difficult to replicate for preload links.
Since our Modern Image Formats plugin only outputs SOURCE tags with type
attributes, this would be the most important to support.
We can leave support for media
attributes for a future enhancement, especially as we find there is a need for it.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Gemini concurs that preloading SOURCE with media attributes is not feasible, after initially getting it wrong: https://g.co/gemini/share/57ac56545028
So yeah, for PICTURE, grab the first SOURCE child and only preload it if none of the SOURCE tags have a media
attribute. The only way to properly prioritize such an image is to ensure that fetchpriority=high
is on the IMG tag, which we should make sure is happening in addition to adding preload links.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I have now implemented this in df1d90e
So yeah, for PICTURE, grab the first SOURCE child and only preload it if none of the SOURCE tags have a media attribute.
But I have a doubt about the part
The only way to properly prioritize such an image is to ensure that
fetchpriority=high
is on the IMG tag, which we should make sure is happening in addition to adding preload links.
Currently preload link has fetchpriority=high
set but the image of that picture element has data-od-removed-fetchpriority="high"
should we add fetchpriority=high
to the image and remove the added data-od-removed-fetchpriority
aatribute?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
OK, I just tested this out and it is actually working currently as expected. Chrome is reporting that the LCP element is the PICTURE > IMG
tag, not the PICTURE
wrapper tag:
So when all viewport groups are populated with URL Metrics, it is resulting in the following markup in trunk
:
<picture class="wp-picture-394" style="display: contents"
><source
type="image/webp"
srcset="
http://localhost:8888/wp-content/uploads/2024/11/1620px-Australasian_swamphen_Porphyrio_melanotus_Tiritiri_Matangi-1024x683-jpg.webp 1024w,
http://localhost:8888/wp-content/uploads/2024/11/1620px-Australasian_swamphen_Porphyrio_melanotus_Tiritiri_Matangi-300x200-jpg.webp 300w,
http://localhost:8888/wp-content/uploads/2024/11/1620px-Australasian_swamphen_Porphyrio_melanotus_Tiritiri_Matangi-768x512-jpg.webp 768w,
http://localhost:8888/wp-content/uploads/2024/11/1620px-Australasian_swamphen_Porphyrio_melanotus_Tiritiri_Matangi-1536x1024-jpg.webp 1536w,
http://localhost:8888/wp-content/uploads/2024/11/1620px-Australasian_swamphen_Porphyrio_melanotus_Tiritiri_Matangi-jpg.webp 1620w
"
sizes="(max-width: 1024px) 100vw, 1024px" />
<img
data-od-fetchpriority-already-added=""
data-od-xpath="/*[1][self::HTML]/*[2][self::BODY]/*[3][self::DIV]/*[3][self::DIV]/*[1][self::DIV]/*[1][self::MAIN]/*[1][self::ARTICLE]/*[2][self::DIV]/*[1][self::FIGURE]/*[1][self::PICTURE]/*[2][self::IMG]"
data-dominant-color="8a9464"
data-has-transparency="false"
style="--dominant-color: #8a9464"
fetchpriority="high"
decoding="async"
width="1024"
height="683"
src="http://localhost:8888/wp-content/uploads/2024/11/1620px-Australasian_swamphen_Porphyrio_melanotus_Tiritiri_Matangi-1024x683.jpg"
alt=""
class="wp-image-394 not-transparent"
srcset="
http://localhost:8888/wp-content/uploads/2024/11/1620px-Australasian_swamphen_Porphyrio_melanotus_Tiritiri_Matangi-1024x683.jpg 1024w,
http://localhost:8888/wp-content/uploads/2024/11/1620px-Australasian_swamphen_Porphyrio_melanotus_Tiritiri_Matangi-300x200.jpg 300w,
http://localhost:8888/wp-content/uploads/2024/11/1620px-Australasian_swamphen_Porphyrio_melanotus_Tiritiri_Matangi-768x512.jpg 768w,
http://localhost:8888/wp-content/uploads/2024/11/1620px-Australasian_swamphen_Porphyrio_melanotus_Tiritiri_Matangi-1536x1024.jpg 1536w,
http://localhost:8888/wp-content/uploads/2024/11/1620px-Australasian_swamphen_Porphyrio_melanotus_Tiritiri_Matangi-1568x1045.jpg 1568w,
http://localhost:8888/wp-content/uploads/2024/11/1620px-Australasian_swamphen_Porphyrio_melanotus_Tiritiri_Matangi.jpg 1620w
"
sizes="(max-width: 1024px) 100vw, 1024px"
/></picture>
I believe you saw data-od-removed-fetchpriority="high"
being added because you didn't have URL Metrics collected for some of the viewport groups, and currently the OD_URL_Metric_Group_Collection::get_common_lcp_element()
method is defined so that all URL Metrics groups must be populated in order for the method to return an element. I think this should be changed to align better with how we determine whether to do lazy-loading: if both the narrowest viewport group (i.e. mobile) and the widest viewport group (i.e. desktop) are populated with URL Metrics and these groups both return an LCP Element via $group->get_lcp_element()
which has the same XPath, then we should consider that it is likely for all of the viewport groups to have the same LCP element (i.e. phablet and tablet).
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I've filed this in #1710.
$tag = $processor->get_tag(); | ||
|
||
// Handle opening and closing `<picture>` tags. | ||
if ( 'PICTURE' === $tag ) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Instead of relying on this __invoke
function being called on the PICTURE
and then also on the child SOURCE
tags (and IMG
) tag, I suggest instead that when 'PICTURE' === $tag
that this here call a new method like process_picture()
and that this method would:
- Call
$processor->set_bookmark( 'img-prioritizer-picture' )
- Add a
while( $processor->next_tag()
loop. Note that our subclass'snext_tag()
method always visits tag closers until$processor->get_tag() === 'picture' && $processor->is_tag_closer()
. - Gather up the attributes data from the
SOURCE
tags and the finalIMG
tag. - Finally reset the processor back to where you started:
$processor->set_bookmark('img-prioritizer-picture')
. - Process the data collected from the child tags, and call
$context->link_collection->add_link()
, leaving the other location as-is for just for processing bareIMG
tags.
This approach of a sub-loop was also taken by Embed Optimizer in embed_optimizer_update_markup()
.
if ( $this->is_within_picture && count( $this->collected_sources ) > 0 ) { | ||
foreach ( $this->collected_sources as $source ) { | ||
$link_attributes = array_merge( | ||
array( | ||
'rel' => 'preload', | ||
'fetchpriority' => 'high', | ||
'as' => 'image', | ||
), | ||
array_filter( | ||
array( | ||
'href' => isset( $source['srcset'] ) && is_string( $source['srcset'] ) | ||
? explode( ' ', $source['srcset'] )[0] | ||
: '', | ||
'imagesrcset' => isset( $source['srcset'] ) && is_string( $source['srcset'] ) ? $source['srcset'] : '', | ||
'imagesizes' => isset( $source['sizes'] ) && is_string( $source['sizes'] ) ? $source['sizes'] : '', | ||
'type' => isset( $source['type'] ) && is_string( $source['type'] ) ? $source['type'] : '', | ||
'media' => isset( $source['media'] ) && is_string( $source['media'] ) ? 'screen and ' . $source['media'] : 'screen', | ||
), | ||
static function ( string $value ): bool { | ||
return '' !== $value; | ||
} | ||
) | ||
); | ||
|
||
if ( isset( $source['crossorigin'] ) ) { | ||
$link_attributes['crossorigin'] = 'use-credentials' === $source['crossorigin'] ? 'use-credentials' : 'anonymous'; | ||
} | ||
|
||
$context->link_collection->add_link( | ||
$link_attributes, | ||
$group->get_minimum_viewport_width(), | ||
$group->get_maximum_viewport_width() | ||
); | ||
} | ||
} else { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
As noted above, I think this logic should rather go into a process_picture()
method along with the other new logic above.
We'll also need to add unit test cases specifically to test the various PICTURE scenarios. |
|
||
if ( 'PICTURE' === $tag ) { | ||
$this->process_picture( $processor, $context ); | ||
return false; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This would need to return true
because we need to capture this tag in URL Metrics, correct? Or maybe we don't if the browser reports that the IMG inside of the PICTURE is the LCP element. We should double check which element is reported as LCP.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes its need to be true now the process_picture
return will handle should URL Metrics should be tracked or not.
…Only add preload link for first source
} | ||
|
||
if ( 'IMG' !== $tag ) { | ||
return false; | ||
} | ||
|
||
return $this->process_img( $processor, $context ); | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Might as well refactor this a bit so the cases are consistent:
} | |
if ( 'IMG' !== $tag ) { | |
return false; | |
} | |
return $this->process_img( $processor, $context ); | |
} | |
} elseif ( 'IMG' === $tag ) { | |
return $this->process_img( $processor, $context ); | |
} | |
return false; | |
} |
$steps = explode( '/', $xpath ); | ||
if ( count( $steps ) < 3 ) { | ||
// There is no parent. | ||
return null; | ||
} | ||
$second_last_step = $steps[ count( $steps ) - 2 ]; | ||
if ( (bool) preg_match( '/\[self::([^\]]+)\]/', $second_last_step, $matches ) ) { | ||
return $matches[1]; | ||
} | ||
return null; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
There is currently a private OD_HTML_Tag_Processor::get_breadcrumbs()
method which returns an array of tuples for the tag name and index for each ancestor tag. I think we could reuse that here. I suggest:
- Rename the existing
OD_HTML_Tag_Processor::get_breadcrumbs()
toget_indexed_breadcrumbs()
. - Add a new
public
methodOD_HTML_Tag_Processor::get_breadcrumbs()
which simply returns$this->open_stack_tags
, which would be essentially providing theWP_HTML_Processor::get_breadcrumbs()
method in theWP_HTML_Tag_Processor
context. - This
get_parent_tag_name()
method could then be implemented as:
$breadcrumbs = $context->processor->get_breadcrumbs();
$length = count( $breadcrumbs );
if ( $length < 2 ) {
return null;
}
return $breadcrumbs[ $length - 2 ];
if ( 'PICTURE' === $parent_tag ) { | ||
return true; | ||
} | ||
|
||
$this->pre_img_process( $processor, $context, $xpath ); | ||
|
||
$this->add_preload_link_for_img( $processor, $context, $xpath ); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
if ( 'PICTURE' === $parent_tag ) { | |
return true; | |
} | |
$this->pre_img_process( $processor, $context, $xpath ); | |
$this->add_preload_link_for_img( $processor, $context, $xpath ); | |
if ( 'PICTURE' !== $parent_tag ) { | |
$this->pre_img_process( $processor, $context, $xpath ); | |
$this->add_preload_link_for_img( $processor, $context, $xpath ); | |
} |
|
||
$this->add_preload_link_for_picture( $context, $img_xpath, $collected_sources[0] ); | ||
|
||
return true; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I don't believe we need to track the PICTURE
in URL Metrics because only the IMG
is reported as the LCP element. I don't believe that either PICTURE
or SOURCE
matter in terms of the intersection observer either.
return true; | |
return false; |
|
||
// Ensure that all <source> elements have a type attribute and no media attribute. | ||
if ( null !== $media || null === $type ) { | ||
return false; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Problem: When this happens, the IMG
will no longer get its necessary optimizations.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Were the necessary optimizations happening in the pre_img_process
function? if thats the case then as the logic of that function is now moved directly into the process_img
function the optimizations will still happening even if this function returns here.
// Process the <img> element within the <picture>. | ||
if ( 'IMG' === $tag && ! $processor->is_tag_closer() ) { | ||
// Skip empty src attributes and data: URLs. | ||
$src = trim( (string) $processor->get_attribute( 'src' ) ); | ||
if ( '' === $src || $this->is_data_url( $src ) ) { | ||
return false; | ||
} | ||
|
||
$img_xpath = $processor->get_xpath(); | ||
|
||
$this->pre_img_process( $processor, $context, $img_xpath ); | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Do we need this here? Couldn't we just rather leave the existing IMG
handling in place when __invoke()
lands on an IMG
tag? We would just modify the logic there to only add preload links if the parent is not a PICTURE
.
// Process the <img> element within the <picture>. | |
if ( 'IMG' === $tag && ! $processor->is_tag_closer() ) { | |
// Skip empty src attributes and data: URLs. | |
$src = trim( (string) $processor->get_attribute( 'src' ) ); | |
if ( '' === $src || $this->is_data_url( $src ) ) { | |
return false; | |
} | |
$img_xpath = $processor->get_xpath(); | |
$this->pre_img_process( $processor, $context, $img_xpath ); | |
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I have removed the pre_img_process
as this processed directly in process_img
but the below logic is still there as it is needed for images xpath
which is required for adding preload links in process_picture
.
if ( 'IMG' === $tag && ! $processor->is_tag_closer() ) {
// Skip empty src attributes and data: URLs.
$src = trim( (string) $processor->get_attribute( 'src' ) );
if ( '' === $src || $this->is_data_url( $src ) ) {
return false;
}
$img_xpath = $processor->get_xpath();
}
* @param OD_Tag_Visitor_Context $context Tag visitor context. | ||
* @param string $xpath XPath of the element. | ||
*/ | ||
private function pre_img_process( OD_HTML_Tag_Processor $processor, OD_Tag_Visitor_Context $context, string $xpath ): void { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Per above, I don't think this needs to be in another method. It can remain in process_img()
, with one change: it should only proceed to add preload links for the IMG
if the parent is not a PICTURE
.
* @param string $xpath XPath of the element. | ||
* @param array{srcset?: string|true|null,sizes?: string|true|null,type?: string|true|null,media?: string|true|null,crossorigin?: string|true|null} $source Collected sources from the <source> elements. | ||
*/ | ||
private function add_preload_link_for_picture( OD_Tag_Visitor_Context $context, string $xpath, array $source ): void { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think this logic could be put inside process_picture()
, unless there is an opportunity to refactor it so that it can be reused both for PICTURE
and IMG
.
* @param OD_Tag_Visitor_Context $context Tag visitor context. | ||
* @param string $xpath XPath of the element. | ||
*/ | ||
private function add_preload_link_for_img( OD_HTML_Tag_Processor $processor, OD_Tag_Visitor_Context $context, string $xpath ): void { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Per above, I think this logic can remain in process_img
but just to wrap it in a check like if ( 'PICTURE' !== $parent_tag )
.
Also, I think this logic could be put inside process_img()
, unless there is an opportunity to refactor it so that it can be reused both for PICTURE
and IMG
.
…me get_breadcrumbs to get_indexed_breadcrumbs
plugins/image-prioritizer/class-image-prioritizer-img-tag-visitor.php
Outdated
Show resolved
Hide resolved
plugins/image-prioritizer/class-image-prioritizer-img-tag-visitor.php
Outdated
Show resolved
Hide resolved
plugins/image-prioritizer/class-image-prioritizer-img-tag-visitor.php
Outdated
Show resolved
Hide resolved
This is looking pretty good! The biggest remaining piece is to add test cases for the new optimization code. |
plugins/image-prioritizer/class-image-prioritizer-img-tag-visitor.php
Outdated
Show resolved
Hide resolved
@@ -296,7 +296,7 @@ public function next_token(): bool { | |||
$i = array_search( 'P', $this->open_stack_tags, true ); | |||
if ( false !== $i ) { | |||
array_splice( $this->open_stack_tags, (int) $i ); | |||
array_splice( $this->open_stack_indices, count( $this->open_stack_tags ) ); | |||
array_splice( $this->open_stack_indices, count( $this->open_stack_tags ) + 1 ); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fixing a classic off-by-one error.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🎉
Fixes #1312
This PR fixes the issue of preloading fallback image instead of preloading the images in the
source
element when wrapped inpicture
element.Example Picture element
Preload Link Before
Preload Link After
This PR also introduces a public
OD_HTML_Tag_Processor::get_breadcrumbs()
method which mirrors the functionality ofWP_HTML_Processor::get_breadcrumbs()
. As part of that, a bug was discovered in howOD_HTML_Tag_Processor
handles implicitly-closedP
tags which resulted in duplicate XPaths being generated.Additionally, when an
IMG
had areferrerpolicy
attribute this is copied to the preloadLINK
the same as thecrossorigin
attribute is copied.