Webpacker, jQuery, and jQuery plugins

inopinatus


So this question comes up a lot when converting to Webpacker: how do I jQuery? And what about plugins?

Webpack is about modules, and when you’re using modules, top-level variables aren’t globals. I hesitate to say they have lexical scope, because that’s a term with strict meaning to programming language nerds, and it’s not quite true, and frankly a thorough discussion of scope in Javascript would be lengthy, dull, technical, and ultimately fail to illuminate the practical issues at hand.

But what Webpack does to your variables has the same outcome: there’s no $ or jQuery global anymore. Your plugins aren’t installed and your jQuery-based code is broken. What now?

The Heath Robinson device

You’d heard that Webpacker, which is Rails’s new wrapper and manager for Webpack and Webpack-generated assets, was the panacea of asset packaging now that Sprockets is on life support. And indeed the combination is powerful.

But Webpack is an infernal machine and notoriously tricky to configure, the error messages are cryptic, and Webpacker just makes debugging harder because the Stack Overflow answers all seem slightly irrelevant and it isn’t terribly well documented for beginners, so diagnosing a problem seems like hunting for a gas leak, in the dark, with a Zippo.

Good news: we can fix it all up, using the tools supplied, and here’s a series of recipes you can follow.

First, a special case.

A transitional app: externals

In an app that is using both Sprockets and Webpack for JS, you may be loading jQuery via Sprockets-managed assets or via a script tag. So don’t load it via Webpack as well, because you end up with two instances of jQuery. You’re wasting kilobytes, and only one of them has your plugins.

In such a case, we need to tell Webpack that jQuery is externally defined. In Webpacker parlance, your config/webpack/environment.js needs this:

environment.config.merge({
  externals: {
    jquery: 'jQuery'
  }
})

After which, when something packaged via Webpack tries to load jquery, it’ll be fed the jQuery global. If that solved your problem, you can stop reading now.

This case is most likely to occur if you’re upgrading an older application. It is otherwise a circumstance best avoided. If you need to know how to re-enable Sprockets for JS processing in a new Rails 6.0 app, I have a short gist for you.

If you want to load jQuery via Webpack, and I recommend that you do, then yarn add jquery now and read on.

The surgical method: import and imports-loader

Many jQuery plugins are npm packages with standard CommonJS and AMD compatibility, and in some cases their jQuery dependency is properly declared and imported, and Webpack will simply inject it when required. In which case all you need is a require('plugin-name') in application.js.

To use jQuery in your own code, code that’s loaded via Webpack, just prepend import jQuery from 'jquery' at the top of each file and you’re done. For third-party code, use the imports-loader, which does essentially the same at packaging time to code you don’t control.

For example, I have a homespun little plugin called “jquery.presence.coffee”, whose entire body was:

do ($ = jQuery) ->
  $.fn.presence = ->
    this if this.length != 0

and although my require('jquery.presence') statement worked, it threw up ReferenceError: jQuery is not defined in the console.

I have two possible fixes. Treating it like a third-party library, I can vary the import in application.js so that Webpack injects the jQuery variable:

require('imports-loader?jQuery=jquery!jquery.presence')

or I can change the body of the plugin to add one line at the top:

import jQuery from 'jquery'

do ($ = jQuery) ->
  $.fn.presence = ->
    this if this.length != 0

Both work fine and have the same effect and the same outcome. Choose one based on your circumstances. My recommendation? For your own code, add the import; that’s just good modular code now: import your dependencies by their module name, then export, well, whatever it is you’re providing. For third-party code, use the imports-loader method (and contact the author about making it a better citizen).

Don’t do both, they’ll conflict and you’ll see Identifier 'jQuery' has already been declared in the console.

DataTables and similar AMD modules

Some libraries have slightly quirky code from a time before Webpack. They may be trying to adapt themselves to legacy module loaders such as AMD, but in the Webpack environment (which is itself adaptive to either CommonJS or AMD) they become confused and screw up their exports.

DataTables is a common case of this and has the additional peculiarity of exporting a factory function that you’re supposed to call with the window and jQuery objects.

Fortunately, the imports-loader can inject a shim that will stop their AMD auto-detection. The resulting incantation is short, but not at all obvious. The relevant documentation for imports-loader is here, and for DataTables here, and then we put the two together to produce a snippet for your application.js:

// Load jQuery
import $ from 'jquery'

// Add DataTables jQuery plugin
require('imports-loader?define=>false!datatables.net')(window, $)
require('imports-loader?define=>false!datatables.net-select')(window, $)

// Load datatables styles
import 'datatables.net-dt/css/jquery.dataTables.css'
import 'datatables.net-select-dt/css/select.dataTables.css'

I’m still having problems

If you’ve still seeing errors like Uncaught ReferenceError: $ is not defined then you still have Javascript that is trying to find the jQuery global, which doesn’t exist anymore.

The most likely place for that is in <script> elements in your HTML. The next most likely is in event attributes in your HTML - onclick handlers and the like. Go hunting for those, and where you find them, you can either eliminate the dependency on jQuery by rewriting them in vanilla Javascript, or bring them into files that are loaded via Webpack instead.

If it’s in a third-party library then don’t fear, we can still solve the problem, but the solutions get uglier from here on.

The shotgun method: ProvidePlugin and expose-loader

You can use Webpack’s ProvidePlugin to make the jQuery top-level variables available everywhere within your Webpack compiled files. It is such a common fix that jQuery is the #1 example in the docs.

Because we’re using Webpacker, the proper incantation must be made in your config/webpack/environment.js:

const webpack = require('webpack')

environment.plugins.prepend(
  'Provide', new webpack.ProvidePlugin({
    $: 'jquery',
    jQuery: 'jquery'
  })
)

After that you can use $ and jQuery confidently within any JS under Webpack management, including most imported jQuery plugins.

Remember, though, they’re not globals, but supplied as an injected import to each module. Code outside of the pack e.g. in <script> tags remains unable to find jQuery; it is still looking for a global that doesn’t exist.

To solve this, configure Webpack to export the jquery module to the necessary window globals with the expose-loader. Install with yarn add expose-loader, and place the following in your config/webpack/environment.js:

environment.loaders.append('expose', {
  test: require.resolve('jquery'),
  use: [
    { loader: 'expose-loader', options: '$' },
    { loader: 'expose-loader', options: 'jQuery' }
  ]
})

Webpack will inject the expected global assignments, to the window object, from within the generated Javascript pack asset.

The last resort

You may be dealing with some very ancient code that plays silly buggers with the globals, and they can still get clobbered by who-knows-what-else from the bottom of the barrel. There’s one more method that sets the globals and locks them down. To me, this stinks, but here it is; stick this near the top of your app/javascript/packs/application.js:

import jQuery from 'jquery'

const jQueryProp = {
  value: jQuery,
  configurable: false,
  writable: false
}

Object.defineProperties(window, { jQuery: jQueryProp, $: jQueryProp })

That’s the bluntest instrument of the lot. Try not to use it.

Cleaning up

If you’re using the imports-loader syntax in a pack file, and everything’s working right, then I suggest moving the configuration into your config/webpack/environment.js. Technically, this is a matter of Webpack configuration and belongs in configuration code, not in application code. For example, in application.js, this:

require('imports-loader?define=>false!datatables.net')(window, $)
require('imports-loader?define=>false!datatables.net-select')(window, $)

becomes the much more normal:

require('datatables.net')(window, $)
require('datatables.net-select')(window, $)

when to config/webpack/environment.js you add:

environment.loaders.append('dt-imports', {
  test: [
    require.resolve('datatables.net'),
    require.resolve('datatables.net-select')
  ],
  use: 'imports-loader?define=>false'
})

I recommend saving this step for last, once everything else is working, because getting these rules correct may involve diving into Webpack configuration, as mediated by Webpacker, and that is a place where the shadows grow long and the beasts are strange.

It’s still worth the effort. Placing build configuration where it belongs leads to easier maintenance. Perhaps one day, DataTables and their ilk will become better citizens and you’ll be able to delete that block from environment.js without touching any application code.

The winning move

Don’t use jQuery

jQuery hails from an age when we needed a toolbelt that worked consistently across all browsers. It served us well and for a long time. Now, though, it’s obsolete; modern Javascript works pretty much consistently across the board, and polyfills handle targeted gaps for special cases.

jQuery doesn’t play well with anything designed in the last few years, and many plugins are equally obsolete, unmaintained, or both. Even venerable Bootstrap is ditching jQuery in version 5.

Alternatives

You might not need jQuery has many of the vanilla recipes you need.

To help you write more app-like code, Stimulus, being a product of Basecamp, is probably the most Rails-centric lightweight automation framework on the table. It is usually very easy to rewrite a jQuery front-end behaviour in Stimulus, it works by design with Turbolinks, and there’s a really interesting synthesis of Stimulus and ActionCable in Stimulus Reflex.

For heavyweight interfaces I often suggest Vue.js, which plays well with Rails, has direct Webpacker support, and (I think) Vue offers the feeling of “programmer happiness” that Ruby developers often seek. Vue’s ecosystem of handy components is also much larger than that of Stimulus, and in my experience, Vue also hooks nicely into ActionCable.

You’ll also want to learn good style for modular javascript and probably ES6 in general, but there’s such a range of books and tutorials available that I can’t even suggest one - just go hunting online.

I want to know more about Webpacker

At the time of writing, that’s a bit of a problem. There’s no introductory guide (yet).

The Rails 6 release included many new features. Some of them were very well documented, to the extent of new official guides, such as the new classloader. Webpacker, despite its significance, just about rated a passing mention. There is a long technical README but no introductory guide for Webpacker, and neither the guide for Working with JavaScript in Rails or the Asset Pipeline even mention it, which to me renders both obsolete at the time of writing.

There is a stack of technical notes in the source code, and overall that is best source of Webpacker reference documentation right now. However, they mostly assume familiarity with Webpack itself, and some are concerned with the internals or development of Webpacker.

Webpack itself is very well documented.

Technical note regarding config/webpack/environment.js

Throughout this article I have suggested adding stanzas to config/webpack/environment.js. Just to be clear, unless you are a Webpacker magician and smithing your own incantations, this file should always start with the line:

const { environment } = require('@rails/webpacker')

and end with this line:

module.exports = environment

Anything you add goes in between.