Migrating from (Rails) Webpack(er) to Vite

11th July 2022 – 2282 words

After introducing the Rails wrapper webpacker in 2018/2019, it has been a great addition and helped to propel our Javascript frontend development. But since a couple of months, there are other alternatives to consider. Rails in general likes to move to more simpler Import-Maps by default, or using esbuild directly. But in our case, having a separate build server that enables productivity enhancing features such as Hot Module Reload, or playing around with SSR, is a much more useful alternative for us. That’s why, after evaluating a couple of other options, we are going to Vite.

Why move?

Compared to Webpack, it’s much faster, as it does less and relies more on the Module loading (and thus, delayed Runtime errors) in the browser. Especially the initial start took like 1-2 minute with Webpack and after a while the server took several (~1-4) gigabyte of RAM (multiplied by Number of Projects Times Number of Developers can grow to unhealthy limits) and needed regular restarts.

Configuration fatigue: Starting with Webpack 2, and going up to 4, migrating between the Rails wrapper Webpacker 3 - 5, it found it is a major PITA to manage all the details, such as Babel-config, loaders, plugins and many other configuration snippets. Vite is a more opinionated approach and works OOTB without many plugins - We only use Vue plugin, the rest, like Sass, Typescript, even Pug, just work if you have the related packages installed). For our migration, the vite.config.js is much smaller than the whole of Webpack config:

git diff --shortstat origin/master -- package.json vite.config.js config/webpack config/webpacker.yml
 11 files changed, 91 insertions(+), 255 deletions(-)

Comparing only the dep-size of the yarn.lock is even more shocking:

git diff --shortstat origin/master -- yarn.lock
 1 file changed, 1358 insertions(+), 7642 deletions(-)

Vite (as does Webpack) supports configuration options for using a public development proxy that is handled by a Reverse Proxy. In our case, we mostly work on a central server, so everybody got a couple of ports assigned to map to frontend https. Vite can be configured via server.hmr.clientPort + server.hmr.host and still support HMR live updates via websockets, which was not the case for some other bundlers.

The biggest drawback against Vite would be, that we could not use wkhtmltopdf with styled documents anymore. For this particular project, that is not necessary, but in the future other projects, that generate styled PDFs must be migrated to puppeteer or similar (See the discussion on Github).

Migration

At the beginning, I’ve tried 2 automatic migration tool, that did almost nothing. I throw them away and started with a fresh vite-config generated by vite-ruby. Then I replaced all javascript_pack_tag with vite_javascript_tag and removed the stylesheet_pack_tag (Vite only needs the entrypoint javascript, and then it will include all dependencies and generated stylesheets). Then, I removed all of webpack, webpacker and babel: babelrc, package.json, Gemfile, config/webpack*, bin/webpack*. You may keep postcss.yml.

The various problems that occurred afterwards were solved as followed below:

Fix import aliases

To reference our own Javascript modules, with used plain path without @/ or ~/ at the beginning.

Like:

import Stuff from 'admin/Stuff.vue'

while admin/Stuff.vue would be located in app/javascript/admin/Stuff.vue

With Vite, the simplest approach seemed to be to define an alias for each folder in app/javascript:

// vite.config.js

import path from 'path';

export default defineConfig({
  resolve: {
    alias: {
      admin: path.resolve(__dirname, './app/javascript/admin'),
      candi: path.resolve(__dirname, './app/javascript/candi'),
      channels: path.resolve(__dirname, './app/javascript/channels'),
      hrfilter_check: path.resolve(__dirname, './app/javascript/hrfilter_check'),
      "job-booking": path.resolve(__dirname, './app/javascript/job-booking'),
      next: path.resolve(__dirname, './app/javascript/next'),
      public: path.resolve(__dirname, './app/javascript/public'),
      registration: path.resolve(__dirname, './app/javascript/registration'),
      utils: path.resolve(__dirname, './app/javascript/utils'),
      images: path.resolve(__dirname, './app/assets/images'),
    },
  },
})

Or automatic:

import fs from 'fs'

const items = fs.readdirSync("app/javascript")
const directories = items.filter(item => fs.lstatSync(path.join("app/javascript", item)).isDirectory())

const aliasesFromJavascriptRoot = {}
directories.forEach(directory => {
  aliasesFromJavascriptRoot[directory] = path.resolve(__dirname, "app/javascript", directory)
})
export default defineConfig({
  resolve: {
    alias: {
      ...aliasesFromJavascriptRoot
    }
  }

In any case, you can also rename all the imports and prefix them with “@/”, but it’s harder to automate, and the method above worked great!

require.context - load a whole folder into Javascript

In some cases, we were using require.context to load a whole folder of javascript into a module.

In one case, it was simplest to just inline all the necessary imports under each other. That makes it also easier for Typescript to analyze imports.

If you want to keep the behavior of loading a whole folder, Vite has a similiar thing, called import.meta.globEager.

So, if you want to load your Stimulus controllers like:

// webpack:
const context = require.context("controllers", true, /_controller\.[jt]s$/)
application.load(definitionsFromContext(context))

…then, have a look at ElMassimo’s repo: ElMassimo/stimulus-vite-helpers.

Import needs file suffix for non-js/ts files

Vue-files need to be fully specified with suffix:

- import FooComponent from './FooComponent'
+ import FooComponent from './FooComponent.vue'

Same with lazy loading:

   components: {
-    "backlink-modal": () => import("../modals/BacklinkModal"),
+    "backlink-modal": () => import("../modals/BacklinkModal.vue"),

The same was true when loading scss files from Javascript.

Import GraphQL queries

In some cases, we are importing GraphQL-queries, like:

import FETCH_USER from '../queries/FetchUser.graphql'

There are a myriad of not-functioning plugins out there. In the end @rollup/plugin-graphql worked:

// yarn add @rollup/plugin-graphql
// vite.config.js
import graphql from '@rollup/plugin-graphql';
...
plugins: [
    ...,
    graphql()
  ]

require not found / not a function

Vite does not support commonjs import anymore! Most dependencies already support the ESM (EcmaScript Modules import/export syntax), some older not just needed a upgrade. So usually it was enough to change it like this:

# find all usages of require("foo")
rg -F 'require('
- const qs = require("qs")
+ import qs from 'qs'

Sentry error tracking also changed the import, but * as was needed to added here:

-const Sentry = require('@sentry/browser')
+import * as Sentry from '@sentry/browser';

We dropped moment.js and replaced it with dayjs or straight Intl. Dayjs is for the most part compatible with momentjs and in many cases a simple search & replace is all you need (After configuring the necessary plugins for Dayjs)

Scss Postcss import

When removing @rails/webpacker from the package.json, the deps that it brought, like sass, postcss, were missing now, so we added it manually:

yarn add postcss-import postcss-preset-env postcss sass

When importing deps, reference it without the ~ at the beginning:

- @import "~bootstrap-vue/src/index";
+ @import "bootstrap-vue/src/index";

We had one Stylesheet theme (Bootswatch) which did not work, as it always wanted to include a Google Font. We copied the Scss file from the module into our repo and deleted the font import.

Also, we are using an older SASS version, so we received a lot of Scss warnings every time. The best way seems to be to downgrade sass for now.

yarn add sass@1.32.0

vuelidate in test/production

One page failed ONLY in test mode when running the full suite, using development server worked always! It was a frustrating and time consuming thing to debug, First I thought vue-router is the culprit, but after forcing sourcemaps and using Chrome to get onto the test-server, I found that vuelidate was the thing responsible.

The error I received, was:

> TypeError: can't access property "components", e is undefined

Which, according to Stackoverflow was related to mixins, which are using with Vuelidate’s ValidationMixin. It seemed, that in production builds, Vite (Rollup) dropped the content, and just imported “undefined”.

So I tried to just change the import syntax slightly:

import { validationMixin } from 'vuelidate/src/index'
console.log(validationMixin) // works!

import { maxLength, required } from "vuelidate/lib/validators"

And also disabling optimization for this particular dependency:

// vite.config.js
defineConfig({
  optimizeDeps: {
    exclude: ['vuelidate']
  },

Then it worked! If this helped you, make sure to upvote my Stackoverflow answer ;)

Legacy / IE11

I checked our access logs, and it seems that the customers using that particular service are finally free from using Internet explorer 11.

But some customers are using older Firefox versions (ESR), so adding a legacy polyfill etc. was necessary.

Have a look at the final vite.config.js (Without Sentry):

import { defineConfig } from 'vite';
import legacy from "@vitejs/plugin-legacy"
import { createVuePlugin } from 'vite-plugin-vue2';
import RubyPlugin from "vite-plugin-ruby"
import FullReload from "vite-plugin-full-reload"
import path from 'path';
import fs from 'fs'
import graphql from '@rollup/plugin-graphql';

const isProd = process.env.NODE_ENV === "production" || process.env.RAILS_ENV === "production"

const items = fs.readdirSync("app/javascript")
const directories = items.filter(item => fs.lstatSync(path.join("app/javascript", item)).isDirectory())
const aliasesFromJavascriptRoot = {}
directories.forEach(directory => {
  aliasesFromJavascriptRoot[directory] = path.resolve(__dirname, "app/javascript", directory)
})

export default defineConfig({
  server: {
    hmr: {
      // in our case the environment variable VITE_WSS_HOST is set by our system
      // so VITE knows were it should direct the wss://HOST requests
      host: process.env.VITE_WSS_HOST,
      clientPort: 443,
      protocol: "wss",
    },
  },
  resolve: {
    alias: {
      ...aliasesFromJavascriptRoot,
      images: path.resolve(__dirname, './app/assets/images'),
    },
  },
  plugins: [
    RubyPlugin(),
    graphql(),
    createVuePlugin({ jsx: true }),
    FullReload(["config/routes.rb", "app/views/**/*", "app/controllers/**/*"], {
      delay: 200,
    }),
    isProd
    // if you want to support older browsers.
    // make sure, you have core-js 3 installed
      ? legacy({
          targets: ["defaults"],
          polyfills: ["es.promise.finally", "es/map", "es/set"],
          additionalLegacyPolyfills: ["regenerator-runtime/runtime"],
        })
      : undefined,
  ],
  optimizeDeps: {
    exclude: ['vuelidate', 'vuelidate/lib']
  },
  build: {
    // keep files between deployments - we are using capistrano-like deployment,
    // if you are using Docker or Heroku, then you can leave it
    emptyOutDir: !isProd,
    // force sourcemaps in test, too!
    sourcemap: true,
  }
})