DEV Community

Cover image for Writing a Vite plugin to add lazyloaded critical CSS fallbacks for users with JavaScript disabled or unavailable
Phil Wolstenholme
Phil Wolstenholme

Posted on • Updated on

Writing a Vite plugin to add lazyloaded critical CSS fallbacks for users with JavaScript disabled or unavailable

I'm using a Netlify build plugin to automatically split out and inline critical CSS for my personal website.

Inlining critical CSS (the CSS needed to display the immediately visible part of the page) makes a lot of sense on a static site where the 'above the fold' content is predictable, and it can help with getting a fast First Contentful Paint as the less CSS we have to download, the better (given its render-blocking nature).

With most critical CSS implementations we inline the critical CSS and lazyload the rest of the CSS using the media="print" and onload trick:

<link
  rel="stylesheet"
  href="/assets/styles.css"
  media="print"
  onload="this.media='all'"
/>
Enter fullscreen mode Exit fullscreen mode

This trick requires JavaScript, so we need to provide an alternative for users without working JavaScript:

<noscript>
  <link 
    rel="stylesheet"
    href="/assets/styles.css"
  />
</noscript>
Enter fullscreen mode Exit fullscreen mode

A while ago I'd noticed that the version (or perhaps the configuration) of the Critical package used by the netlify-plugin-inline-critical-css Netlify build plugin was missing a <noscript> fallback, so I manually added it to my Eleventy template, no big deal.

A few weeks ago, I switched to using Vite and I noticed that my <noscript> fallback had stopped working. Vite renames our CSS and JS to give them a hashed file name, then detects links to these assets and updates the path to use the hashed version, like this:

<link 
  rel="stylesheet"
  href="/assets/styles.833a7f93.css"
/>
Enter fullscreen mode Exit fullscreen mode

However, Vite didn't seem to be picking up on the link element within my <noscript> fallback.

To get around this problem I used the transformIndexHtml hook inside a small Vite plugin (inlined into my vite.config.js file) to add the fallback in myself:

const { defineConfig } = require('vite');

const addNoscriptCss = () => {
  return {
    name: 'add-noscript-css',
    transformIndexHtml(html, { chunk }) {
      const tags = [];

      Array.from(chunk.viteMetadata.importedCss, assetUrl => {
        tags.push({
          tag: 'noscript',
          children: [
            {
              tag: 'link',
              attrs: {
                rel: 'stylesheet',
                href: `/${assetUrl}`,
              },
            },
          ],
          injectTo: 'body',
        });
      });

      return {
        html,
        tags,
      };
    },
  };
};

module.exports = defineConfig({
  plugins: [
    addNoscriptCss(),
  ],
});
Enter fullscreen mode Exit fullscreen mode

That plugin will take the generated HTML file and add in a <noscript> fallback for every CSS file that Vite is aware of on the page.

I have a really simple setup (a single HTML page where every CSS <link> needs a fallback), so this snippet might not be bulletproof for other projects, but I hope it helps anyone who stumbles on it.

Update, October 2022

After updating Vite to 3.1.0 and working on my site (adding some new pages, tweaking some settings - the usual) this plugin stopped working. chunk.viteMetadata.importedCss was coming back empty each time.

I refactored the plugin to get the CSS paths a different way, and now it looks like this:

const addNoscriptCss = () => {
  return {
    name: 'add-noscript-css',
    enforce: 'post',
    transformIndexHtml(html, { bundle, chunk }) {
      const tags = [];
      const cssBundleKeys = Object.keys(bundle).filter(key => key.endsWith('.css'));
      cssBundleKeys.forEach(key => {
        const cssBundle = bundle[key];

        tags.push({
          tag: 'noscript',
          children: [
            {
              tag: 'link',
              attrs: {
                rel: 'stylesheet',
                href: `/${cssBundle.fileName}`,
              },
            },
          ],
          injectTo: 'body',
        });
      });

      return {
        html,
        tags,
      };
    },
  };
};
Enter fullscreen mode Exit fullscreen mode

Top comments (0)