Rails + webpack - webpacker

Adam Jahnke
Adam Jahnke

Rails is great, webpack is great, abstractions are not always great.

I'm going to start this from a pretty vanilla Rails 5 setup, I started with rails new webpacked --webpack.

Also, for this to work, you'll need webpack > 4.3.0.

Remove the webpacker things

Remove sass-rails, uglifier, coffee-rails, turbolinks, webpacker from Gemfile.

Remove config.webpacker.check_yarn_integrity settings in config/envronments.

Change javascript_pack_tag and stylesheet_pack_tag to javascript_include_tag and stylesheet_link_tag.

Then:

$ rm -r bin/webpack config/webpack* public/packs
$ bundle
$ yarn uninstall @rails/webpacker webpack-dev-server

Disable sprockets

# config/environments/development.rb
Rails.application.configure do
  …
  config.assets.compile = false
  …
end

Install dependencies

$ mkdir public/assets
$ touch public/assets/.keep
$ yarn add --dev webpack webpack-{cli,dev-server}

Check /public/assets into git, then Add /public/assets to your .gitignore. This is where we're going to build assets to for production.

Add build scripts

// package.json
{
  "name": "webpacked",
  "private": true,
  "scripts": {
    "server": "webpack-dev-server",
    "precompile": "rm -r public/assets/* && NODE_ENV=production webpack"
  },
  "dependencies": {},
  "devDependencies": {
    "webpack": "^4.5.0",
    "webpack-cli": "^2.0.13",
    "webpack-dev-server": "^3.1.1"
  }
}

Add a simple webpack config

// webpack.config.js
const DIR = require("path").resolve(__dirname);
const PROD = process.env.NODE_ENV === "production";

module.exports = {
  entry: {
    application: `${DIR}/app/assets/javascripts/application.js`,
  },
  mode: PROD ? "production" : "development",
  output: {
    chunkFilename: "[name].js",
    filename: "[name].js",
    path: `${DIR}public/assets/`,
  },
  resolve: {
    extensions: [".js", ".json", ".css"],
    modules: ["node_modules", `${DIR}/app/assets`],
  },
};

Now start the wepback dev server:

$ yarn server
ℹ 「wds」: Project is running at http://localhost:8080/
ℹ 「wds」: webpack output is served from /
ℹ 「wdm」: Hash: ee01dba8920628636a4e
ℹ 「wdm」: Compiled successfully.

Go to http://localhost:8080/application.js and you should be able to see the compiled webpack bundle.

Compile CSS

Add the necessary webpack loaders:

$ yarn add -D {file,extract,css,postcss}-loader cssnano

And then set up a rule for them:

// webpack.config.js
module.exports = {
  …
  module: {
    rules: [
      {
        test: /\.css/,
        use: [
          {
            loader: "file-loader",
            options: {
              name: "[name].css",
            },
          },
          { loader: "extract-loader" },
          { loader: "css-loader" },
          {
            loader: "postcss-loader",
            options: {
              ident: "postcss",
              plugins: loader => [
                require("cssnano")({
                  discardComments: { removeAll: true },
                }),
              ],
            },
          },
        ],
      },
    ],
  },
  …
};

Now, in your JavaScript files, you can require stylesheets like so:

// app/assets/javascripts/application.js
require("stylesheets/application.css")

console.log("Hello from application.js!")

Tell Rails where to find assets from webpack-dev-server

Now if you load up your site, you're probably seeing something like: Sprockets::Rails::Helper::AssetNotFound in Welcome#index

Add this helper to app/helpers/application_helper.rb:

# app/helpers/application_helper.rb
module ApplicationHelper
  def webpack_asset_url(name)
    "http://localhost:8080/#{name}"
  end
end

Then wrap the paths of any assets you're including in that helper, and don't forget the extensions:

<!-- app/views/layouts/application.html.erb -->
<!DOCTYPE html>
<html>
  <head>
    <title>Webpacked</title>
    <%= csrf_meta_tags %> <%= stylesheet_link_tag
    webpack_asset_url('application.css'), media: 'all' %> <%=
    javascript_include_tag webpack_asset_url('application.js') %>
  </head>

  <body>
    <%= yield %>
  </body>
</html>

Building for production

But wait, production! We need to tell Rails to serve the static assets in production:

# app/helpers/application_helper.rb
module ApplicationHelper
  def webpack_asset_url(name)
    if Rails.env.production?
      "/assets/#{name}"
    else
      "http://localhost:8080/#{name}"
    end
  end
end

You can test this by precompiling the assets and then restarting your Rails server in production mode:

$ yarn precompile && \
  RAILS_ENV=production \
  RAILS_SERVE_STATIC_FILES=true \
  SECRET_KEY_BASE=hi \
  bin/rails server

Fingerprinting assets

But wait, asset fingerprinting! Change the output filename for CSS and entrypoints:

// webpack.config.js
module.exports = {
  …
  module: {
    rules: [
      {
        test: /\.css/,
        use: [
          {
            loader: "file-loader",
            options: {
              name: PROD ? "[name]-[hash].css" : "[name].css",
            },
          },
          { loader: "extract-loader" },
          { loader: "css-loader" },
          {
            loader: "postcss-loader",
            options: {
              ident: "postcss",
              plugins: loader => [
                require("cssnano")({
                  discardComments: { removeAll: true },
                }),
              ],
            },
          },
        ],
      },
    ],
  },
  output: {
    chunkFilename: PROD ? "[name]-[contenthash].js" : "[name].js",
    filename: PROD ? "[name]-[contenthash].js" : "[name].js",
    hashDigestLength: 32,
    path: `${DIR}public/assets/`,
  },
  …
}

Now we need to tell Rails about these hashes somehow. First, we'll add the webpack-manifest-plugin (when building for production) to output a JSON map of the names of chunks to their hashed names:

// webpack.config.js
module.exports = {
  …
  plugins: [
    PROD && new (require("webpack-manifest-plugin"))()
  ].filter(Boolean),
  …
};

If you yarn precompile again, you should see it output a file like this, in addition to the other chunks:

// public/assets/manifest.json
{
  "application.css": "application-4cf6c42fe4e9a27ee5a758dcf37091f7.css",
  "application.js": "application-5689a742f9b3cb6758a6b35164a98ade.js"
}

Now let's update the Rails helper to get everything sorted out:

# app/helpers/application_helper.rb
module ApplicationHelper
  def webpack_asset_url(name)
    if Rails.env.production?
      "/assets/#{webpack_fingerprint_for(name)}"
    else
      "http://localhost:8080/#{name}"
    end
  end

  def webpack_fingerprint_for(name)
    @chunk_manifest ||= JSON.parse(File.read('public/assets/manifest.json'))
    if @chunk_manifest[name].nil?
      logger.error "chunk_manifest_missing name=#{name}"
      logger.error @chunk_manifest.inspect
    end
    @chunk_manifest[name]
  end
end

You might have to restart the Rails server again, but now you should see it loading the fingerprinted assets! Now wherever you run rake assets:precompile during your deployment process, replace it with yarn precompile.

Separate the webpack runtime

For better caching, it's smart to separate the webpack runtime from your application's JavaScript:

// webpack.config.js
module.exports = {
  …
  optimization: {
    runtimeChunk: "single",
  },
  …
};

Then just include that runtime chunk before any other JavaScript:

<!-- app/views/layouts/application.html.erb -->
<!DOCTYPE html>
<html>
  <head>
    <title>Webpacked</title>
    <%= csrf_meta_tags %> <%= stylesheet_link_tag
    webpack_asset_url('application.css'), media: 'all' %> <%=
    javascript_include_tag webpack_asset_url('runtime.js') %> <%=
    javascript_include_tag webpack_asset_url('application.js') %>
  </head>

  <body>
    <%= yield %>
  </body>
</html>