Asynchronous CSS Loading in Sage

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

I am having the same problem, but I’m not specifying the CSS route. As far as I understand that’s not needed as the plugin will generate a CSS file from the HTML specified in ‘src’.

What is your config.devUrl value? It works for me with http://localhost:8000, but not if I use my dev or prod URLs.

Asked for help in this thread too Critical CSS plugin

Edit: Forgot to say that I’m running Sage in a docker container.

@Alfalfa, I would prefer not to have to specify the css file(s) and instead have it generate from the src html. That does work, but only when I disable the functionality that adds the media=“print” and onload=“this.media=‘all’” in the ‘style_loader_tag’ filter

My config.devUrl config value is my custom local development url, not http://localhost

Your issue could also be related to the media=“print” and onload=“this.media=‘all’” issue I am running into as well.

I am using sage in a lando/docker environment.

Hey @RyanBaron thanks for your answer. It’s not a problem with the async, as I have tried the critical CSS with and without it, and it is still failing with the following error:

95% emitting TypeError: Cannot read property 'content-type' of undefined
at temp (/home/jenkins/workspace/some_path/node_modules/critical/lib/file-helper.js:106:37)
at requestAsync.then.response (/home/jenkins/workspace/some_path/node_modules/critical/lib/file-helper.js:204:27)
at process._tickCallback (internal/process/next_tick.js:68:7) error Command failed with exit code 1.

I have been using localhost:8000 as a devUrl the whole time. I have local environment, dev, staging and prod. Everything works fine so far with this devUrl, should I change it to be my dev URL? I don’t understand how the ‘src’ works in here. I’ve tried dev and staging URLs anyway, and it’s still failing in the same point.
If I have a look to the line shown in the error, it’s the following:

    const contentType = resp.headers['content-type']; 

About the async error you have, I had the same problem and solved it adding an URL parameter as follows:

In your webpack.config.optimize.js change the ‘src’ to be src: config.devUrl + '?no-async=true',, and then in your filters:

if (is_admin() || $_GET['no-async']) {
    return $html;
}

This way it won’t load CSS asynchronously and the critical CSS will be generated without problems. This works for me in localhost as I said, can’t make it work in dev/staging, even removing the async.

Wouldn’t be the same http://localhost or http://test.dev ?

Any ideas?

I don’t fully understand your dev setup, but the can't read content type of undefined combined with the fact that the line throwing that error appears to be dealing with an HTTP response object is what I might expect to see if something was trying to get a CSS file while you were running yarn start. When using HMR Sage 9 doesn’t actually generate any CSS files: it inserts the CSS directly into the DOM so that the styles update without a page reload. Not sure if that’s your issue, though.

That’s true, but the problem appears when running yarn build:production which creates all the assets for production. Doing that locally it creates a ‘dist’ folder with scripts and styles, and I can see my critical.css file being generated. The error appears trying to do the same in my dev server, which is deployed by a jenkins job creating a docker container.

Is your entire project (web server, build process, etc) in a single container, or do you have a couple of networked containers handling different concerns? I fully admit I’m not super familiar with docker, but my understanding is that containers have an internal network they communicate with each other on. Is it possible that your build process is unable to access your web server because the external url you use to access the server isn’t available on the internal network?

I had thought on that but no, it’s in a single container and it should be able to access to any external URL as in the docker container it’s installing different external resources. Any external URL should be valid to get the critical CSS, right? At the moment the config.devUrl is pointing to my dev server, but I could point directly to production site if I wanted too, right?
Just want to make sure that I understand correctly how the devUrl is being used as a ‘src’.

Another concern I had is if the docker container couldn’t use headless Chrome, so I installed it too, but still no luck :frowning:

I get the same thing locally if I don’t use env variables and try to run both HtmlCriticalWebpackPlugin and setting media="print". So it depends on your deployment setup, but if you use trellis to run yarn build:production locally and use env variables to async CSS only in prod then that combo should work.

While playing around with this I also found a bug with getting a critical CSS path. Currently locate_asset() runs twice in critical CSS snippet and inside get_file_contents() so here is the update:

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

    if (is_front_page()) {
        // $critical_CSS = locate_asset('styles/critical-home.css');
        $critical_CSS = 'styles/critical-home.css';
    } elseif (is_singular()) {
        // $critical_CSS = locate_asset('styles/critical-singular.css');
        $critical_CSS = 'styles/critical-singular.css';
    } else {
        // $critical_CSS = locate_asset('styles/critical-landing.css');
        $critical_CSS = 'styles/critical-landing.css';
    }

    // if (file_exists($critical_CSS)) {
    if (file_exists(locate_asset($critical_CSS))) {
        echo '<style id="critical-css">' . get_file_contents($critical_CSS) . '</style>';
    }
}, 1);

@ben I’d apreciate if you could update the guide.

1 Like

I a looking to add rel=“preload” in stylesheet and i follow your instruction but not affecting anything.

can you please guide me where i can add

/**
 * Async load CSS
 */
add_filter('style_loader_tag', function (string $html, string $handle): string {
    if ('development' === env('WP_ENV') || 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;
}, 999, 2);

Best to add it to filters.php.
Keep in mind that this is not going to run in development because we add:

    if ('development' === env('WP_ENV') || is_admin()) {
        return $html;
    }

So comment out this part for testing in development.