jQuery 4 (legacy code and migration to DOM APIs) Module 5 – Upgrading to jQuery 4
jQuery 4 as ES modules: bundlers, Node, TypeScript
How jQuery 4 serves Node.js, bundlers and import maps, why require("jquery") breaks tests, how plugins get one jQuery, and what its types catch.
What you will learn
- Import jQuery 4 as an ES module in a bundle, in Node.js and through a browser import map
- Fix Node.js test set-ups that jQuery 4's entry points and its exports map break
- Load classic plugins after window.jQuery is set, with a single copy of jQuery in the bundle
- Check jQuery 4 code with TypeScript and @types/jquery 4, and explain what the types cannot see
Before you start
What you’ll learn · Before you start
What you will learn
- Import jQuery 4 as an ES module in a bundle, in Node.js and through a browser import map
- Fix Node.js test set-ups that jQuery 4's entry points and its exports map break
- Load classic plugins after window.jQuery is set, with a single copy of jQuery in the bundle
- Check jQuery 4 code with TypeScript and @types/jquery 4, and explain what the types cannot see
Before you start
On this page
A test suite that passed for years on jQuery 3.7.1 stops on its first line after npm install jquery@4.0.0: “jQuery
requires a window with a document”. The same upgrade can nearly double a bundle, to 538,251 bytes, and leave a date
picker missing from $, without one wrong line in your own code.
Both come from how jQuery 4 is packaged. Its source is written as ES modules, and its package.json has an exports map: The exports field of a package.json: the paths other code may import from the package and, for each, the file to load under each condition, such as Node.js import, require or a bundler's module. Paths it does not list cannot be resolved, which Node.js reports as ERR_PACKAGE_PATH_NOT_EXPORTED. that hands Node.js, bundlers and browsers different files. Below you will see which file each of them gets, fix the test set-ups and bundles that break, and type-check the result.
The exports map gives Node.js, bundlers and browsers their own file
A package’s exports field lists the paths other code may import and, for each one, a file per condition: one for
Node.js import, one for require, one for bundlers that read the module condition, and a default
(Node.js packages). jQuery 4 added the map
(upgrade guide), and its package.json lists ., ./slim, ./factory,
./factory-slim and the source modules under ./src/. Step through what five kinds of caller receive:
The drawing shows the whole scene; its 7 steps are listed below.
Every step as text
- One name, five files. Every caller asks for the same name, "jquery". The package.json of jquery 4.0.0 has an exports map that hands each kind of caller its own file.
- Node.js, require. require('jquery') gets dist/jquery.js, the UMD build. It needs a window with a document as soon as it runs, which plain Node.js does not have.
- Node.js, import. import in Node.js gets a small wrapper that imports the same dist/jquery.js and adds the named exports $ and jQuery, so both module systems share one jQuery.
- A bundle. A bundler that reads the "module" condition takes dist-module/jquery.module.js, the ES module build, as the recorded esbuild bundles did.
- A CommonJS plugin. A plugin that calls require('jquery') inside the same bundle gets a wrapper that requires that ES module, so the plugin and your code hold one and the same jQuery.
- The browser. A page without a bundler maps "jquery" to the module file of the jQuery CDN in an import map. It is the same code as the ES module build, and it sets no window.jQuery.
- Paths the map does not list. A path the exports map does not name, such as jquery/dist/jquery.min.js or jquery/package.json, now fails to resolve with ERR_PACKAGE_PATH_NOT_EXPORTED.
A small program asked Node.js for jQuery eight ways, in a project with jquery 4.0.0 and 3.7.1 installed side by side, and a check read the import map of a page that loads jQuery without any bundler:
node/resolve.mjs · node/resolve.mjs
// resolve.mjs: which file each way of asking for jQuery gets in Node.js, in a project that has jquery@4.0.0 and
// jquery3@npm:jquery@3.7.1 installed.
import { createRequire } from 'node:module';
const require = createRequire(import.meta.url);
const short = (path) => path.slice(path.lastIndexOf('node_modules/') + 'node_modules/'.length);
function ask(how, resolve) {
try {
console.log(`${how.padEnd(42)} ${short(resolve())}`);
} catch (error) {
console.log(`${how.padEnd(42)} ${error.code}`);
}
}
ask("import 'jquery'", () => new URL(import.meta.resolve('jquery')).pathname);
ask("require('jquery')", () => require.resolve('jquery'));
ask("import 'jquery/factory'", () => new URL(import.meta.resolve('jquery/factory')).pathname);
ask("require('jquery/slim')", () => require.resolve('jquery/slim'));
ask("require('jquery/dist/jquery.min.js')", () => require.resolve('jquery/dist/jquery.min.js'));
ask("require('jquery/package.json')", () => require.resolve('jquery/package.json'));
ask("require('jquery3/dist/jquery.min.js')", () => require.resolve('jquery3/dist/jquery.min.js'));
ask("require('jquery3/package.json')", () => require.resolve('jquery3/package.json')); browser/page.html · browser/page.html
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Shop: jQuery 4 as a module</title>
<script type="importmap">
{
"imports": {
"jquery": "https://code.jquery.com/jquery-4.0.0.module.min.js"
}
}
</script>
<script type="module" src="app.js"></script>
</head>
<body>
<p id="greeting"></p>
</body>
</html> browser/app.js · browser/app.js
// app.js: the page's module. The browser resolves "jquery" with the page's import map.
import { $ } from 'jquery';
$('#greeting').text(`Namaste from jQuery ${$.fn.jquery}`); // Prints which file each way of asking for jQuery got in Node.js (node/resolve.mjs), and what the page's import map
// gives the browser (browser/page.html and app.js), as record.mjs recorded them.
import { recording, unchanged } from './show.mjs';
const { nodePrograms: node, browser } = recording;
unchanged(node.files);
console.log('Node.js, in a project with jquery 4.0.0 and jquery3 (3.7.1):');
console.log(node['resolve.mjs'].out);
unchanged(browser.files);
console.log('\nThe browser, with page.html:');
for (const [specifier, url] of Object.entries(browser.map)) console.log(` import map: "${specifier}" -> ${url}`);
for (const s of browser.specifiers) console.log(` app.js imports "${s}": ${s in browser.map ? 'mapped' : 'NOT MAPPED'}`);
const m = browser.module;
console.log(` ${m.file} exports ${m.exports.join(', ')}${m.same ? ', all the same function' : ''}: jQuery ${m.version}`);
console.log(` window.jQuery after the import: ${browser.globalAfterImport}`); Output
Node.js, in a project with jquery 4.0.0 and jquery3 (3.7.1):
import 'jquery' jquery/dist-module/wrappers/jquery.node-module-wrapper.js
require('jquery') jquery/dist/jquery.js
import 'jquery/factory' jquery/dist/jquery.factory.js
require('jquery/slim') jquery/dist/jquery.slim.js
require('jquery/dist/jquery.min.js') ERR_PACKAGE_PATH_NOT_EXPORTED
require('jquery/package.json') ERR_PACKAGE_PATH_NOT_EXPORTED
require('jquery3/dist/jquery.min.js') jquery3/dist/jquery.min.js
require('jquery3/package.json') jquery3/package.json
The browser, with page.html:
import map: "jquery" -> https://code.jquery.com/jquery-4.0.0.module.min.js
app.js imports "jquery": mapped
jquery-4.0.0.module.min.js exports $, default, jQuery, all the same function: jQuery 4.0.0
window.jQuery after the import: undefined
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node exports.mjs
Two lines are new failures. require('jquery/dist/jquery.min.js') and require('jquery/package.json') worked on
3.7.1 and fail on 4.0.0 with ERR_PACKAGE_PATH_NOT_EXPORTED, because Node.js refuses every path the map does not
list. Build scripts and test configurations that copy the dist file by its package path break here; resolve jquery
itself and take the file beside it, or read it from node_modules/jquery/dist/ as a plain path.
The browser part needs no tool at all. An import map: A script element of type importmap whose JSON tells the browser what a bare module name such as "jquery" means, as a URL, so that module scripts can import it without a bundler. tells the browser that "jquery"
means the module file on the jQuery CDN, so app.js can write import { $ } from 'jquery'
(HTML Standard). Import maps work from Chrome
89, Firefox 108 and Safari 16.4 (MDN browser-compat-data).
The check found the module exports $, jQuery and a default, all one function, and that window.jQuery is still
undefined after the import: the module build never sets a global, a fact that matters as soon as a classic plugin is on
the page.
Checkpoint
Check: The exports map of jquery 4
In Node.js, jQuery 4 needs a window first, or its factory
jQuery needs a window with a document, which Node.js does not have, so tests create one with
jsdom: A JavaScript implementation of the DOM and HTML standards for Node.js: it builds a page's document from HTML and can run the page's scripts, so tests can load jQuery in a window without a browser. It does no layout and enforces no Content Security Policy..
jQuery 3.7.1’s require('jquery') returned a function when no global window existed, and test set-ups called it with
the emulated window. jQuery 4 moved that function to a separate entry point, jquery/factory
(package README). This set-up tries four ways,
each with a fresh window:
// entry.cjs: the ways a Node.js test set-up can get jQuery when there is no global window, on 4.0.0 (jquery) and
// 3.7.1 (jquery3), each with a fresh jsdom window.
const { JSDOM } = require('jsdom');
function attempt(label, get) {
const { window } = new JSDOM('<p id="greeting">Namaste</p>');
try {
const $ = get(window);
const globals = typeof window.jQuery === 'function' ? 'sets window.jQuery' : 'no window.jQuery';
console.log(`${label}\n -> jQuery ${$.fn.jquery}, text "${$('#greeting').text()}", ${globals}`);
} catch (error) {
console.log(`${label}\n -> ${error.name}: ${error.message}`);
}
}
attempt("4.0.0: require('jquery')(window)", (window) => require('jquery')(window));
attempt("3.7.1: require('jquery3')(window)", (window) => require('jquery3')(window));
attempt("4.0.0: require('jquery/factory')(window)", (window) => require('jquery/factory')(window));
attempt("4.0.0: require('jquery/factory').jQueryFactory(window)", (window) => {
const { jQueryFactory } = require('jquery/factory');
return jQueryFactory(window);
}); // Prints what node/entry.cjs and node/plugin.cjs printed with jsdom, as record.mjs recorded them.
import { recording, unchanged } from './show.mjs';
const node = recording.nodePrograms;
unchanged(node.files);
console.log(node['entry.cjs'].out);
console.log('\nA classic plugin after jQueryFactory(window):');
console.log(node['plugin.cjs'].out); Output
4.0.0: require('jquery')(window)
-> Error: jQuery requires a window with a document
3.7.1: require('jquery3')(window)
-> jQuery 3.7.1, text "Namaste", sets window.jQuery
4.0.0: require('jquery/factory')(window)
-> TypeError: require(...) is not a function
4.0.0: require('jquery/factory').jQueryFactory(window)
-> jQuery 4.0.0, text "Namaste", no window.jQuery
A classic plugin after jQueryFactory(window):
without window.jQuery = $: ReferenceError: jQuery is not defined
with window.jQuery = $: data-count="3"
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node node.mjs
The first attempt is the error from the opening: in 4.0.0, require('jquery') runs the UMD build at once and finds no
document. The third attempt is the upgrade guide’s own example, require('jquery/factory')(window), which fails because
the module exports an object, { jQueryFactory }, not a function. The fourth attempt is the form the package README
shows: const { jQueryFactory } = require('jquery/factory'), then jQueryFactory(window).
The last two lines show what the factory does not do. jQuery 3’s function set window.jQuery on the window it was
given; jQuery 4’s factory returns jQuery and sets no global. A classic plugin evaluated in that window looks for the
global jQuery and throws ReferenceError. One line restores what jQuery 3 did by itself:
window.jQuery = window.$ = $. The upgrade guide names one more way: attach the jsdom window to globalThis before
you import or require jquery.
Copying the factory call from the upgrade guide
Wrong: const $ = require('jquery/factory')(window), which throws “is not a function” on 4.0.0. Right:
const { jQueryFactory } = require('jquery/factory'); const $ = jQueryFactory(window);, as the package README and the
@types/jquery declarations both write it.
Checkpoint
Check: jQuery 4 in Node.js and test set-ups
A classic plugin needs window.jQuery before it runs
Moving a page’s scripts into a bundle usually starts with import { $ } from 'jquery' and the old plugins imported
after it. A classic plugin has no import of its own: it reads the global jQuery, as it did from a script element.
This entry point sets the global on the line before it imports the plugin, and esbuild bundled it and a corrected one:
plugins/badge.js · bundle/src/plugins/badge.js
// badge.js: a classic jQuery plugin. It has no import: it finds jQuery as the global jQuery, as a script element would.
(function ($) {
$.fn.badge = function (count) {
return this.attr('data-count', count);
};
})(jQuery); bundle/src/wrong.js
// wrong.js: the entry point as first written. It sets the global before the plugin's import line.
import { $ } from 'jquery';
window.jQuery = window.$ = $;
import './plugins/badge.js';
$('#cart').badge(3);
console.log(`badge: data-count="${$('#cart').attr('data-count')}"`); bundle/src/expose.js
// expose.js: makes the jQuery of the bundle the page's global jQuery, for the classic plugins imported after it.
import { $ } from 'jquery';
window.jQuery = window.$ = $; bundle/src/right.js
// right.js: the global is set in a module of its own, imported before the plugin.
import './expose.js';
import './plugins/badge.js';
import { $ } from 'jquery';
$('#cart').badge(3);
console.log(`badge: data-count="${$('#cart').attr('data-count')}"`); // Prints the two bundles of bundle/ that esbuild built, and what each logged in a jsdom page (recorded.mjs).
import { printBundle, recording, unchanged } from './show.mjs';
unchanged(recording.bundle.files);
for (const entry of ['wrong.js', 'right.js']) {
console.log(`${entry} (esbuild ${recording.esbuild}):`);
printBundle(recording.bundle[entry]);
} Output
wrong.js (esbuild 0.28.2):
210,403 bytes, from:
node_modules/jquery/dist-module/jquery.module.js
Uncaught ReferenceError: jQuery is not defined
right.js (esbuild 0.28.2):
210,423 bytes, from:
node_modules/jquery/dist-module/jquery.module.js
badge: data-count="3"
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node bundle.mjs
wrong.js threw ReferenceError: jQuery is not defined, although it sets the global on the line before the plugin’s
import. The order of the lines is not the order of evaluation: an ES module runs all of its imports before any line of its own body. Step
through both bundles:
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- Imports run first. An ES module runs every module it imports before its own body, whatever line the imports are written on. wrong.js imports jQuery first.
- The plugin's turn. Next comes ./plugins/badge.js, still before any line of wrong.js's body has run, so window.jQuery = $ has not happened yet.
- jQuery is not defined. badge.js reads the global jQuery, and the module build sets no global, so it throws ReferenceError: jQuery is not defined, as the recorded run did.
- A module of its own. right.js moves the assignment into expose.js and imports it first. expose.js imports jQuery, then its body sets window.jQuery and window.$.
- The plugin finds it. Now badge.js runs after expose.js has finished, finds the global jQuery and adds $.fn.badge to the same object the bundle's code uses.
- The body runs last. Only then does right.js run its own body: $('#cart').badge(3) sets data-count="3", the line the recorded run printed.
The fix gives the assignment a module of its own, expose.js, and imports it before the plugins. Its body runs right
after its own import of jQuery and before the next module, so every plugin imported after it finds window.jQuery. esbuild’s inject option
can supply the name instead (esbuild API), but only inside the bundle: inline
scripts and other script elements of the page still need window.jQuery, which a module of your own gives them.
Key idea
The jQuery 4 module build sets no global, and imports run before the importing module’s body, so give
window.jQuery its own module and import it before every classic plugin.
Checkpoint
Check: window.jQuery for classic plugins
One copy of jQuery: a plugin’s own copy splits the bundle
A plugin installed with npm declares the jQuery releases it accepts. bootstrap-datepicker 1.10.1 accepts
>=3.4.0 <4.0.0, so beside jquery 4.0.0 npm gives it a nested copy: A second copy of a package that npm installs inside another package's folder, because that package's dependency range excludes the release the project installs: jquery-ui 1.13.3 got its own jquery 3.7.1 beside 4.0.0. More in “Audit a jQuery codebase before you upgrade” of 3.7.1, and its
code calls require('jquery') from inside its own folder. The shop’s date field ran in two projects, the second with an
overrides entry (npm Docs):
copies/package.json · copies/package.json
{
"name": "shop-front",
"version": "1.0.0",
"private": true,
"dependencies": {
"bootstrap-datepicker": "1.10.1",
"jquery": "4.0.0"
}
} copies/one/package.json · copies/one/package.json
{
"name": "shop-front",
"version": "1.0.0",
"private": true,
"dependencies": {
"bootstrap-datepicker": "1.10.1",
"jquery": "4.0.0"
},
"overrides": {
"bootstrap-datepicker": {
"jquery": "$jquery"
}
}
} src/picker.js · copies/src/picker.js
// picker.js: the delivery date field of the shop, with the bootstrap-datepicker plugin from npm.
import { $ } from 'jquery';
import 'bootstrap-datepicker';
console.log(`jQuery ${$.fn.jquery}; $.fn.datepicker is ${typeof $.fn.datepicker}`);
if ($.fn.datepicker) {
$('#delivery').datepicker({ format: 'dd/mm/yyyy' });
console.log(`the field has a picker: ${Boolean($('#delivery').data('datepicker'))}`);
} // Prints the two npm projects of copies/: npm ls jquery --all, the bundle of src/picker.js and what it logged in a
// jsdom page, first as installed, then with the override (recorded.mjs).
import { printBundle, recording, unchanged } from './show.mjs';
const { copies } = recording;
unchanged(copies.files);
for (const [label, title] of [['nested', 'copies/package.json'], ['one', 'copies/one/package.json, with the override']]) {
const c = copies[label];
console.log(`${title}\n $ npm ls jquery --all`);
for (const l of c.ls.lines) console.log(` ${l}`);
console.log(' the bundle of src/picker.js:');
printBundle(c);
console.log('');
} Output
copies/package.json
$ npm ls jquery --all
shop-front@1.0.0
├─┬ bootstrap-datepicker@1.10.1
│ └── jquery@3.7.1
└── jquery@4.0.0
the bundle of src/picker.js:
538,251 bytes, from:
node_modules/bootstrap-datepicker/dist/js/bootstrap-datepicker.js
node_modules/bootstrap-datepicker/node_modules/jquery/dist/jquery.js
node_modules/jquery/dist-module/jquery.module.js
jQuery 4.0.0; $.fn.datepicker is undefined
copies/one/package.json, with the override
$ npm ls jquery --all
shop-front@1.0.0
├─┬ bootstrap-datepicker@1.10.1
│ └── jquery@4.0.0 deduped
└── jquery@4.0.0
the bundle of src/picker.js:
281,444 bytes, from:
node_modules/bootstrap-datepicker/dist/js/bootstrap-datepicker.js
node_modules/jquery/dist-module/jquery.module.js
node_modules/jquery/dist/wrappers/jquery.bundler-require-wrapper.js
jQuery 4.0.0; $.fn.datepicker is function
the field has a picker: true
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node copies.mjs
The first bundle took in both copies: 538,251 bytes, with jquery 3.7.1’s dist/jquery.js from the plugin’s folder and
4.0.0’s module from yours. The plugin added datepicker to its own copy, so $.fn.datepicker was undefined on the
$ of picker.js, and nothing threw until the field needed it. The override tells npm to give the plugin the
project’s own jquery: npm ls shows jquery@4.0.0 deduped, the bundle shrank to 281,444 bytes, and the plugin’s
require('jquery') reached the bundler wrapper of the same module, so the picker attached to the field.
An override overrules the plugin’s own range, so it is a decision, not a fix. The datepicker’s authors have not declared support for 4.x, so test the plugin on 4.0.0 with jQuery Migrate’s development build before you ship it, as the lesson on Migrate shows, and prefer a release of the plugin that accepts 4.x when one exists.
Make two copies a build error
Write esbuild’s --metafile and fail the build when its inputs hold files from more than one node_modules/jquery/
folder; the practice lab below builds that check.
Checkpoint
Check: One copy of jQuery in a bundle
TypeScript knows jQuery 4’s names, not its behaviour
jQuery ships no type declarations; @types/jquery 4.0.1 describes jQuery 4, with declarations for jquery,
jquery/slim, jquery/factory and jquery/factory-slim that follow the package’s exports map
(@types/jquery). With module and moduleResolution set to nodenext,
TypeScript reads those conditions the way Node.js does
(TypeScript modules reference). Four files went
through TypeScript 6.0.3: a cart module that kept four lines of its jQuery 3 version, a test set-up written from the
upgrade guide, and both files fixed:
types/cart.mts
// cart.mts: the cart badge, moved into a module for jQuery 4, with four lines kept from its jQuery 3 version.
import { $ } from 'jquery';
export function showCart(count: number, open: boolean): void {
const $badge = $('#cart-badge');
$badge.text($.trim(String(count)));
$badge.toggleClass(open);
$badge.css('fontSize', 18);
$.fx.interval = 100;
} types/setup.cts
// setup.cts: a CommonJS test set-up, written from the upgrade guide's example of the factory.
import factory = require('jquery/factory');
export const $ = factory(window); types/cart-fixed.mts
// cart-fixed.mts: the same badge, written for jQuery 4.
import { $ } from 'jquery';
export function showCart(count: number, open: boolean): void {
const $badge = $('#cart-badge');
$badge.text(String(count).trim());
$badge.toggleClass('open', open);
$badge.css('fontSize', '18px');
} types/setup-fixed.cts
// setup-fixed.cts: the same set-up, with the factory as the package exports it.
import { jQueryFactory } from 'jquery/factory';
export const $ = jQueryFactory(window); types/tsconfig.json
{
"compilerOptions": {
"target": "es2022",
"module": "nodenext",
"moduleResolution": "nodenext",
"lib": ["es2022", "dom"],
"strict": true,
"noEmit": true,
"types": []
},
"files": ["cart.mts", "setup.cts", "cart-fixed.mts", "setup-fixed.cts"]
} // Prints what TypeScript said about the four files of types/ (recorded.mjs).
import { recording, unchanged } from './show.mjs';
const { types } = recording;
unchanged(types.files);
console.log(`$ tsc -p tsconfig.json (TypeScript ${recording.typescript}, @types/jquery ${recording.typesJquery})`);
for (const l of types.lines) console.log(l);
const files = new Set(types.lines.map((l) => /^([\w.-]+)\(/.exec(l)?.[1]).filter(Boolean));
console.log(`\nexit status ${types.exit}: errors in ${[...files].join(' and ')}; cart-fixed.mts and setup-fixed.cts are clean`); Output
$ tsc -p tsconfig.json (TypeScript 6.0.3, @types/jquery 4.0.1)
cart.mts(6,17): error TS2339: Property 'trim' does not exist on type 'JQueryStatic'.
cart.mts(7,22): error TS2345: Argument of type 'boolean' is not assignable to parameter of type 'TypeOrArray<string> | ((this: HTMLElement, index: number, className: string, state: boolean) => string)'.
cart.mts(9,8): error TS2339: Property 'interval' does not exist on type 'Effects'.
setup.cts(4,18): error TS2349: This expression is not callable.
Type 'typeof import("node_modules/@types/jquery/factory")' has no call signatures.
exit status 2: errors in cart.mts and setup.cts; cart-fixed.mts and setup-fixed.cts are clean
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node types.mjs
The types caught three of the four old lines: $.trim(), toggleClass() with a boolean and $.fx.interval, all gone
from jQuery 4. They also caught the upgrade guide’s factory call before it ever ran: factory(window) is “not
callable”, because the module’s type is an object with jQueryFactory. The fourth old line, css('fontSize', 18),
passed, although jQuery 4 no longer adds px to it: a number is a valid argument, and what jQuery does with it at run
time is not in any type. Tests cover that part, as the lesson on silent changes
shows.
Checkpoint
Check: What @types/jquery 4 catches
Practise: a set-up and a build check
Two steps of an upgrade’s tooling: a test set-up that gets jQuery from either release’s module, and a build check that counts the copies of jQuery in a bundle. The sample tests give each step stand-ins of the modules and metafiles of this lesson:
Code lab · 2 steps
Tooling for the jQuery 4 move
Write jqueryFor(), which builds jQuery for an emulated window from jQuery 3's or 4's module, then jqueryCopies(), which lists the jQuery packages in an esbuild metafile.
- One test set-up for the jQuery 3 and jQuery 4 factories To do
- Fail the build when a bundle holds two copies of jQuery To do
Exercise · Medium · JavaScript
One test set-up for the jQuery 3 and jQuery 4 factories
A test suite must run on jQuery 3.7 and on jQuery 4.0 while the upgrade is under way. Write jqueryFor(mod, window, { globals }) in setup.mjs: it takes what the suite's set-up loaded with require() and an emulated window, and returns the jQuery of that window.
- On jQuery 3,
modis whatrequire('jquery')returns in Node.js without a window: a function that takes the window and returns jQuery. - On jQuery 4,
modis whatrequire('jquery/factory')returns: an object whosejQueryFactory(window)returns jQuery. - When
globalsistrue, the window'sjQueryand$are that jQuery afterwards, so that classic plugins loaded into the window find it. jQuery 3's function sets them by itself; jQuery 4's factory never does. - When
globalsisfalseor not given,jqueryFor()adds no global of its own.
Anything else as mod is a mistake in the set-up: throw a TypeError whose message names what was expected, with the words require('jquery') and require('jquery/factory') in it.
Starter code · setup.mjs
/**
* The jQuery of an emulated window, from jQuery 3's require('jquery') or jQuery 4's require('jquery/factory').
* @param {Function | { jQueryFactory: Function }} mod
* @param {object} window
* @param {{ globals?: boolean }} [options]
*/
export function jqueryFor(mod, window, options = {}) {
return mod(window);
} The sample tests · setup.test.mjs
import { test, assert } from 'mysmartcopilot:test';
import { jqueryFor } from './setup.mjs';
// Stand-ins for what require() returns in Node.js without a window. jQuery 3's module is a function that builds
// jQuery for a window and sets window.jQuery and window.$ itself; jQuery 4's jquery/factory module is an object whose
// jQueryFactory() builds it and sets no global.
function fakeJquery(version, window) {
const $ = () => ({ text: () => window.document.title });
$.fn = { jquery: version };
return $;
}
const v3module = (window) => {
if (!window.document) throw new Error('jQuery requires a window with a document');
const $ = fakeJquery('3.7.1', window);
window.jQuery = window.$ = $;
return $;
};
const v4module = {
jQueryFactory(window) {
if (!window.document) throw new Error('jQuery requires a window with a document');
return fakeJquery('4.0.0', window);
},
};
const newWindow = () => ({ document: { title: 'Shop' } });
test('returns the jQuery of the window, from either module', () => {
for (const [mod, version] of [[v3module, '3.7.1'], [v4module, '4.0.0']]) {
const window = newWindow();
const $ = jqueryFor(mod, window);
assert.equal($.fn.jquery, version);
assert.equal($().text(), 'Shop', `${version}: built for this window`);
}
});
test('globals: true makes window.jQuery and window.$ that jQuery', () => {
for (const mod of [v3module, v4module]) {
const window = newWindow();
const $ = jqueryFor(mod, window, { globals: true });
assert.equal(window.jQuery, $);
assert.equal(window.$, $);
}
});
test('without globals, jQuery 4 leaves the window as it was', () => {
const window = newWindow();
jqueryFor(v4module, window);
assert.equal(window.jQuery, undefined);
assert.equal(window.$, undefined);
});
test('a module of another shape is a TypeError that names jquery/factory', () => {
for (const mod of [{}, { jQueryFactory: 'no' }, null]) {
assert.throws(() => jqueryFor(mod, newWindow()), TypeError);
assert.throws(() => jqueryFor(mod, newWindow()), /jquery\/factory/);
}
}); A hint
Tell the two shapes apart with typeof: a function is jQuery 3's module, an object with a jQueryFactory function is jQuery 4's. Call the one you have with the window, then set window.jQuery = window.$ = $ only when globals is true.
Results of the sample tests
| Test | Result | Details |
|---|
What your code printed
The sample tests run on this device, in your browser (QuickJS): nothing is sent to mysmartcopilot.com. The first run downloads JavaScript (about 0.6 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.
Exercise · Medium · JavaScript
Fail the build when a bundle holds two copies of jQuery
esbuild's --metafile option writes a JSON file whose inputs object has one key for every file the bundle took in, such as node_modules/jquery/dist-module/jquery.module.js. Write jqueryCopies(meta) in copies.mjs: it returns the folder of every copy of the jquery package the bundle contains, sorted, each folder once, so that a build step can stop when there is more than one.
- A copy of jQuery is the folder of a package named exactly
jquery: the part of a key up to and includingnode_modules/jquery, the last time it appears, followed by/.node_modules/jquery/dist/jquery.jsandnode_modules/bootstrap-datepicker/node_modules/jquery/dist/jquery.jsare two copies. - Several files of one folder are one copy: the ES module and the wrapper that requires it come from the same package.
- Packages whose names only start with
jquery, such asjquery-uiorjquery-migrate, are not jQuery. - Keys without
node_modules, your ownsrc/files, never count.
Starter code · copies.mjs
/**
* The folders of the jquery packages an esbuild bundle took in, sorted, each once.
* @param {{ inputs: Record<string, unknown> }} meta esbuild's metafile
* @returns {string[]}
*/
export function jqueryCopies(meta) {
return Object.keys(meta.inputs).filter((file) => file.includes('jquery'));
} The sample tests · copies.test.mjs
import { test, assert } from 'mysmartcopilot:test';
import { jqueryCopies } from './copies.mjs';
// Inputs of esbuild metafiles, as the lesson's bundles of src/picker.js recorded them, and two more layouts.
const metafile = (...files) => ({ inputs: Object.fromEntries(files.map((f) => [f, { bytes: 1 }])) });
test('a plugin with its own copy: two folders', () => {
const meta = metafile(
'node_modules/bootstrap-datepicker/dist/js/bootstrap-datepicker.js',
'node_modules/bootstrap-datepicker/node_modules/jquery/dist/jquery.js',
'node_modules/jquery/dist-module/jquery.module.js',
'src/picker.js',
);
assert.deepEqual(jqueryCopies(meta), ['node_modules/bootstrap-datepicker/node_modules/jquery', 'node_modules/jquery']);
});
test('the module and its require wrapper: one folder', () => {
const meta = metafile(
'node_modules/bootstrap-datepicker/dist/js/bootstrap-datepicker.js',
'node_modules/jquery/dist-module/jquery.module.js',
'node_modules/jquery/dist/wrappers/jquery.bundler-require-wrapper.js',
'src/picker.js',
);
assert.deepEqual(jqueryCopies(meta), ['node_modules/jquery']);
});
test('jquery-ui and jquery-migrate are not jQuery', () => {
const meta = metafile(
'node_modules/jquery-ui/ui/widgets/datepicker.js',
'node_modules/jquery-migrate/dist/jquery-migrate.js',
'node_modules/jquery/dist-module/jquery.module.js',
'src/jquery-setup.js',
);
assert.deepEqual(jqueryCopies(meta), ['node_modules/jquery']);
});
test('a pnpm store path and no jQuery at all', () => {
const pnpm = metafile('node_modules/.pnpm/jquery@3.7.1/node_modules/jquery/dist/jquery.js', 'src/app.js');
assert.deepEqual(jqueryCopies(pnpm), ['node_modules/.pnpm/jquery@3.7.1/node_modules/jquery']);
assert.deepEqual(jqueryCopies(metafile('src/app.js')), []);
}); A hint
For each key of meta.inputs, find the last node_modules/jquery/ in it with lastIndexOf(); the folder is the key up to the end of node_modules/jquery. Collect the folders in a Set to keep each one once, then sort them.
Results of the sample tests
| Test | Result | Details |
|---|
What your code printed
The sample tests run on this device, in your browser (QuickJS): nothing is sent to mysmartcopilot.com. The first run downloads JavaScript (about 0.6 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.
Then the questions of the lesson, one at a time, the weakest idea first, each choice with what it gets right or misses:
Practice that adapts to you
Practise this lesson
15 questions about 5 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
Interview questions
Warm-up (fresher to 2–5 years): why does require('jquery') throw “jQuery requires a window with a document” in
Node.js with jQuery 4, when the same line worked with jQuery 3?
Because jQuery 3 and 4 behave differently when there is no global window. jQuery 3.7.1’s CommonJS entry returned a
factory function in that case, and test set-ups called it with a jsdom window: require('jquery')(window). jQuery 4’s
require('jquery') runs the build at once, finds no document and throws. The factory now has its own entry point:
const { jQueryFactory } = require('jquery/factory'), then jQueryFactory(window). It returns jQuery without setting
window.jQuery, so a set-up that loads classic plugins into the window adds window.jQuery = window.$ = $. The other
way is to put the jsdom window on globalThis before importing jquery.
The bank of this module has two harder questions, about a plugin that cannot find jQuery in a bundle and a bundle that doubled in size. Work out your answer before you open each first step:
Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.
-
Mid to senior
After a page's scripts move into a bundle with import { $ } from 'jquery', a classic plugin throws ReferenceError: jQuery is not defined, although the entry sets window.jQuery on the line before the plugin's import. Explain why, and fix it so the page's inline scripts keep working too.
-
Mid to staff
After the move to jQuery 4, a shop's bundle grew from about 280 KB to about 540 KB, and $('#delivery').datepicker() now throws "is not a function". The plugin's newest release still declares jquery below 4. Diagnose it, and say what you would ship this week and what later.
Key takeaways
- jQuery 4’s exports map gives Node.js
requirethe UMD build, Node.jsimporta wrapper of it, bundlers the ES module, and refuses deep paths such asjquery/dist/jquery.min.jswithERR_PACKAGE_PATH_NOT_EXPORTED. - In Node.js without a global window, use
jQueryFactory(window)fromjquery/factory, and setwindow.jQueryyourself when classic plugins run in that window. - The module build sets no global and imports run first, so
window.jQuerygets a module of its own, imported before every classic plugin. - A plugin whose range excludes 4.x brings its own copy of jQuery into the bundle; find it in
npm lsor the metafile, and settle it with a plugin release that accepts 4.x or a tested override. @types/jquery4.0.1 rejects removed APIs and the wrong factory call, but cannot see behaviour such as a number passed to.css().
Terms in this lesson
- exports map
- The exports field of a package.json: the paths other code may import from the package and, for each, the file to load under each condition, such as Node.js import, require or a bundler's module. Paths it does not list cannot be resolved, which Node.js reports as ERR_PACKAGE_PATH_NOT_EXPORTED.
- import map
- A script element of type importmap whose JSON tells the browser what a bare module name such as "jquery" means, as a URL, so that module scripts can import it without a bundler.
References
- jQuery 4.0.0 package README: Node.js pre-requisites and the factory (OpenJS Foundation (jQuery))
- jQuery Core 4.0 Upgrade Guide (OpenJS Foundation (jQuery))
- jQuery 4.0.0 Released (OpenJS Foundation (jQuery blog))
- Modules: Packages, package entry points and conditional exports (OpenJS Foundation (Node.js))
- HTML Standard: import maps (WHATWG)
- MDN browser-compat-data: html.elements.script.type.importmap (MDN (Mozilla))
- esbuild API: bundle, metafile and conditions (esbuild project)
- npm Docs: package.json overrides (npm, Inc. (GitHub))
- npm Docs: npm ls (npm, Inc. (GitHub))
- @types/jquery (npm (DefinitelyTyped))
- TypeScript: Modules reference (node16 and nodenext) (Microsoft (TypeScript))
- jsdom (jsdom project)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress