Asynchronous CSS Loading in Sage

Originally published at: https://roots.io/guides/asynchronous-css-loading-in-sage/

Why load CSS asynchronously? All stylesheets are render-blocking by nature. Meaning that referencing CSS stylesheets with <link rel="stylesheet"> causes the browser to stop parsing the HTML and wait while a stylesheet loads. This is bad for performance and will trigger warnings on all page speed tests. Asynchronous CSS automation The new <link rel="preload"> attribute allows…

12 Likes

Great guide, just what I was looking for! thank you @jasonbaciulis.

However, I’m getting a FatalThrowableError: Call to a member function setAttribute() on null on the $tag->setAttribute('rel', 'preload') line for the Async load CSS filter.

$dom and $tag vars look like this on that line. Am I missing something? Sorry if it’s a dumb question, but I’m kinda new to this:

Thanks for your help and this awesome guide!

Try debugging a $handle and $html values. It looks like $handle is missing on some tag and maybe you can pinpoint which <link> tag is loaded at that time.

Found it! The clean_style_tag function on Soil removes ids from style tags, so getElementById($handle . '-css') returns null.

Solved it by disabling add_theme_support('soil-clean-up'), though I’m loosing all the other goodies from Soil’s clean up :stuck_out_tongue:

Good catch. So it’s something to be aware with other optimization plugins as well that might remove ids.

You could try calling async CSS earlier before Soil. Just need to find out what priority Soil is using and reduce the number from 999.

When i test this local it works fine but after deploying it to staging I got a http server 500 error. any idea? Still got the error “FatalThrowableError: Call to a member function setAttribute() on null” even if I disable soil-clean-up or decrease the priority of the filter to , for example , 10

hmmm getElementByID seems to bee ‘null’??

You mentioned it works in your local env, so try to pinpoint the differences between environments. Maybe there are additional plugins running on staging that could be removing ids?

Unfortunately, no result looks like the setups are similar. Only difference was W3 Total cache, which was disabled on local env . but disabling this on staging did not solve the issue.

What might be the modifications necessary – made to the filter and polyfill scripts – to only enable async CSS for specific stylesheets.

The reason I ask is because I have a specific project with javascript firing that will conflict with inline styling. I need certain stylesheets to load on page-load (without being inlined), and others to load in the background.

I’m having an issue when using your tutorial. Everything appears to work correctly except, I get the following error on the console.

Uncaught (in promise) TypeError: Failed to resolve module specifier ‘fg-loadcss/dist/cssrelpreload.min’

When following this the templates are outputting this.

	<style type="text/css">.recentcomments a{display:inline !important;padding:0 !important;margin:0 !important;}</style>
	<script>import("fg-loadcss/dist/cssrelpreload.min");</script></head>

I also noticed I get an error from yarn linting

yarn run v1.12.3

$ npm run -s lint:scripts && npm run -s lint:styles

/Users/robert/Sites/mysite.com/site/web/app/themes/mytheme/resources/assets/scripts/cssrelpreload.js

1:7 error Parsing error: Unexpected token (

:heavy_multiplication_x: 1 problem (1 error, 0 warnings)

error Command failed with exit code 1.

info Visit https://yarnpkg.com/en/docs/cli/run for documentation about this command.

Here is the content of the file cssrelpreload.js

import(“fg-loadcss/dist/cssrelpreload.min”);

Did I do something wrong?

I made a syntax error in the guide. Remove parentheses from import path. It should be:

import "fg-loadcss/dist/cssrelpreload.min";

Hi,

I ran yarn build:production and the critical-home.css file was generated correctly in the styles folder, but not added to assets.json

then this line $critical_CSS = asset_path ('styles/critical-home.css'); returns empty.

I think there is need of adjustment in the webpack, but I do not know exactly how to do it.

Would you help me?

Watchout, if critial files are not correctly generated and you open your website it seems that a lot of requests are made to db server. A lot (50 sleeping proccess in 3 seconds) :slight_smile: by this part

add_action('wp_head', function () {
if (is_front_page()) {
	$critical_CSS = \App\asset_path('styles/critical-home.css');
} elseif (is_singular()) {
	$critical_CSS = \App\asset_path('styles/critical-singular.css');
} else {
	$critical_CSS = \App\asset_path('styles/critical-archive.css');
}

if (fopen($critical_CSS, 'r')) {
	echo '<style>' . file_get_contents($critical_CSS) . '</style>';
}

I’ve rewritten it a little bit to use the local files instead of requesting remote files (also using WP_Filesystem), this may be of use.

This is the main code:

# app/filters.php
add_filter('style_loader_tag', function (string $html, string $handle): string {
    if ('development' === WP_ENV || is_admin() || ! is_front_page()) {
        return $html;
    }

    $dom = new \DOMDocument();
    $dom->loadHTML($html);
    $tag = $dom->getElementById("{$handle}-css");
    $tag->setAttribute('rel', 'preload');
    $tag->setAttribute('as', 'style');
    // phpcs:ignore WordPress.WP.EnqueuedResources.NonEnqueuedStylesheet
    $tag->setAttribute('onload', "this.onload=null;this.rel='stylesheet'");
    $tag->removeAttribute('type');
    $html = $dom->saveHTML($tag);
    return $html;
}, 999, 3);
add_action('wp_head', function (): void {
    /** @var \WP_Filesystem_Base */
    global $wp_filesystem;

    if ('development' === WP_ENV || ! is_front_page()) {
        return;
    }

    if (empty($wp_filesystem)) {
        require_once ABSPATH . '/wp-admin/includes/file.php';
    }

    \WP_Filesystem();

    $preload_script = get_theme_file_path() . '/resources/assets/scripts/cssrelpreload.js';

    if ($wp_filesystem->is_readable($preload_script)) {
        // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
        echo '<script id="critical-js">' . $wp_filesystem->get_contents($preload_script) . '</script>';
    }
}, 101);

add_action('wp_head', function (): void {
    if ('development' === WP_ENV || ! is_front_page()) {
        return;
    }

    /** @var \WP_Filesystem_Base */
    global $wp_filesystem;

    if (empty($wp_filesystem)) {
        require_once ABSPATH . '/wp-admin/includes/file.php';
    }

    \WP_Filesystem();

    $critical_CSS = asset_file_path('styles/critical-home.css');

    if ($wp_filesystem->is_readable($critical_CSS)) {
        // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
        echo '<style id="critical-css">' . $wp_filesystem->get_contents($critical_CSS) . '</style>';
    }
}, 1);

This code is for assets_file_path() which is used above.

# config/assets.php
    'path' => get_theme_file_path().'/dist',
# app/helpers.php
function asset_file_path(string $asset): string
{
    return config('assets.path') . '/' . sage('assets')->get($asset);
}

Read more of the above here: SVG sprites workflow

1 Like

Hi everyone,

Just been implementing this into my theme and noticed that in Firefox (currently on 69) I get a FOUC as the page loads. I did a few tests and disabled the main stylesheet only leaving the critical styles and the page looks fine when loaded but I still get a FOUC. When I disabled the function adding the preload attributes etc I no longer have a problem.

Anyone else experiencing this or knows what is going on?

Thanks

edit: I narrowed it down by just disabling the line “$tag->setAttribute(‘rel’, ‘preload’)” which immediately solved the problem but of course defeates the purpose.

edit2: I missed a few things. Since I am working on a child-theme I forgot to disable the parent css, when I did the page was mostly unstyled which meaning the FOUC is to be expected. I think this is something to do with my configuration of html-critical-webpack-plugin.

I’ll write an update to this guide over the weekend as I learned a cool trick to simplify Async CSS. Here is the full article about it: https://www.filamentgroup.com/lab/load-css-simpler/

In short, now you can drop the polyfill and instead of preloading now you can use this:

/**
 * Async load CSS.
 */
if (env('WP_ENV') === 'production') {
    add_filter('style_loader_tag', function ($html, $handle, $href) {
        if (is_admin()) {
            return $html;
        }

        $dom = new \DOMDocument();
        $dom->loadHTML($html);
        $tag = $dom->getElementById($handle . '-css');
        $tag->setAttribute('media', 'print');
        $tag->setAttribute('onload', "this.media='all");
        $tag->removeAttribute('type');
        $tag->removeAttribute('id');
        $html = $dom->saveHTML($tag);

        return $html;
    }, 9999, 3);
}

Though regarding FOUC, that is expected when loading CSS async but critical CSS should solve that. So first thing would be to check if critical CSS is generated at all. Later I’ll share a better way to inline critical CSS as well.

1 Like

Thanks for your help and awesome article jasonbaciulis.

Just in case, I noticed a missing single quote after the word “all”:

$tag->setAttribute('onload', "this.media='all");

it should be:

$tag->setAttribute('onload', "this.media='all'");

edit: just tested it and it does seems to work without the polyfill

1 Like

Did anyone manage to stop the clean_style_tag filter from soil? I for the life of me can’t get it removed using remove_filter.

If anyone else is interested I stopped Soil’s clean_style_tag using the below:

add_action( 'after_setup_theme', function () {
    remove_filter('style_loader_tag','Roots\Soil\CleanUp\clean_style_tag');
}, 100);
1 Like

@jasonbaciulis Thanks for the great guide. I was following the instructions here: https://roots.io/guides/asynchronous-css-loading-in-sage/ and I’m running into an issue with the style media=“print” and critical css working together. Each of them work independently, but when I use them together I get an error when running yarn build:production locally. Error: “No usable stylesheets found in html source. Try to specify the stylesheets manually.” I believe critical css is skipping the css files on the page because they have the media print attribute.

In webpack.config.optimize.js, I can specify the css value in the HtmlCriticalWebpackPlugin and get a successful build with media=“print” attributes enabled:

/resources/assets/build/webpack.config.optimize.js

new HtmlCriticalWebpackPlugin({
base: config.paths.dist,
src: config.devUrl,
dest: “styles/critical-home.css”,
ignore: ["@font-face", /url(/],
inline: false,
minify: true,
extract: false,
css: [ config.paths.dist + ‘/styles/main_18b3c83b.css’],
dimensions: [
{
width: 360,
height: 640,
},
{
width: 1920,
height: 1080,
},
],
penthouse: {
blockJSRequests: false,
},
}),

but since css the file names change, with different hash values added during production builds, this hard coded solution doesn’t work long term. One possible solution could be to access the .css file names from /dist/assets.json and populate the css array in HtmlCriticalWebpackPlugin