wp-rocket icon indicating copy to clipboard operation
wp-rocket copied to clipboard

Preloaded external fonts arenot preconnected on certain templates

Open Mai-Saad opened this issue 7 months ago • 4 comments

Describe the bug When preloading external fonts in style tag , we are not preconnecting them

To Reproduce Steps to reproduce the behavior:

  1. install and activate WPR 3.19
  2. enable preload fonts
  3. visit page with external font defined in style tag
  4. 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

Mai-Saad avatar May 21 '25 11:05 Mai-Saad

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

hanna-meda avatar May 28 '25 11:05 hanna-meda

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:

  1. https://fonts.googleapis.com
  2. https://fonts.bunny.net
  3. https://api.fontshare.com
  4. https://unpkg.com
  5. https://cdn.jsdelivr.net

However, in the preconnect_external_domains table in the database, only the following 3 are actually preconnected:

Image

hanna-meda avatar May 30 '25 08:05 hanna-meda

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/

Mai-Saad avatar Jun 03 '25 08:06 Mai-Saad

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

  1. Added processStyleTags Method: This method parses all style tags in the document, extracts URLs from @font-face rules, 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);
        }
    });
}
  1. Updated run Method: The run method now includes a call to processStyleTags to ensure external font URLs in style tags 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

  1. Extracting URLs: The processStyleTags method uses the CSSRule.FONT_FACE_RULE type to identify @font-face rules in style tags. 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.

  1. Avoiding Duplicates:

    • The matchedItems set ensures that duplicate entries are not added.
    • The result array is updated with unique values using new Set([...]).
  2. 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 avatar Jun 10 '25 03:06 Miraeld

@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?

jeawhanlee avatar Jul 01 '25 10:07 jeawhanlee

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 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:

  1. https://fonts.googleapis.com
  2. https://fonts.bunny.net
  3. https://api.fontshare.com
  4. https://unpkg.com
  5. https://cdn.jsdelivr.net

However, in the preconnect_external_domains table in the database, only the following 3 are actually preconnected:

Image

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 avatar Jul 01 '25 10:07 Miraeld

@Miraeld I mean to do this on the preload fonts side Did you read this? 😀

jeawhanlee avatar Jul 01 '25 11:07 jeawhanlee

So @jeawhanlee if I understand well your feedback, here is the approach:

  1. Add a filter in PreconnectExternalDomains Frontend Controller after the conditional check and before the return statement
  2. Hook into this filter from the preload fonts subscriber to get preloaded fonts from the database for that page
  3. 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

Miraeld avatar Jul 02 '25 00:07 Miraeld

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.

jeawhanlee avatar Jul 02 '25 11:07 jeawhanlee

Okay so, @jeawhanlee, final modification:

Scope a solution

  1. Add a filter in PreconnectExternalDomains Frontend Controller right after line 98 (after the conditional check and before the return statement at line 100)
  2. Create a new method in PreloadFonts Frontend Controller that takes the existing domains array and adds external font domains from the database
  3. 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 ?

Miraeld avatar Jul 07 '25 10:07 Miraeld

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_fonts which 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]

jeawhanlee avatar Jul 07 '25 16:07 jeawhanlee

Looks good to me.

Miraeld avatar Jul 07 '25 16:07 Miraeld