Writing a Vite plugin to add lazyloaded critical CSS fallbacks for users with JavaScript disabled or unavailable
Tags: vite, performance, css, javascript
Published: 2022-04-28 21:20:02

I'm using a [Netlify build plugin to automatically split out and inline critical CSS](https://github.com/Tom-Bonnike/netlify-plugin-inline-critical-css) for my [personal website](https://wolstenhol.me).

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](https://www.filamentgroup.com/lab/load-css-simpler/):

```html
<link
  rel="stylesheet"
  href="/assets/styles.css"
  media="print"
  onload="this.media='all'"
/>
```

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

```html
<noscript>
  <link 
    rel="stylesheet"
    href="/assets/styles.css"
  />
</noscript>
```

A while ago I'd noticed that the version (or perhaps the configuration) of the [Critical](https://github.com/addyosmani/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:

```html
<link 
  rel="stylesheet"
  href="/assets/styles.833a7f93.css"
/>
```

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](transformIndexHtml) inside a small Vite plugin (inlined into my `vite.config.js` file) to add the fallback in myself:


```javascript
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(),
  ],
});
```

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:


```javascript
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,
      };
    },
  };
};
```