loading...
Cover image for Critical CSS with Rails and Webpacker - SprocketsLess Part 1

Critical CSS with Rails and Webpacker - SprocketsLess Part 1

adrienpoly profile image Adrien Poly ใƒป4 min read

This is the first of hopefully a series of articles on new advanced usage available when moving all assets to Webpacker. In the first part, we will look into optimizing your CSS size.

We all want fast & reliable web pages. When doing a Page Speed Audit what often comes up as a recommendation is critical CSS. Critical CSS and especially above the fold critical CSS is the ability to inline (in the HTML) the minimal CSS required to render the top of your page (above the fold). I have for some time looked into an easy solution to achieve this in a Rails app but I was never really successful at it.

One of the great thing with Webpack (ie Webpacker in Rails) is all of the echo systems around it. While the JS side in Rails is largely documented they are also lots of tools available for CSS & images.

A few months ago I discovered a great video from GoRails for using PurgeCss in a Rails application.

Understanding PurgeCSS

Alt Text

The global concept of PurgeCSS is that on one side you feed PurgeCSS with all of your files where you would have some CSS class used (usually .html, html.erb, .js). PurgesCSS create a list of all token that could be CSS selectors.

On the other side Webpacker create a CSS bundle using the mini-css-extract-plugin. PurgeCSS extract a list of tokens

The result is the intersection of those two lists of tokens.

Multiple Pack with Multiple Rules

With Webpacker it is easy to have multiple packs. You just need to create a new some-pack.js file in app/javascript/packs directory.

The global idea of what we are going to do is:

  • Define a second pack critical.js with only some CSS import in it.
  • Split our PurgesCss process in PostCss to apply much stricter rules for critical.css.
  • Inline our Critical CSS in the HTML as Dev.to is doing by the way.
  • Lazy load our main application.css.

Our critical.js entrypoint

Given an application.js that would look something like this:

// app/javascript/packs/application.js

require("@rails/ujs").start();
require("local-time").start();
require("turbolinks").start();

window.Rails = Rails;

// import CSS
import "stylesheets/application.scss";

// import Stimulus controllers
import "controllers/index";

// import vendor JS
import "bootstrap";

Our main entry point, import our main application.scss that usually look something like that:

// app/javascript/stylesheets/application.scss

// Fonts
@import "config/fonts";

// Graphical variables
@import "config/colors";

// Vendor
@import "~bootstrap/scss/functions";
@import "config/bootstrap_variables";
@import "~bootstrap/scss/bootstrap";

$fa-font-path: "~@fortawesome/fontawesome-free/webfonts";
@import "~@fortawesome/fontawesome-free/scss/fontawesome";
@import "~@fortawesome/fontawesome-free/scss/brands";

// Components
@import "components/index";

// layouts
@import "layouts/sticky-footer";

We can create a very basic critical.js, the only thing it will do is to import a new critical.scss stylesheet.

// app/javascript/packs/critical.js
import "stylesheets/critical.scss";

In our critical.scss file we can start to be a little more selective as what we put inside to help PurgeCSS make a better job. (it does make a small difference)

// colors
@import "config/colors";

// vendor
@import "~bootstrap/scss/functions";
@import "config/bootstrap_variables";
@import "config/bootstrap_critical"; // pick only the Bootstrap module you need

// Components
@import "components/banner"; //just pick the components you need for the homepage

PostCSS / PurgeCSS configuration

Then this is the important part. We need to tell PurgeCSS to apply different rules per files. Luckily we have a context full of information in PostCSS.

So we can pass our information context to the environment:

module.exports = ctx => environment(ctx);

Add a context variable to our envrionment

const environment = ctx => ({
  plugins: [
    require("postcss-import"),
    require("postcss-flexbugs-fixes"),
    require("postcss-preset-env")({
      autoprefixer: {
        flexbox: "no-2009"
      },
      stage: 3
    }),
    purgeCss(ctx)
  ]
});

Call our PurgeCss plugin with this context

const purgeCss = ({ file }) => {
  return require("@fullhuman/postcss-purgecss")({
    content: htmlFilePatterns(file.basename),
    defaultExtractor: content => content.match(/[A-Za-z0-9-_:/]+/g) || []
  });
};

And now that we have the filename in PurgeCss, we can specify different rules for each file. For my critical CSS, I specify only pages related to the home page and the usual set of patterns for all other files.

const htmlFilePatterns = filename => {
  switch (filename) {
    case "critical.scss":
      return [
        "./app/views/pages/index.html.erb",
        "./app/views/shared/_navbar.html.erb",
        "./app/views/layouts/application.html.erb"
      ];
    default:
      return [
        "./app/**/*.html.erb",
        "./config/initializers/simple_form_bootstrap.rb",
        "./app/helpers/**/*.rb",
        "./app/javascript/**/*.js"
      ];
  }
};

So in full, it looks like that

// postcss.config.js

const environment = ctx => ({
  plugins: [
    require("postcss-import"),
    require("postcss-flexbugs-fixes"),
    require("postcss-preset-env")({
      autoprefixer: {
        flexbox: "no-2009"
      },
      stage: 3
    }),
    purgeCss(ctx)
  ]
});

const purgeCss = ({ file }) => {
  return require("@fullhuman/postcss-purgecss")({
    content: htmlFilePatterns(file.basename),
    defaultExtractor: content => content.match(/[A-Za-z0-9-_:/]+/g) || [],
  });
};

const htmlFilePatterns = filename => {
  switch (filename) {
    case "critical.scss":
      return [
        "./app/views/pages/index.html.erb",
        "./app/views/shared/_navbar.html.erb",
        "./app/views/layouts/application.html.erb"
      ];
    default:
      return [
        "./app/**/*.html.erb",
        "./config/initializers/simple_form_bootstrap.rb",
        "./app/helpers/**/*.rb",
        "./app/javascript/**/*.js"
      ];
  }
};

module.exports = ctx => environment(ctx);

Results

In a small test I did I had those results

  • Initial bundle size of 32kb
  • With purge CSS this dropped to 9kb
  • My critical.css only 3kb!

BAMMMMM ๐ŸŽ‰๐ŸŽ‰๐ŸŽ‰๐ŸŽ‰๐ŸŽ‰๐ŸŽ‰๐ŸŽ‰๐ŸŽ‰๐ŸŽ‰๐ŸŽ‰๐ŸŽ‰๐ŸŽ‰๐ŸŽ‰

Inline CSS from Webpacker

I did scratch my head a bit to inline my CSS file in the HTML. Thanks to Stackoverflow I could get some help here

<% if current_page?(root_path) %>
  <!-- Inline the critical CSS -->
  <style>
    <%= File.read(File.join(Rails.root, 'public', Webpacker.manifest.lookup('critical.css'))).html_safe %>
  </style>

  <!-- Lazy load the rest with loadCSS -->
  <link rel="preload" href="<%= Webpacker.manifest.lookup('application.css') %>" as="style" onload="this.rel='stylesheet'">
  <noscript><link rel="stylesheet" href="<%= Webpacker.manifest.lookup('application.css') %>"></noscript>
<% else %>
  <%= stylesheet_pack_tag 'application', 'data-turbolinks-track': 'reload' %>
<% end %>

Et voila!!!!

Demo: https://sprockets-less-rails6.herokuapp.com
Source code: https://github.com/adrienpoly/sprockets-less-rails6

Posted on by:

adrienpoly profile

Adrien Poly

@adrienpoly

Freelance Fullstack developer Rails React Javascript

Discussion

pic
Editor guide
 

Hi Andrien,

Thanks for this.

How to whitelist specific folder in Purgecss config?

Say I have

app/javascript/stylesheets/marketing
app/javascript/stylesheets/others

Now I want to whitelist all SCSS/CSS files from below folder

app/javascript/stylesheets/others 

Thanks!

 

Hi Adrien

Thanks for this. I've added the critical.js and critical.scss which then appear in my manifest.json. I've set extract_css: true in wepbacker.yml.

However i never get any files written to my public/packs directory so the inline read call can't open the extracted file name.

I assume i need to run "rake webpacker:compile" on every change to write to the packs directory? Did you ever find a way around this?

Thanks again

 

Hi Chris

I did run into issues in development when using overmind to start my Procfil.dev.

Starting my server with a basic rails s did solve this issue.
In production I never had any issue (I deploy to Heroku).

I never do a rake webpacker:compile here on my side

 

Thanks Adrien

I'm using foreman so perhaps thats something to do with it

currently my public/packs folder is empty

well in development it is normal to have public/packs empty.
Assets are lives served by webpack-dev-server

 

Hey nice article do you know by any chance how I could import .less files into the critical.scss

 

Hello
I don't really use less. but anyhow if you are able to get less files into webpacker (this could help github.com/rails/webpacker/issues/...). Then what I describred will work exactly the same. PostCSS gets the output of Webpacker so at this point it is a CSS file (not anymore Less, Sass, Scss etc)

 

Thank you, I try it out