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

Prioritize loading fonts for textual LCP elements #1313

Open
westonruter opened this issue Jun 23, 2024 · 9 comments
Open

Prioritize loading fonts for textual LCP elements #1313

westonruter opened this issue Jun 23, 2024 · 9 comments
Labels
[Plugin] Optimization Detective Issues for the Optimization Detective plugin [Type] Feature A new feature within an existing module

Comments

@westonruter
Copy link
Member

westonruter commented Jun 23, 2024

Feature Description

When the LCP element is text, the loading of the font being used should be prioritized. For example, on one of my blog posts (using the Twenty Twenty theme), the LCP element is an h1. It has a font-family style of:

"Inter var", -apple-system, BlinkMacSystemFont, "Helvetica Neue", Helvetica, sans-serif 

The Inter var font is loaded via this stylesheet:

<link rel="stylesheet" id="twentytwenty-fonts-css" href="https://weston.ruter.net/wp-content/themes/twentytwenty/assets/css/font-inter.css?ver=2.6" media="all">

The @font-face rule is:

@font-face {
	font-family: "Inter var";
	font-weight: 100 900; /* stylelint-disable-line font-weight-notation */
	font-style: normal;
	font-display: swap;
	src: url(../fonts/inter/Inter-upright-var.woff2) format("woff2");
}

The font-inter.css stylesheet is already loaded with highest priority, but the font file is not in the critical path so it is not discovered until after the critical CSS is parsed:

image

To improve performance, this font file should be getting loaded sooner by adding this link:

<link rel="preload" as="font" href="https://weston.ruter.net/wp-content/themes/twentytwenty/assets/fonts/inter/Inter-upright-var.woff2" fetchpriority="high">

This allows the font file to start loading the same time as the font-inter.css stylesheet:

image

And this will improve LCP.

Note that h1 is LCP element 5% of the time on mobile, with h2 and h3 being 2% and 1% respectively. The p element is the LCP element 9% off the time on mobile.

@westonruter westonruter added [Type] Feature A new feature within an existing module [Plugin] Optimization Detective Issues for the Optimization Detective plugin labels Jun 23, 2024
@westonruter westonruter changed the title Prioritizer loading fonts for textual LCP elements Prioritize loading fonts for textual LCP elements Jun 23, 2024
@westonruter
Copy link
Member Author

I'm thinking about how this would be implemented in practice.

It seems it would rely on calling getComputedStyle() on the LCP text element to determine the current font-family. It would then need to get the first font in that list, and then iterate over all of the stylehseets in document.styleSheets and for all of the styleSheet.cssRules` inside of them, for example:

( textElement ) => {
    const cssFontFaceRules = [];
    const stripQuotes = ( str ) => str.replace( /^"/, '' ).replace( /"$/, '' );
    const computedStyle = getComputedStyle( textElement );
    const fontFamilies = computedStyle.fontFamily.split( /\s*,\s*/ );
    const fontFamily = stripQuotes( fontFamilies[0] );
    for (const sheet of document.styleSheets) {
        for (const rule of sheet.cssRules) {
            if (rule.constructor.name === 'CSSFontFaceRule' && fontFamily === stripQuotes( rule.style.fontFamily )) {
                cssFontFaceRules.push( rule );
            }
        }
    }
    return cssFontFaceRules;
}

But note there can be multiple fonts that have the same font-family name, but just vary in terms of the font-weight and font-style:

@font-face {
	font-family: "Inter var";
	font-weight: 100 900;
	font-style: normal;
	font-display: swap;
	src: url(./assets/fonts/inter/Inter-upright-var.woff2) format("woff2");
}

@font-face {
	font-family: "Inter var";
	font-weight: 100 900;
	font-style: italic;
	font-display: swap;
	src: url(./assets/fonts/inter/Inter-italic-var.woff2) format("woff2");
}

So when determining the font file to prioritize loading, it would also need to look at the computed style to find the weight and style to determine which variant of the font should actually be preloaded.

Nevertheless, there could also be duplicate @font-face rules altogether. The above are from Twenty Twenty's style.css. However, Twenty Twenty also includes the following in assets/css/font-inter.css:

@font-face {
	font-family: "Inter var";
	font-weight: 100 900; /* stylelint-disable-line font-weight-notation */
	font-style: normal;
	font-display: swap;
	src: url(../fonts/inter/Inter-upright-var.woff2) format("woff2");
}

@font-face {
	font-family: "Inter var";
	font-weight: 100 900; /* stylelint-disable-line font-weight-notation */
	font-style: italic;
	font-display: swap;
	src: url(../fonts/inter/Inter-italic-var.woff2) format("woff2");
}

So these two stylesheets are duplicating the @font-face rules.

The last one encountered should be used since it wins the cascade.

@westonruter
Copy link
Member Author

This all depends on the new client-side extension system being implemented in #1373, so this issue is blocked by that.

I suppose a new dependent plugin would be required for this as it wouldn't make sense in Image Prioritizer or Embed Optimizer.

@benniledl
Copy link

benniledl commented Nov 6, 2024

hey @westonruter
I can't find any statistics on which elements are most often LCP but I guess it's mostly <img> or <h1> tags, if we could find some reliable source and if <h1> is very likely to be the lcp image then maybe we could start off by always preloading the font thats used for <h1>

@westonruter
Copy link
Member Author

@swissspidy
Copy link
Member

Does the element type (div or h1) really matter? As long as we get information from Optimization Detective about the LCP element and the font it uses, then that's all that's needed to preload the font file.

@benniledl
Copy link

benniledl commented Nov 6, 2024

I thought that the challenge lies in identifying which element is the LCP element. So I thought: If statistics indicate that text elements, like headers, are frequently the LCP element, we could simplify by not determining the lcp but just preloading the font used for <h1> elements by default, regardless of which specific element ends up as the LCP. (h1 since I thought it's probably the most common to be an lcp element out of all text elements)

@swissspidy
Copy link
Member

swissspidy commented Nov 6, 2024

Normally, yes. But with Optimization Detective that's not a problem. With it, we get the LCP element information when you visit a webpage, then we store that in the database and on the next page load we retrieve it from there to add the preloading. That's how Embed Optimizer and Image Prioritizer work. Hope that makes sense.

I suppose a new dependent plugin would be required for this as it wouldn't make sense in Image Prioritizer or Embed Optimizer.

@westonruter we could perhaps rename it to "Assets Prioritizer" or "Media Prioritizer"

@westonruter
Copy link
Member Author

Right, Optimization Detective leverages web-vitals.js which tells us what the LCP element is. For example, this patch logs out the LCP element:

diff --git a/plugins/optimization-detective/detect.js b/plugins/optimization-detective/detect.js
index 9445ffbe..f468196d 100644
--- a/plugins/optimization-detective/detect.js
+++ b/plugins/optimization-detective/detect.js
@@ -437,6 +437,9 @@ export default async function detect( {
 	await new Promise( ( resolve ) => {
 		onLCP(
 			( /** @type LCPMetric */ metric ) => {
+				for ( const entry of metric.entries ) {
+					console.log( 'LCP candidate element:', entry.element );
+				}
 				lcpMetricCandidates.push( metric );
 				resolve();
 			},

we could perhaps rename it to "Assets Prioritizer" or "Media Prioritizer"

I think perhaps a new "Text Optimizer" plugin is warranted for this actually. Optimizing text is a very different problem space than optimizing images (and video), where there will need to be more client-side logic to sniff out the font being used for the LCP element. In this way it is similar to how Embed Optimizer is also a separate plugin which is tailored for the needs of embeds (e.g. adding a ResizeObserver to be able to reduce CLS).

As for how to get this started with implementation:

I think the od_url_metric_schema_root_additional_properties filter would be used to add a new top-level field to the URL Metric, perhaps something like lcpTextFontFaces. This should probably be an array of objects, each of which has a src property with a uri format and a format property which is an enumerated string of the known font format types (e.g. woff2, woff, otf). The family would probably be helpful as well. For example:

[
    {
        "family": "Inter var",
        "src": "https://example.com/wp-content/themes/twentytwenty/assets/fonts/inter/Inter-upright-var.woff2",
        "format": "woff2"
    }
]

Then a od_extension_module_urls filter would be used to register a new extension script module URL for Text Optimizer. See the Embed Optimizer plugin for an example of this.

This script module would then add its own onLCP callback to capture the LCP element. To do this we probably need to pass the webVitalsLibrarySrc as an arg into each extension script module as noted here:

// TODO: There should to be a way to pass additional args into the module. Perhaps extensionModuleUrls should be a mapping of URLs to args. It's important to pass webVitalsLibrarySrc to the extension so that onLCP, onCLS, or onINP can be obtained.
if ( extension.initialize instanceof Function ) {
extension.initialize( { isDebug } );
}

This extension script module would then need to do some CSS legwork to:

  1. Get the computed style of the LCP element to obtain the fontFamily, fontWeight, and fontStyle.
  2. Parse out the individual font families from that string to obtain the first one.
  3. Loop over all stylesheets to find @font-face rules that have a matching fontFamily, fontWeight, and fontStyle.
  4. Parse out the font URLs and their formats from the src. If there are multiple formats specified in the src, probably only the first should be captured since we wouldn't want to add font preload links for alternative formats as well.

When the extension module script's finalize method is called, it will need to invoke the provided extendRootData function to send back the accumulated font data for the lcpTextFontFaces key.

So then the font data should be stored in the URL Metrics. Then we need to actually optimize the page by adding the font preload link. This would be handled differently than how other optimizations have been applied with Optimization Detective, since they have relied on tag visitor callbacks to apply optimizations to specific tags located in the page In particular, registered tag visitors mark potential elements to be optimized so that their XPaths will be stored in URL Metrics in order to be re-located during optimization. For optimizing LCP text, however, we don't really need a tag visitor at all. We just need the HTML Tag Processor instance to be passed to an object prior to it being serialized so that the necessary font preload link can be inserted. For example:

--- a/plugins/optimization-detective/optimization.php
+++ b/plugins/optimization-detective/optimization.php
@@ -235,6 +235,15 @@ function od_optimize_template_output_buffer( string $buffer ): string {
 		}
 	} while ( $processor->next_open_tag() );
 
+	/**
+	 * Fires after the document has been iterated over and each tag has been visited by the registered tag visitors.
+	 *
+	 * @since n.e.x.t
+	 *
+	 * @param OD_Tag_Visitor_Context $tag_visitor_context
+	 */
+	do_action( 'od_document_tags_visited', $tag_visitor_context );
+
 	// Send any preload links in a Link response header and in a LINK tag injected at the end of the HEAD.
 	if ( count( $link_collection ) > 0 ) {
 		$response_header_links = $link_collection->get_response_header();

(Aside: The OD_Tag_Visitor_Context class name should be renamed since it's not used exclusively by tag visitors anymore. Maybe OD_Optimization_Context or OD_Processor_Context would be better.)

With such a od_document_tags_visited action in hand, the Text Optimizer plugin could then:

  1. Loop over all of the URL metrics in $context->url_metric_group_collection to gather up all of the lcpTextFontFaces referenced, as well as the min/max viewport widths for the group.
  2. Add font preload links for the desired fonts via $context->link_collection->add_link(). Each call should include the $minimum_viewport_width and $maximum_viewport_width so that the font preloading is not done for viewports in which the font doesn't appear. Additionally, the MIME type of the font format should be added as a type attribute for this preload link so that browsers which don't support the format won't load it.

I believe this will get us to automatically preloading the fonts for LCP text.

Important: Since the client is discovering the font URL, it will be critical to ensure that when the URL Metric is submitted that the font URL is valid. A malicious user could attempt to send a URL Metric that points to a bogus font file, for example a URL on some untrusted domain. It will be critical to validate the URL. If the font file is on the same domain of the site, a file exists check could be used. Otherwise, if the font file is on another domain than there should probably be an allowlist of origins that fonts can come from (e.g. Google Fonts). There is also a risk that malicious actors could add preload links for many fonts that aren't even used on the page. I don't think there's anything we can do to prevent this. It's a similar issue to #1584 where if we store the URL of a CSS background image for prioritization in URL Metrics, there is the potential for abuse. This is why Image Prioritizer has until now only stored the XPath for the image element to prioritize, and then the actual URL to preload is computed at runtime from the document. However, for background images (or fonts) coming from stylesheets, it is not feasible to do such runtime processing.

@westonruter
Copy link
Member Author

I did some benchmarking to see what impact preloading the font would have.

I used Local to create a vanilla site with the Twenty Twenty theme active. Then, in order to ensure that the H1 is the LCP element, I created a post with a long title that had text spanning 4 lines:

I then added the following plugin:

<?php
/**
 * Plugin Name: Preload Font
 */

add_action( 'wp_head', static function () {
	if ( isset( $_GET['disable_font_preload'] ) ) {
		return;
	}
	?>
	<link rel="preload" as="font" type="font/woff2" href="http://localhost:10058/wp-content/themes/twentytwenty/assets/fonts/inter/Inter-upright-var.woff2" fetchpriority="high" crossorigin="anonymous">
	<?php
}, 0 );

I then created a font-preload-urls.txt text file containing:

http://localhost:10058/a-blog-post-with-a-long-title/?disable_font_preload=1
http://localhost:10058/a-blog-post-with-a-long-title/

Then I used benchmark-web-vitals (with GoogleChromeLabs/wpp-research#164) to benchmark the performance comparing 100 iterations without the font preload with 100 iterations with the font preload:

npm run research benchmark-web-vitals -- --file=font-preload-urls.txt -n 10  -w "360x640"

Unfortunately, the preload link actually resulted in worse performance, which is surprising to me:

Metric Before After
FCP (median) 87.2 91.15
LCP (median) 87.2 91.15
TTFB (median) 24.55 24.45
LCP-TTFB (median) 60.6 65.25

Here LCP is 7.67% slower. Why?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
[Plugin] Optimization Detective Issues for the Optimization Detective plugin [Type] Feature A new feature within an existing module
Projects
Status: Not Started/Backlog 📆
Development

No branches or pull requests

3 participants