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

Preload image URLs for LCP elements with external background images #1697

Merged
merged 32 commits into from
Dec 11, 2024

Conversation

westonruter
Copy link
Member

@westonruter westonruter commented Nov 22, 2024

Fixes #1584

This captures the URL for the background image of the LCP element which is defined in a CSS stylesheet and not in an inline style attribute. A new client-side extension module from Image Prioritizer is introduced to implement this. A new root property is added to the URL Schema called lcpElementExternalBackgroundImage which includes not only the url of the background image but also the tag name, id, class for the LCP element that has the background image. When the Image_Prioritizer_Background_Image_Styled_Tag_Visitor iterates over tags in the document, it checks to see if there is a matching tag (and thus the original element with the background image is still present). If so, and if all URL Metrics in the group are in agreement on that being the LCP element with that same background image, then the URL for the background image is added as a fetchpriority=high preload link for that viewport group. The requirement that all gathered URL Metrics be in agreement helps ensure that if a randomized background image is used (e.g. as can be done in core's header image), then no preloading will be done (as it is likely the wrong image will be preloaded).

Twenty Thirteen Example

Given the Twenty Thirteen theme which has a CSS background image in the header and some images in the post content, on desktop the header is the LCP element whereas on mobile the first image is the LCP element:

Desktop Mobile
2013-desktop 2013-mobile

With the changes in this PR, the CSS background image gets a fetchpriority=high preload link for desktop, whereas the first image in the content gets a preload link for mobile:

<link
  data-od-added-tag
  rel="preload"
  fetchpriority="high"
  as="image"
  href="http://localhost:8888/wp-content/themes/twentythirteen/images/headers/circle.png"
  media="screen and (min-width: 783px)"
>
<link
  data-od-added-tag
  rel="preload"
  fetchpriority="high"
  as="image"
  href="http://localhost:8888/wp-content/uploads/2024/06/bison2-1024x673-jpg.webp"
  imagesrcset="http://localhost:8888/wp-content/uploads/2024/06/bison2-1024x673-jpg.webp 1024w, http://localhost:8888/wp-content/uploads/2024/06/bison2-300x197-jpg.webp 300w, http://localhost:8888/wp-content/uploads/2024/06/bison2-768x505-jpg.webp 768w, http://localhost:8888/wp-content/uploads/2024/06/bison2-1536x1010.jpg 1536w, http://localhost:8888/wp-content/uploads/2024/06/bison2-2048x1347.jpg 2048w, http://localhost:8888/wp-content/uploads/2024/06/bison2-1568x1031.jpg 1568w"
  imagesizes="(max-width: 1024px) 100vw, 1024px"
  media="screen and (max-width: 480px)"
>

The impact of preloading the background image on mobile is reflected in the network log, where without the optimization the circle.png image is loaded with an initial low priority long after the other assets on the page have started loading:

image

In contrast to when the image is preloaded, it is the initial resource loaded and has an initial high priority:

image

Here are the before/after metrics testing with benchmark-web-vitals (with GoogleChromeLabs/wpp-research#164) without any page caching:

  Desktop Before Desktop After Mobile Before Mobile After
FCP (median) 121.55 143.9 113.25 113.6
LCP (median) 151.95 143.9 113.25 113.6
TTFB (median) 30.1 32.55 32 33.75
LCP-TTFB (median) 122.1 111.35 80.6 79.9
Test setup

I created a site in Local and set the theme to Twenty Thirteen. I then created a post which had a 3-columns block with the center column being wider. I put an image in each column, so the middle image was larger. I visited the URL in a desktop viewport and mobile viewport multiple times to ensure the URL Metrics were fully populated. I then created a text file called preload-bgimage-urls.txt which contained:

http://localhost:10053/bison-columns/?optimization_detective_disabled
http://localhost:10053/bison-columns/

I then ran 100 iterations before/after on desktop and mobile:

npm run research benchmark-web-vitals -- --file=preload-bgimage-urls.txt -n 100 -w "1920x1080" -o csv
npm run research benchmark-web-vitals -- --file=preload-bgimage-urls.txt -n 100 -w "360x800" -o csv

So on desktop, the LCP-TTFB is improved by 8.8%. On mobile, the LCP-TTFB is improved by a lesser degree by 0.87% because WordPress was already adding fetchpriority=high to the first image, so the marginal improvement is gained by the preload link.

Elementor Example

As another test, I created a site with Elementor and the Hello Elementor theme. I used the Ceramic Studio "website kit" to create a page, and I added a mobile-specific image to the hero's second container:

Desktop Mobile
elementor-desktop elementor-mobile

Elementor implements the images here as background images pulled from an external CSS file (http://localhost:10053/wp-content/uploads/elementor/css/post-67.css?ver=1732401999):

.elementor-67 .elementor-element.elementor-element-35a28f8:not(.elementor-motion-effects-element-type-background), .elementor-67 .elementor-element.elementor-element-35a28f8 > .elementor-motion-effects-container > .elementor-motion-effects-layer {
    background-image: url("http://localhost:10053/wp-content/uploads/2024/11/HomePage-Hero.jpg");
    background-position: center center;
    background-repeat: no-repeat;
    background-size: cover;
}
/*...*/
@media(max-width: 767px) {
    /*...*/
    .elementor-67 .elementor-element.elementor-element-35a28f8:not(.elementor-motion-effects-element-type-background), .elementor-67 .elementor-element.elementor-element-35a28f8 > .elementor-motion-effects-container > .elementor-motion-effects-layer {
        background-image: url("http://localhost:10053/wp-content/uploads/2024/11/WorkshopPage-WorkGallery-img_3.jpg");
    }
    /*...*/
}

The element being targeted is:

<div
  class="elementor-element elementor-element-35a28f8 e-con-full e-flex e-con e-child"
  data-id="35a28f8"
  data-element_type="container"
  data-settings='{"background_background":"classic"}'
></div>

Running the same tests as before with 100 iterations on both desktop and mobile, before and after the optimizations, yields the following results (again where mobile is 360x800 and desktop is 1920x1080):

  Desktop Before Desktop After Mobile Before Mobile After
FCP (median) 120.45 122.4 121.3 112.8
LCP (median) 158.85 122.75 145.65 113.25
TTFB (median) 54.5 39.1 55 39.4
LCP-TTFB (median) 103.35 84.55 91.55 71.8

The LCP-TTFB on desktop is improved by 18.19% and on mobile it is improved by 21.57%! 🎉 (What is surprising to me as well is that TTFB is reduced when the optimizations are applied, which doesn't make any sense since the HTML Tag Processor spends cycles doing work.)

It's important to note that this page doesn't just have CSS background images. Further down the page outside the viewport of both desktop and mobile, there are three IMG tags in another section:

image

Elementor is adding fetchpriority=high to this IMG even though it is not even displayed in any initial viewport:

<figure class="elementor-image-box-img">
  <img
    fetchpriority="high"
    decoding="async"
    width="327"
    height="293"
    src="http://localhost:10053/wp-content/uploads/2024/11/HomePage-Offers_1.jpg"
    class="elementor-animation-sink attachment-full size-full wp-image-70"
    alt=""
    srcset="
      http://localhost:10053/wp-content/uploads/2024/11/HomePage-Offers_1.jpg         327w,
      http://localhost:10053/wp-content/uploads/2024/11/HomePage-Offers_1-300x269.jpg 300w
    "
    sizes="(max-width: 327px) 100vw, 327px"
  >
</figure>

The Elementor code responsible is the maybe_add_fetchpriority_high_attr() method which appears to be heavily inspired by what WordPress core does.

Here's a diff of the page (with Prettier formatting). Note how Image Prioritizer is adding responsive preload links for the two different background images while at the same time removing fetchpriority=high from the IMG that Elementor added it to. In addition, Image Prioritizer is adding loading=lazy and sizes=auto to all of these images since none of them appear in the initial viewport on desktop or mobile:

--- /tmp/disabled.html	2024-11-23 21:33:35.088604290 -0800
+++ /tmp/enabled.html	2024-11-23 21:33:42.844604516 -0800
@@ -712,6 +712,22 @@
       }
     </style>
     <meta name="generator" content="image-prioritizer 0.2.0" />
+    <link
+      data-od-added-tag
+      rel="preload"
+      fetchpriority="high"
+      as="image"
+      href="http://localhost:10053/wp-content/uploads/2024/11/HomePage-Hero.jpg"
+      media="screen and (min-width: 783px)"
+    />
+    <link
+      data-od-added-tag
+      rel="preload"
+      fetchpriority="high"
+      as="image"
+      href="http://localhost:10053/wp-content/uploads/2024/11/WorkshopPage-WorkGallery-img_3.jpg"
+      media="screen and (max-width: 480px)"
+    />
   </head>
   <body
     class="home page-template page-template-elementor_header_footer page page-id-67 elementor-default elementor-template-full-width elementor-kit-23 elementor-page elementor-page-67"
@@ -970,7 +986,11 @@
                   <div class="elementor-image-box-wrapper">
                     <figure class="elementor-image-box-img">
                       <img
-                        fetchpriority="high"
+                        data-od-added-loading
+                        data-od-removed-fetchpriority="high"
+                        data-od-replaced-sizes="(max-width: 327px) 100vw, 327px"
+                        data-od-xpath="/*[1][self::HTML]/*[2][self::BODY]/*[3][self::DIV]/*[3][self::DIV]/*[2][self::DIV]/*[2][self::DIV]/*[1][self::DIV]/*[1][self::DIV]/*[1][self::DIV]/*[1][self::DIV]/*[1][self::FIGURE]/*[1][self::IMG]"
+                        loading="lazy"
                         decoding="async"
                         width="327"
                         height="293"
@@ -981,7 +1001,7 @@
                           http://localhost:10053/wp-content/uploads/2024/11/HomePage-Offers_1.jpg         327w,
                           http://localhost:10053/wp-content/uploads/2024/11/HomePage-Offers_1-300x269.jpg 300w
                         "
-                        sizes="(max-width: 327px) 100vw, 327px"
+                        sizes="auto, (max-width: 327px) 100vw, 327px"
                       />
                     </figure>
                     <div class="elementor-image-box-content">
@@ -1032,6 +1052,10 @@
                   <div class="elementor-image-box-wrapper">
                     <figure class="elementor-image-box-img">
                       <img
+                        data-od-added-loading
+                        data-od-replaced-sizes="(max-width: 327px) 100vw, 327px"
+                        data-od-xpath="/*[1][self::HTML]/*[2][self::BODY]/*[3][self::DIV]/*[3][self::DIV]/*[2][self::DIV]/*[2][self::DIV]/*[2][self::DIV]/*[1][self::DIV]/*[1][self::DIV]/*[1][self::DIV]/*[1][self::FIGURE]/*[1][self::IMG]"
+                        loading="lazy"
                         decoding="async"
                         width="327"
                         height="293"
@@ -1042,7 +1066,7 @@
                           http://localhost:10053/wp-content/uploads/2024/11/HomePage-Offers_2.jpg         327w,
                           http://localhost:10053/wp-content/uploads/2024/11/HomePage-Offers_2-300x269.jpg 300w
                         "
-                        sizes="(max-width: 327px) 100vw, 327px"
+                        sizes="auto, (max-width: 327px) 100vw, 327px"
                       />
                     </figure>
                     <div class="elementor-image-box-content">
@@ -1093,6 +1117,10 @@
                   <div class="elementor-image-box-wrapper">
                     <figure class="elementor-image-box-img">
                       <img
+                        data-od-added-loading
+                        data-od-replaced-sizes="(max-width: 327px) 100vw, 327px"
+                        data-od-xpath="/*[1][self::HTML]/*[2][self::BODY]/*[3][self::DIV]/*[3][self::DIV]/*[2][self::DIV]/*[2][self::DIV]/*[3][self::DIV]/*[1][self::DIV]/*[1][self::DIV]/*[1][self::DIV]/*[1][self::FIGURE]/*[1][self::IMG]"
+                        loading="lazy"
                         decoding="async"
                         width="327"
                         height="293"
@@ -1103,7 +1131,7 @@
                           http://localhost:10053/wp-content/uploads/2024/11/HomePage-Offers_3.jpg         327w,
                           http://localhost:10053/wp-content/uploads/2024/11/HomePage-Offers_3-300x269.jpg 300w
                         "
-                        sizes="(max-width: 327px) 100vw, 327px"
+                        sizes="auto, (max-width: 327px) 100vw, 327px"
                       />
                     </figure>
                     <div class="elementor-image-box-content">
@@ -1388,6 +1416,9 @@
                 >
                   <figure class="swiper-slide-inner">
                     <img
+                      data-od-added-loading
+                      data-od-xpath="/*[1][self::HTML]/*[2][self::BODY]/*[3][self::DIV]/*[5][self::DIV]/*[2][self::DIV]/*[1][self::DIV]/*[1][self::DIV]/*[1][self::DIV]/*[1][self::DIV]/*[1][self::FIGURE]/*[1][self::IMG]"
+                      loading="lazy"
                       decoding="async"
                       class="swiper-slide-image"
                       src="http://localhost:10053/wp-content/uploads/2024/11/HomePage-WorkGallery-img_1.jpg"
@@ -1403,6 +1434,9 @@
                 >
                   <figure class="swiper-slide-inner">
                     <img
+                      data-od-added-loading
+                      data-od-xpath="/*[1][self::HTML]/*[2][self::BODY]/*[3][self::DIV]/*[5][self::DIV]/*[2][self::DIV]/*[1][self::DIV]/*[1][self::DIV]/*[1][self::DIV]/*[2][self::DIV]/*[1][self::FIGURE]/*[1][self::IMG]"
+                      loading="lazy"
                       decoding="async"
                       class="swiper-slide-image"
                       src="http://localhost:10053/wp-content/uploads/2024/11/HomePage-WorkGallery-img_2.jpg"
@@ -1418,6 +1452,9 @@
                 >
                   <figure class="swiper-slide-inner">
                     <img
+                      data-od-added-loading
+                      data-od-xpath="/*[1][self::HTML]/*[2][self::BODY]/*[3][self::DIV]/*[5][self::DIV]/*[2][self::DIV]/*[1][self::DIV]/*[1][self::DIV]/*[1][self::DIV]/*[3][self::DIV]/*[1][self::FIGURE]/*[1][self::IMG]"
+                      loading="lazy"
                       decoding="async"
                       class="swiper-slide-image"
                       src="http://localhost:10053/wp-content/uploads/2024/11/HomePage-WorkGallery-img_3.jpg"
@@ -1433,6 +1470,9 @@
                 >
                   <figure class="swiper-slide-inner">
                     <img
+                      data-od-added-loading
+                      data-od-xpath="/*[1][self::HTML]/*[2][self::BODY]/*[3][self::DIV]/*[5][self::DIV]/*[2][self::DIV]/*[1][self::DIV]/*[1][self::DIV]/*[1][self::DIV]/*[4][self::DIV]/*[1][self::FIGURE]/*[1][self::IMG]"
+                      loading="lazy"
                       decoding="async"
                       class="swiper-slide-image"
                       src="http://localhost:10053/wp-content/uploads/2024/11/HomePage-WorkGallery-img_4.jpg"
@@ -1722,5 +1762,32 @@
       src="http://localhost:10053/wp-content/plugins/elementor/assets/js/frontend.min.js?ver=3.25.9"
       id="elementor-frontend-js"
     ></script>
+
+    <script type="module">
+      import detect from "http:\/\/localhost:10053\/wp-content\/plugins\/optimization-detective\/detect.min.js?ver=0.9.0-alpha";
+      detect({
+        minViewportAspectRatio: 0.40000000000000002,
+        maxViewportAspectRatio: 2.5,
+        isDebug: false,
+        extensionModuleUrls: [
+          "http:\/\/localhost:10053\/wp-content\/plugins\/image-prioritizer\/detect.min.js?ver=0.2.0",
+        ],
+        restApiEndpoint:
+          "http:\/\/localhost:10053\/wp-json\/optimization-detective\/v1\/url-metrics:store",
+        currentUrl: "http:\/\/localhost:10053\/",
+        urlMetricSlug: "d751713988987e9331980363e24189ce",
+        cachePurgePostId: 67,
+        urlMetricHMAC: "35086facb74f518514588b7e83751043",
+        urlMetricGroupStatuses: [
+          { minimumViewportWidth: 0, complete: true },
+          { minimumViewportWidth: 481, complete: false },
+          { minimumViewportWidth: 601, complete: false },
+          { minimumViewportWidth: 783, complete: true },
+        ],
+        storageLockTTL: 60,
+        webVitalsLibrarySrc:
+          "http:\/\/localhost:10053\/wp-content\/plugins\/optimization-detective\/build\/web-vitals.js?ver=4.2.4",
+      });
+    </script>
   </body>
 </html>

Network log without the optimizations up until the LCP element's background image is loaded:

image

Compared with after the optimizations applied:

image

Note how the LCP element's background image is now loaded as early as possible with initial high priority, whereas without the optimizations the image is loaded very late and has an initial priority of low.

Takeaway

This represents an critical performance advancement for optimizing LCP in WordPress because on the web a DIV is the second most common LCP element after IMG. Since images account for the LCP type 82% desktop and and 72% on mobile, it's likely that most of the DIV LCP elements represent background images. Additionally, page builders like Elementor and Divi leverage external background images extensively, including separate background images for desktop and mobile.

Top LCP element types Top LCP content types
Top LCP element types Top LCP content types

Todo

  • Add additional validation constraints on the new root property (e.g. require same-origin).
  • Add tests.

@westonruter westonruter added [Type] Enhancement A suggestion for improvement of an existing feature [Plugin] Optimization Detective Issues for the Optimization Detective plugin [Plugin] Image Prioritizer Issues for the Image Prioritizer plugin (dependent on Optimization Detective) labels Nov 22, 2024
@westonruter westonruter added this to the image-prioritizer n.e.x.t milestone Nov 22, 2024
@westonruter
Copy link
Member Author

westonruter commented Nov 23, 2024

@felixarntz This is getting close! I'm excited about this one since it unlocks a very common use case which we've seen especially among page builders. This unlocks potential optimization of the DIV (assuming it has a background image) which is the second most common LCP element.

@westonruter
Copy link
Member Author

I've just updated the description with findings on how this impacts Elementor. In short, I'm seeing a ~20% improvement in LCP on both desktop and mobile!

@westonruter westonruter force-pushed the add/external-bg-preload branch from c872c1b to f2959cd Compare November 25, 2024 04:35
};

export type InitializeCallback = ( args: InitializeArgs ) => void;
export type InitializeCallback = ( args: InitializeArgs ) => Promise< void >;
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 now consistent with the FinalizeCallback.

Copy link
Member

@felixarntz felixarntz left a comment

Choose a reason for hiding this comment

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

@westonruter This looks solid as far as I can tell, but I have a few questions as it's a large and complex PR to review.

$this->add_preload_link( $context->link_collection, $group, $common['url'] );

// Now that the preload link has been added, eliminate the entry to stop looking for it while iterating over the rest of the document.
unset( $this->group_common_lcp_element_external_background_images[ $i ] );
Copy link
Member

Choose a reason for hiding this comment

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

Can this not impact the foreach loop in a problematic way, given this same array we're iterating through? 🤔

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 had wondered that as well, but I found that foreach() operates on a copy of the array, so it is safe to unset keys in the loop.

Examples:
https://3v4l.org/OOlpa
https://3v4l.org/oQF2h

But this could be made less concerning upon inspection if this iterates over array_keys() instead.

Copy link
Member Author

Choose a reason for hiding this comment

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

Addressed in ec59d85

* @param OD_URL_Metric_Group $group URL Metric group.
* @param non-empty-string $url Image URL.
*/
private function add_preload_link( OD_Link_Collection $link_collection, OD_URL_Metric_Group $group, string $url ): void {
Copy link
Member

Choose a reason for hiding this comment

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

Consider adding this as a public method on OD_Link_Collection directly (then without the first parameter of course), wrapping its add_link() method.

Regardless of whether it remains here or there, I think it would be good to rename it to add_image_preload_link(), as it's only for images but that's not obvious from the method name.

Copy link
Member Author

Choose a reason for hiding this comment

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

Good idea. This is closely related to the direction that #1707 is going as well.

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 just renamed it for now in fbb6076.

It's more complicated to make a general method for image preloading because a link may not have an href at all in the case of responsive preloads in which imagesrcset and imagesizes would be used instead (or in addition). It's simpler here for the background image case because it's just a single possible URL value as the href.

I'll keep this in mind for a future enhancement for facilitating image preloads, perhaps as part of #1707 assuming this PR is merged first, or else a separate PR.

*
* Handles 'autoplay' and 'preload' attributes accordingly.
*
* @since 0.2.0
Copy link
Member

Choose a reason for hiding this comment

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

Where does this function come from? It seems unrelated and the PR diff makes it look like it's new? 🤔

plugins/image-prioritizer/helper.php Outdated Show resolved Hide resolved
plugins/optimization-detective/detect.js Outdated Show resolved Hide resolved
plugins/optimization-detective/detect.js Outdated Show resolved Hide resolved
plugins/optimization-detective/storage/rest-api.php Outdated Show resolved Hide resolved
Copy link
Member

@felixarntz felixarntz left a comment

Choose a reason for hiding this comment

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

@westonruter I may be realizing this a bit late, but now I'm curious why this is not more closely tying into what Optimization Detective already provides.

* @return array<string, array{type: string}> Additional properties.
*/
function image_prioritizer_add_element_item_schema_properties( array $additional_properties ): array {
$additional_properties['lcpElementExternalBackgroundImage'] = array(
Copy link
Member

Choose a reason for hiding this comment

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

Maybe I should have asked this before... But why are we introducing this in Image Prioritizer, when Optimization Detective already has awareness of the LCP element?

It feels like we're duplicating a bit of what Optimization Detective already handles (e.g. in the detect.js script the onLCP etc.). Why not enhance this via Optimization Detective? Or provide a more targeted extension point for the LCP element specifically that Image Prioritizer could leverage?

Copy link
Member Author

Choose a reason for hiding this comment

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

Humm. Well, the normal case for how data is collected in Optimization Detective is for a tag visitor to mark tags of interest (which result in data-od-xpath attributes being emitted on them on the frontend), and then for OD's detect.js to collect data about those specific elements to then send back for storage with the URL Metric.

What is needed here is different because we don't know what the element of interest will be, since any element could be LCP with a background image applied with an external stylesheet.

So that's why this new lcpElementExternalBackgroundImage root property is being introduced in the URL Metric schema. There is no XPath because the element is not explicitly visited by tag visitors, so it doesn't necessarily appear in elements. Now, if it just so happens that there is a tag which shows up in elements (which was processed by a tag visitor) which is also the LCP element... it would make sense to store the LCP metric's url there as well. However, this would be coincidental and not something we can rely on specifically for optimizing non-inline background images in LCP elements.

In regards to onLCP, we could potentially expose that onLCP callback (in addition to onCLS, onFCP, onINP, and onTTFB) to the initialize function for each extension. This would eliminate the need to pass the webVitalsLibrarySrc.

Copy link
Member

Choose a reason for hiding this comment

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

I may be missing some understanding here, so apologies in advance. But don't we only care about the background image that's relevant to the LCP element?

So from that perspective, couldn't we have an extension point that allows extensions to expand what data is collected for the LCP element? And then Image Prioritizer could add the background image data as needed?

Copy link
Member Author

Choose a reason for hiding this comment

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

The problem is that the tag visitors don't know what the LCP element with an external background image could possibly be, so they don't know to return true to flag the tag for capturing in URL Metrics. I mean, there could be a tag visitor that just returns true for every visited tag, which would result in data-od-xpath attributes being added to every single tag and then for every single tag to be stored in the elements of URL Metrics. But this would be wasteful.

In the case of an IMG being the LCP element, this is covered because the tag visitors for images are visiting every IMG so they are all being captured in the elements of a URL Metric. When such an IMG is the LCP element, we don't even need the url from the LCP Metric because the URL is right there in the src and srcset. The same goes for an element which has an inline background-image style: the tag visitor can see it so optimization can preload what is defined right there in the HTML.

But for elements with external background images, this is a special 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.

I've exposed the Web Vitals functions to extensions in 47d06dc.

Copy link
Member

Choose a reason for hiding this comment

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

47d06dc#diff-ec3ffd3a54ce7df9d216b18a1dee27e4c41785e16698bf3229efcef5e7072fb2R64 looks good to me. It's definitely a nice little enhancement to not have to reimplement that part.

I guess I had not considered this scenario before - so is it correct that, in case the LCP element is not one of the specific elements that are being visited, it will be unknown what the LCP element is for that page load?

In that case, it makes sense that the extension has to implement it. Though I wonder whether we could make this a bit more ergonomic and centralize the implementation, e.g. to get benefits like "common LCP element" out of the box (with what #1723 improved). Doesn't have to be in this PR, but maybe we could implement the LCP element logic centrally so that every URL metric receives the LCP element, whichever it is? If we're worried about overhead of doing this when it's not needed, we could make it opt-in so that it's only recorded if at least one extension opts in to requiring it.

It is a bit specific to implement this directly in OD, but on the other hand my concern is that not doing so will have any extension that needs something LCP related to reinvent the wheel, when instead we could have a general LCP element handler and extensions could only extend what data is included as part of it. WDYT?

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 guess I had not considered this scenario before - so is it correct that, in case the LCP element is not one of the specific elements that are being visited, it will be unknown what the LCP element is for that page load?

Yes, that's exactly right. It won't be captured which element is the LCP element since the LCP element is something other than one of the elements being tracked.

In that case, it makes sense that the extension has to implement it. Though I wonder whether we could make this a bit more ergonomic and centralize the implementation, e.g. to get benefits like "common LCP element" out of the box (with what #1723 improved). Doesn't have to be in this PR, but maybe we could implement the LCP element logic centrally so that every URL metric receives the LCP element, whichever it is? If we're worried about overhead of doing this when it's not needed, we could make it opt-in so that it's only recorded if at least one extension opts in to requiring it.

So you're saying to implement a new root-level property in the URL Metric, like lcpElement (alongside the existing elements) which captures the properties in the LargestContentfulPaint interface? In this PR that's what is essentially what is being done, although specifically for a background image. And since the element can't be captured as-is, this PR is capturing the id (also exposed on LargestContentfulPaint), tag, and class as a way to identify the element. Ideally we'd be able to store the server-side XPath to the element, but again it may not be a visited tag, so that xpath property could perhaps be a nullable field so that client-side detection can provide it if it so happens that the LCP element has a data-od-xpath attribute. In this way, we wouldn't technically need to store isLCP with each element, as we could then just look to see if the xpath for an element matches the XPath stored with the LCP data.

And if we're capturing these properties of LargestContentfulPaint, should we also then capture the other properties like size, renderTime, and loadTime?

And then if we're capturing the LCP metrics, should we then also centralize the capturing of the CLS and INP data as well?

Or maybe we reconsider for later since this could go to far. For storing the LCP url, there's also the question of data validation. We'd want to make sure that we only preload safe URLs which is being addressed in #1713. We can always update Image Prioritizer later to reuse LCP metric data stored by Optimization Detective centrally, especially when/if it comes to light that more use cases need access to this centralized data.

So how about going with an Image Prioritizer-specific implementation but then we open a new issue to discuss capturing these metrics centrally in a future version. This would tie in nicely with the Performance dashboard (#1324) since the data would already be there (although the dashboard would probably want to introduce additional data retention to capture more than the sample size).

Copy link
Member

Choose a reason for hiding this comment

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

So you're saying to implement a new root-level property in the URL Metric, like lcpElement (alongside the existing elements) which captures the properties in the LargestContentfulPaint interface?

Yes, exactly.

And if we're capturing these properties of LargestContentfulPaint, should we also then capture the other properties like size, renderTime, and loadTime?

Maybe. I think we should probably keep it light in the default implementation, but allow extensions to expand what is detected. For example there should be an extension point where the LCP data is reported in JS so that a plugin could add more data to the lcpElement object that is sent to the REST API, and on the server-side it would need to be possible to extend the schema for what can be in lcpElement.

I also would prefer not to include actual performance numbers, because OD is primarily an optimization tool, not a monitoring tool. A separate extension for monitoring could add such data as needed.

And then if we're capturing the LCP metrics, should we then also centralize the capturing of the CLS and INP data as well?

Potentially, but only if we see a need for it. While it seems to make sense from a metric perspective, I'd argue LCP is different from CLS and INP because there is no "CLS element" or "INP element". The LCP element is directly relevant to things that Optimization Detective allows to do. This also relates to what I mention above about focusing on areas that help optimization, not performance metrics. I find it less applicable for INP and CLS, because what would we send there to help optimization? Possibly there is some useful data (e.g. long tasks), but I feel that this should be separately explored, if at all.

So how about going with an Image Prioritizer-specific implementation but then we open a new issue to discuss capturing these metrics centrally in a future version. This would tie in nicely with the Performance dashboard (#1324) since the data would already be there (although the dashboard would probably want to introduce additional data retention to capture more than the sample size).

That sounds good to me.

Copy link
Member Author

Choose a reason for hiding this comment

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

there is no "CLS element"

Well, there are elements that cause CLS. So in theory a top-level CLS property could capture the LayoutShiftAttribution sources. Also, for INP That said, Embed Optimizer isn't using this and is instead using ResizeObserver. Things to explore in the new issue. I've just filed it as #1730.

@@ -65,33 +80,128 @@ public function __invoke( OD_Tag_Visitor_Context $context ): bool {
}

if ( is_null( $background_image_url ) ) {
$this->maybe_preload_external_lcp_background_image( $context );
return false;
}

$xpath = $processor->get_xpath();

// If this element is the LCP (for a breakpoint group), add a preload link for it.
foreach ( $context->url_metric_group_collection->get_groups_by_lcp_element( $xpath ) as $group ) {
Copy link
Member

@felixarntz felixarntz Dec 9, 2024

Choose a reason for hiding this comment

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

This is missing the fix we're including via #1723. I think we should merge that PR since it's more of a fix while this is an enhancement, and then update this PR to follow suit. Otherwise we're introducing the same problem for LCP background images that #1723 is fixing for the LCP generally.

This re-emphasizes to me what I mentioned in my previous point: If we were using Optimization Detective's own LCP handling, we would be able to get this kind of correct behavior "for free", rather than having to re-implement it here too. So I'm curious why we're not doing that.

At a minimum, this needs to be fixed here to be aligned with what #1723 does.

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 missing the fix we're including via #1723.

I'm not sure this is the case. The fix in #1723 is to allow fetchpriority=high to be on the IMG element when only the mobile and desktop viewport groups have URL Metrics collected. Preload links are still only being added for IMG for the actual viewport groups that have URL metrics collected for them:

foreach ( $context->url_metric_group_collection->get_groups_by_lcp_element( $xpath ) as $group ) {
$context->link_collection->add_link(
$attributes,
$group->get_minimum_viewport_width(),
$group->get_maximum_viewport_width()
);
}

Or are you saying that if the mobile and desktop viewport groups have the same LCP element, that we should go ahead and also preload the same URL for that LCP Element even for phablet and tablet when no URL Metrics are yet confirming that they also have the same LCP element?

This re-emphasizes to me what I mentioned in my previous point: If we were using Optimization Detective's own LCP handling, we would be able to get this kind of correct behavior "for free", rather than having to re-implement it here too. So I'm curious why we're not doing that.

I'm not sure I understand. For elements which are tracked in URL Metrics (and an element appears in elements when there is a tag visitor which returns true for a tag during iterator), then as part of the stored metadata for that tracked element will be whether it isLCP. So here it's looking up which viewport groups have the current tag as the LCP element, and for each one a preload link is added.

@@ -424,8 +471,6 @@ export default async function detect( {
} );
}

const { onLCP } = await import( webVitalsLibrarySrc );

/** @type {LCPMetric[]} */
const lcpMetricCandidates = [];

Copy link
Member Author

Choose a reason for hiding this comment

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

Now that onLCP is typed as OnLCPFunction I shouldn't think that /** @type LCPMetric */ is needed here:

onLCP(
( /** @type LCPMetric */ metric ) => {
lcpMetricCandidates.push( metric );
resolve();
},

However, when I remove it, TypeScript in PHPStorm complains:

image

It doesn't complain about the usage in Image Prioritizer's detect.js here, however:

image

Copy link
Member

@adamsilverstein adamsilverstein left a comment

Choose a reason for hiding this comment

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

Looking good!

@westonruter westonruter merged commit b1bf1ae into trunk Dec 11, 2024
18 checks passed
@westonruter westonruter deleted the add/external-bg-preload branch December 11, 2024 19:36
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
[Plugin] Image Prioritizer Issues for the Image Prioritizer plugin (dependent on Optimization Detective) [Plugin] Optimization Detective Issues for the Optimization Detective plugin [Type] Enhancement A suggestion for improvement of an existing feature
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Image Prioritizer should be able to store the LCP (background) image URL for prioritization
3 participants