Preloaded external fonts arenot preconnected on certain templates
Describe the bug When preloading external fonts in style tag , we are not preconnecting them
To Reproduce Steps to reproduce the behavior:
- install and activate WPR 3.19
- enable preload fonts
- visit page with external font defined in style tag
- clear cache and revisit page => external font URL is not preconnected
Expected behavior external font is preconnected as mentioned here https://www.notion.so/wpmedia/Preload-fonts-user-stories-17eed22a22f0807f8d31eb500d860e24?pvs=4#183ed22a22f080228b40d5c7fcdaed02 (in Epic https://www.notion.so/wpmedia/Preload-fonts-126ed22a22f0808ba45ad3673c2c176b#17ced22a22f08038b2fcc744e5ef9f83)
Additional context
- Slack discussion: https://group-onecom.slack.com/archives/C08F4LZB2QG/p1747752403207769
- test page https://new.rocketlabsqa.ovh/preloadfonts_withrucss
Please also note that this seems to be the case for any external domains referenced within a style tag. When testing with this page template https://rocketlabsqa.ovh/preconnect_external_resources/, I noticed that on some visits, none of the external links inside the style tag are preconnected, while on other visits, one or two may be. This behavior appears to be inconsistent.
However, I don’t believe we specifically addressed this scenario during implementation. The style tag is not mentioned in the related GitHub issue (#7247), and the Notion documentation only contains a passing comment about it, here: Notion link.
cc @piotrbak, @DahmaniAdame
Another note: Some external domains are not preconnected, regardless of their inclusion via a style tag. This can be noticed with the following page template:
https://rocketlabsqa.ovh/preloadfonts_multiple_external_fonts/ (preloadfonts_multiple_external_fonts.php)
We expect to see 5 external domains preconnected:
- https://fonts.googleapis.com
- https://fonts.bunny.net
- https://api.fontshare.com
- https://unpkg.com
- https://cdn.jsdelivr.net
However, in the preconnect_external_domains table in the database, only the following 3 are actually preconnected:
preloaded font here https://use.fontawesome.com/releases/v6.5.1/webfonts/fa-solid-900.woff2 isn't preconnected https://new.rocketlabsqa.ovh/fontawesome-css-loaded/
It has nothing to do with WP Rocket itself, but more with beacon within rocket-scripts
Scope a solution
The issue involved external font URLs defined within style tags not being preconnected. To address this, we enhanced the BeaconPreconnectExternalDomain class to process style tags and extract external font URLs from @font-face rules.
Key Changes
-
Added processStyleTags Method: This method parses all
styletags in the document, extracts URLs from@font-facerules, and processes them for preconnection.
processStyleTags() {
const styleTags = document.querySelectorAll('style');
styleTags.forEach(styleTag => {
try {
const sheet = styleTag.sheet;
if (!sheet) return;
Array.from(sheet.cssRules).forEach(rule => {
if (rule.type === CSSRule.FONT_FACE_RULE) {
const fontUrlMatch = rule.cssText.match(/url\\(([^)]+)\\)/);
if (fontUrlMatch) {
const fontUrl = fontUrlMatch[1].replace(/['\"]+/g, '');
const url = new URL(fontUrl, location.href);
if (!this.isExcludedByDomain(url) && this.isExternalDomain(url)) {
this.matchedItems.add(`${url.hostname}-style`);
this.result = [...new Set(this.result.concat(url.origin))];
}
}
}
});
} catch (e) {
this.logger.logMessage(e);
}
});
}
-
Updated run Method: The run method now includes a call to processStyleTags to ensure external font URLs in
styletags are processed.
async run() {
const elements = document.querySelectorAll(
`${this.eligibleElements.join(', ')}[src], ${this.eligibleElements.join(', ')}[href], ${this.eligibleElements.join(', ')}[rel], ${this.eligibleElements.join(', ')}[type]`
);
elements.forEach(el => this.processElement(el));
// Process style tags for external font URLs
this.processStyleTags();
this.logger.logMessage({matchedItems: this.getMatchedItems(), excludedItems: Array.from(this.excludedItems)});
}
How It Works
-
Extracting URLs: The
processStyleTagsmethod uses theCSSRule.FONT_FACE_RULEtype to identify@font-facerules instyletags. It then extracts the url() value using a regular expression.Example:
@font-face {
font-family: 'CustomFont';
src: url('https://example.com/fonts/customfont.woff2') format('woff2');
}
The URL https://example.com/fonts/customfont.woff2 is extracted and processed.
-
Avoiding Duplicates:
- The matchedItems set ensures that duplicate entries are not added.
- The result array is updated with unique values using new Set([...]).
-
Preconnection: The extracted URLs are added to the list of domains to be preconnected, ensuring faster loading of external fonts.
Example Output
For a style tag containing:
<style>
@font-face {
font-family: 'CustomFont';
src: url('https://example.com/fonts/customfont.woff2') format('woff2');
}
</style>
The following domain will be preconnected:
https://example.com
Efforts
S
@Miraeld Won't this be much easier handling on PHP from the preload fonts part. We already have preloaded fonts in DB, we can get this value, filter out the ones with external domain and add them to be preconnected. To do this efficiently, we may need to create a new filter after this conditional and hook to it in the frontend preload subscriber with callback that get's the preloaded fonts from DB for that page. Then filter out the urls that are not external and extract only their host and return this to the filter.
This I believe will save us the stress of re-inventing the wheel on the preconnect external domain beacon and also with the current proposal, this looks like it will preconnect to all fonts in the style whether eligible to be preloaded or not. WDYT?
Umm, @jeawhanlee, You can't do it on the PHP side, as you don't get everything saved in the DB as it should.
Another note: Some external domains are not preconnected, regardless of their inclusion via a
styletag. This can be noticed with the following page template: https://rocketlabsqa.ovh/preloadfonts_multiple_external_fonts/ (preloadfonts_multiple_external_fonts.php) We expect to see 5 external domains preconnected:
- https://fonts.googleapis.com
- https://fonts.bunny.net
- https://api.fontshare.com
- https://unpkg.com
- https://cdn.jsdelivr.net
However, in the
preconnect_external_domainstable in the database, only the following 3 are actually preconnected:![]()
From this comment you can see we expect 5 fonts to be saved, but we are only saving 3, it means we aren't detecting them properly on the JS side.
@Miraeld I mean to do this on the preload fonts side Did you read this? 😀
So @jeawhanlee if I understand well your feedback, here is the approach:
- Add a filter in PreconnectExternalDomains Frontend Controller after the conditional check and before the return statement
- Hook into this filter from the preload fonts subscriber to get preloaded fonts from the database for that page
- Filter out external URLs and extract their hostnames to add them for preconnection
Step 1: Add Filter in PreconnectExternalDomains Frontend Controller:
<?php
// ...existing code...
public function add_custom_data( array $data ): array {
if ( ! $this->context->is_allowed() ) {
$data['status']['preconnect_external_domain'] = false;
return $data;
}
$elements = [ 'link', 'script', 'iframe' ];
/**
* Filters the array of eligible elements to be processed by the preconnect external domain beacon.
*
* @since 3.19
*
* @param string[] $elements Array of element selectors to be processed.
*/
$elements = wpm_apply_filters_typed( 'string[]', 'rocket_preconnect_external_domain_elements', $elements );
$exclusions = [];
/**
* Filters the array of elements to be excluded from being processed by the preconnect external domain beacon.
*
* @since 3.19
*
* @param string[] $exclusions Array of patterns used to identify elements that should be excluded.
*/
$exclusions = wpm_apply_filters_typed( 'string[]', 'rocket_preconnect_external_domain_exclusions', $exclusions );
$data['preconnect_external_domain_elements'] = $elements;
$data['preconnect_external_domain_exclusions'] = $exclusions;
$data['status']['preconnect_external_domain'] = $this->context->is_allowed();
/**
* Filters the data array for preconnect external domains to allow adding additional domains.
*
* This filter allows other features like preload fonts to add external domains
* that should be preconnected.
*
* @since 3.19.1
*
* @param array $data Array of data passed to beacon.
*/
$data = wpm_apply_filters_typed( 'array', 'rocket_preconnect_external_domains_data', $data );
return $data;
}
// ...existing code...
Step 2: Update PreloadFonts Frontend Subscriber to Hook into the Filter
<?php
// ...existing code...
/**
* Returns an array of events that this subscriber wants to listen to.
*
* @since 3.19
*
* @return array
*/
public static function get_subscribed_events(): array {
return [
'rocket_head_items' => [ 'add_preload_fonts_in_head', 30 ],
'rocket_enable_rucss_fonts_preload' => 'disable_rucss_preload_fonts',
'rocket_preload_fonts_excluded_fonts' => 'get_exclusions',
'rocket_buffer' => 'maybe_remove_existing_preloaded_fonts',
'rocket_preconnect_external_domains_data' => 'add_external_fonts_to_preconnect',
];
}
// ...existing code...
/**
* Add external font domains from preloaded fonts to preconnect domains.
*
* This method retrieves preloaded fonts for the current page from the database,
* filters out external domains, and adds them to the preconnect domains list.
*
* @since 3.19.1
*
* @param array $data Array of data passed to beacon.
* @return array Modified data array with additional preconnect domains.
*/
public function add_external_fonts_to_preconnect( array $data ): array {
if ( ! $this->preload_fonts->get_context()->is_allowed() ) {
return $data;
}
// Get preloaded fonts for current page
$row = $this->get_current_url_row();
if ( empty( $row ) || empty( $row->fonts ) ) {
return $data;
}
$fonts = json_decode( $row->fonts, true );
if ( empty( $fonts ) ) {
return $data;
}
$external_domains = [];
// Extract external domains from font URLs
foreach ( $fonts as $font_url ) {
$parsed_url = wp_parse_url( $font_url );
if ( ! $parsed_url || empty( $parsed_url['host'] ) ) {
continue;
}
$domain_origin = $parsed_url['scheme'] . '://' . $parsed_url['host'];
$site_domain = wp_parse_url( home_url(), PHP_URL_HOST );
// Only add if it's an external domain (not same as site domain)
if ( $parsed_url['host'] !== $site_domain ) {
$external_domains[] = $domain_origin;
}
}
// Add unique external domains to preconnect domains
if ( ! empty( $external_domains ) ) {
$external_domains = array_unique( $external_domains );
if ( ! isset( $data['preconnect_external_domains'] ) ) {
$data['preconnect_external_domains'] = [];
}
$data['preconnect_external_domains'] = array_merge(
$data['preconnect_external_domains'],
$external_domains
);
$data['preconnect_external_domains'] = array_unique( $data['preconnect_external_domains'] );
}
return $data;
}
/**
* Get current visited page row in DB.
*
* @since 3.19.1
*
* @return false|\WP_Rocket\Engine\Media\PreloadFonts\Database\Rows\PreloadFonts
*/
private function get_current_url_row() {
global $wp;
$url = untrailingslashit( home_url( add_query_arg( [], $wp->request ) ) );
$is_mobile = $this->preload_fonts->get_context()->is_mobile_allowed();
return $this->preload_fonts->get_query()->get_row( $url, $is_mobile );
}
// ...existing code...
Step 3: Update PreloadFonts Frontend Controller to Expose Required Methods
<?php
// ...existing code...
/**
* Get the context instance.
*
* @since 3.19.1
*
* @return Context
*/
public function get_context(): Context {
return $this->context;
}
/**
* Get the query instance.
*
* @since 3.19.1
*
* @return PFQuery
*/
public function get_query(): PFQuery {
return $this->query;
}
// ...existing code...
Summary
- Handle this on the PHP side using preload fonts data
- Create a filter after the conditional in PreconnectExternalDomains Frontend Controller
- Hook to it from the preload fonts frontend subscriber
- Get preloaded fonts from DB for the current page
- Filter out external URLs and extract only their hostnames
- Return these domains to be added to preconnect
Yes, you are quite right about the analogy but we don't need to pass it as a custom data. To explain in details:
- We will create a new filter right after this line. This filter will return an array of domains from the previous line
- Then we will create a new method in
WP_Rocket\Engine\Media\PreloadFonts\Frontend\Controller, which will accept the array of domains returned in the previous step something like what you've created above, we would combine the 2 arrays, the one gotten from the filter and the one we filter out from the preload and then return return the array. - And lastly in
WP_Rocket\Engine\Media\PreloadFonts\Frontend\Subscriber, we will hook the new filter we created with this callback we created in the controller.
Okay so, @jeawhanlee, final modification:
Scope a solution
-
Add a filter in
PreconnectExternalDomains Frontend Controllerright after line 98 (after the conditional check and before the return statement at line 100) - Create a new method in PreloadFonts Frontend Controller that takes the existing domains array and adds external font domains from the database
- Hook into this filter from the preload fonts subscriber with the new controller method
Step 1: Add Filter in PreconnectExternalDomains Frontend Controller
<?php
// ...existing code...
public function add_custom_data( array $data ): array {
if ( ! $this->context->is_allowed() ) {
$data['status']['preconnect_external_domain'] = false;
return $data;
}
$elements = [
'link',
'script',
'iframe',
];
/**
* Filters the array of eligible elements to be processed by the preconnect external domain beacon.
*
* @since 3.19
*
* @param array $elements Array of elements
*/
$elements = wpm_apply_filters_typed( 'array', 'rocket_preconnect_external_domain_elements', $elements );
$elements = array_filter( $elements, 'is_string' );
$data['preconnect_external_domain_elements'] = $elements;
/**
* Filters the array of elements to be excluded from being processed by the preconnect external domain beacon.
*
* @since 3.19
*
* @param string[] $exclusions Array of patterns used to identify elements that should be excluded.
*/
$exclusions = wpm_apply_filters_typed( 'string[]', 'preconnect_external_domain_exclusions', [] );
$data['preconnect_external_domain_exclusions'] = $exclusions;
$data['status']['preconnect_external_domain'] = $this->context->is_allowed();
/**
* Filters the external domains that need to be preconnected.
*
* This filter allows other features like preload fonts to add external domains
* that should be preconnected.
*
* @since 3.19.1
*
* @param array $domains Array of domains to be preconnected.
*/
$external_domains = wpm_apply_filters_typed( 'array', 'rocket_preconnect_external_domains', [] );
if ( ! empty( $external_domains ) ) {
$data['preconnect_external_domains'] = $external_domains;
}
return $data;
}
// ...existing code...
Step 2: Create New Method in PreloadFonts Frontend Controller:
<?php
// ...existing code...
/**
* Get the context instance.
*
* @since 3.19.1
*
* @return Context
*/
public function get_context(): Context {
return $this->context;
}
/**
* Get the query instance.
*
* @since 3.19.1
*
* @return PFQuery
*/
public function get_query(): PFQuery {
return $this->query;
}
/**
* Add external font domains from preloaded fonts to preconnect domains.
*
* This method retrieves preloaded fonts for the current page from the database,
* filters out external domains, and adds them to the preconnect domains list.
*
* @since 3.19.1
*
* @param array $domains Array of existing domains to be preconnected.
* @return array Modified domains array with additional external font domains.
*/
public function add_external_fonts_to_preconnect( array $domains ): array {
if ( ! $this->context->is_allowed() ) {
return $domains;
}
// Get preloaded fonts for current page
$row = $this->get_current_url_row();
if ( empty( $row ) || empty( $row->fonts ) ) {
return $domains;
}
$fonts = json_decode( $row->fonts, true );
if ( empty( $fonts ) ) {
return $domains;
}
$external_domains = [];
// Extract external domains from font URLs
foreach ( $fonts as $font_url )
Step 3: Update PreloadFonts Frontend Subscriber to Hook into the Filter
<?php
// ...existing code...
/**
* Returns an array of events that this subscriber wants to listen to.
*
* @since 3.19
*
* @return array
*/
public static function get_subscribed_events(): array {
return [
'rocket_head_items' => [ 'add_preload_fonts_in_head', 30 ],
'rocket_enable_rucss_fonts_preload' => 'disable_rucss_preload_fonts',
'rocket_preload_fonts_excluded_fonts' => 'get_exclusions',
'rocket_buffer' => 'maybe_remove_existing_preloaded_fonts',
'rocket_preconnect_external_domains' => 'add_external_fonts_to_preconnect',
];
}
// ...existing code...
/**
* Add external font domains from preloaded fonts to preconnect domains.
*
* @since 3.19.1
*
* @param array $domains Array of existing domains to be preconnected.
* @return array Modified domains array with additional external font domains.
*/
public function add_external_fonts_to_preconnect( array $domains ): array {
return $this->preload_fonts->add_external_fonts_to_preconnect( $domains );
}
// ...existing code...
Summary
- Handle this on the PHP side using preload fonts data from the database
- Create a filter right after line 98 in PreconnectExternalDomains Frontend Controller
- Hook to it from the preload fonts frontend subscriber with a callback that gets preloaded fonts from DB for the current page
- Filter out external URLs and extract only their hostnames
- Return these domains to be added to preconnect
- This approach is much cleaner and avoids modifying the beacon JavaScript, instead leveraging the existing preload fonts database data to add external domains for preconnection.
Efforts
S
WDYT @jeawhanlee ?
Scope a solution ✅
In WP_Rocket\Engine\Media\PreconnectExternalDomains\Frontend\Controller
- Create a new filter right after this line.
wpm_apply_filters_typed( 'string[]', 'rocket_add_preconnect_external_domain', $domains ); - This filter will return an array of domains gotten here
In WP_Rocket\Engine\Media\PreloadFonts\Frontend\Controller
- Create a new method
extract_external_domains_of_fontswhich will accept an array as argument. - This argument value is gotten from the return of the new filter we created earlier.
- Add logic in this new method that will get the preload fonts data from DB for the current page
- Filter out all preloaded fonts with external domain and extract the domain
- Then return the extracted domain.
Finally In WP_Rocket\Engine\Media\PreloadFonts\Frontend\Subscriber
- Hook the new filter created (
rocket_add_preconnect_external_domain) with the new method created in the controller (extract_external_domains_of_fonts) as the callback.
Add or update tests.
Estimate the effort ✅
[S]
Looks good to me.