Fix a jQuery plugin that registers on the wrong jQuery in webpack
If a jQuery plugin imported through webpack is undefined on the page's jQuery, the plugin probably registered itself on a second, private copy of jQuery that webpack bundled. The narrow fix is a function-style externals entry that maps the request jquery to the page's global only when it comes from that plugin's own package directory, leaving every other bundle untouched.
What went wrong with the plugin?
A teammate was wiring a jQuery plugin into a Drupal theme through webpack. Drupal ships its own jQuery and exposes it as a global, and the theme's other scripts rely on that one. The plugin worked only when a separate library happened to load first. Otherwise calling the plugin method on a jQuery object threw that the function did not exist.
The cause was that require("jquery") inside the plugin's package did not resolve to Drupal's jQuery at all. Webpack resolved it to a copy under node_modules that was pulled in because another dependency, a menu plugin, listed jQuery as a dependency. jQuery plugins work by attaching a method to jQuery.fn, so the plugin attached itself to the bundled copy, while the page code kept calling the global one. The loading-order dependency came from a different library that had already attached the method to the global.
How to confirm the plugin used a different jQuery
Before changing the build, check that the problem is what it appears to be. Two quick tests settle it:
- In the browser console, compare the global with what the bundle used. A bundled copy usually has a different
jQuery.fn.jqueryversion string, andwindow.jQuery.fn.pluginNameis undefined while it is defined on the bundled copy. - Run
npm ls jquery. More than one entry, or a deduped entry under an unexpected parent, tells you which dependency dragged a copy in.
Why not make jQuery external for everything?
The textbook answer is webpack's externals option: declare jquery as external and webpack leaves the import alone, reading it from a global at runtime.
// webpack.config.js
module.exports = {
externals: {
jquery: "jQuery",
},
};
That is correct if every file in the bundle should share the page's jQuery. Here it was too broad. Other code in the same bundle, including the menu plugin that caused the problem, was written and tested against its own jQuery. Switching all of them to the global changes their behaviour, and the global may be a different major version from what they expect. The change needed to be limited to the one plugin that must register on the page's jQuery.
How to scope externals to one package
In webpack 5 an externals entry can be a function. It receives the request and the directory of the file making the request, and calls back with either nothing (bundle as normal) or an external definition. That lets you decide per importer:
// webpack.config.js
const path = require("path");
const pluginDir = path.dirname(
require.resolve("some-jquery-plugin/package.json")
);
module.exports = {
externals: [
({ context, request }, callback) => {
const fromPlugin =
context && path.resolve(context).startsWith(pluginDir + path.sep);
if (request === "jquery" && fromPlugin) {
// Read jQuery from the page global instead of bundling a copy.
return callback(null, "window jQuery");
}
callback();
},
],
};
Three details matter. context is the directory of the importing module, so comparing it with the plugin's package directory identifies the importer without listing files. Appending path.sep before startsWith stops a sibling directory with a similar name from matching. And the "window jQuery" form tells webpack to read window.jQuery at runtime, whatever the library output type.
With that in place the plugin gets the page's jQuery, so its method lands on jQuery.fn of the object the theme code uses. Every other import of jquery still resolves to the bundled copy as before.
What to watch for with this approach
- Load order. An external is read when the module executes, so the page global has to exist by then. In Drupal that means declaring a dependency on core's jQuery library for the theme's script, so the global is loaded first.
- Nested plugin dependencies. The check covers files inside the plugin's package directory. If the plugin has its own nested
node_modules, those paths are also under that directory and are covered, which is usually what you want. - Deduplication as an alternative. If every consumer can share one version, a
resolve.aliasthat pointsjqueryat a single path avoids the problem too. It still bundles jQuery, so it does not give you the page's global, and it is the wrong tool when the page already provides jQuery.
I would reach for this scoped form whenever a bundle mixes code that wants the page's jQuery with code that carries its own, and for the global form only when I own the whole bundle.
Key takeaways
- A jQuery plugin that is missing from the page's jQuery has usually registered on a second copy that webpack bundled because some other dependency required jQuery.
- Compare
window.jQuery.fn.jqueryandnpm ls jqueryto prove there are two copies before touching the build. - A blanket
externalsentry for jQuery changes every consumer in the bundle, so prefer a function entry that checks the importing directory. - In a function externals entry, compare
contextagainst the plugin's package directory plus a path separator, and return"window jQuery"to read the page global at runtime. - Make sure the page's jQuery loads before the bundle executes, because an external is resolved when the module runs.