Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

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.

  • Advanced
  • 25 minutes
  • Examples run with Node.js 24.21.0, jQuery 4.0.0 and quickjs 0.32.0
  • By MySmartCoPilot
Free lesson, Pro answers: 25 minutes
Free: the whole lesson. With a pass: the model answers to its 2 interview questions.

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:

Interactive · step by step Who gets which file of the jquery 4.0.0 package Step through five ways of asking for "jquery" and watch which file of the package each one is given.
Who gets which file of the jquery 4.0.0 package, step 7 of 7. Showing require('jquery'), import 'jquery', import in a bundle, require in a bundle, The browser, dist/jquery.js, node-module-wrapper.js, jquery.module.js, bundler-require-wrapper, CDN module file. In focus: dist/jquery.js.require('jquery')Node.js, CommonJSimport 'jquery'Node.js, ES moduleimport in a bundlethe bundler's own coderequire in a bundlea CommonJS pluginThe browserthrough an import mapdist/jquery.jsUMD, needs a windownode-module-wrapper.jsimports dist/jquery.jsjquery.module.jsthe ES module buildbundler-require-wrapperrequires the ES moduleCDN module filesame code as the ES builddeep paths refused

The drawing shows the whole scene; its 7 steps are listed below.

Every step as text
  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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.
  6. 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.
  7. 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:

The program, and the page with an import map

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}`);
Which file each caller got JavaScript · exports.mjs
// 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

  1. Question 1 of 2 A test configuration lists the jQuery file with require.resolve('jquery/dist/jquery.min.js'). On 4.0.0 it stops with ERR_PACKAGE_PATH_NOT_EXPORTED. Which fix works?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: Resolve 'jquery' itself, which gives dist/jquery.js, and take jquery.min.js from the same folder, or list node_modules/jquery/dist/jquery.min.js as a plain path

    Since jQuery 4, Node.js refuses every package path that the exports map does not name. 3.7.1 had no map, so the same line still resolved for jquery3 in the recorded run.

  2. Question 2 of 2 A page now loads jQuery 4 as a module through an import map, and still has an old script element for a slider plugin that calls jQuery(…). What happens?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: The plugin fails: the module build sets no window.jQuery, so a module must set it, and the plugin must load after that module

    The module build ends by calling its factory with noGlobal set, so it never touches window. Expose it from a module of your own, and load classic plugins after it.

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:

node/entry.cjs JavaScript · node/entry.cjs
// 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);
});
Four ways to get jQuery in Node.js, and a classic plugin after the factory JavaScript · node.mjs
// 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

  1. Question 1 of 2 Which code gives a jQuery 4 test set-up jQuery for a jsdom window?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: const { jQueryFactory } = require('jquery/factory'); const $ = jQueryFactory(window);

    jquery/factory exports { jQueryFactory }. Call it with the window, and set window.jQuery yourself if plugins need the global.

  2. Question 2 of 2 After the switch to jQueryFactory(window), a test that loads a classic plugin into the window fails with ReferenceError: jQuery is not defined. Why?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: jQuery 4's factory sets no window.jQuery, which jQuery 3's require('jquery')(window) did; add window.jQuery = window.$ = $

    jQuery 3 set the globals as a side effect; jQuery 4's factory leaves them to you, which keeps tests explicit about what the page provides.

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:

The plugin and the two entry points

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')}"`);
Both bundles, run in a page JavaScript · bundle.mjs
// 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:

Interactive · message by message Why the plugin ran before window.jQuery was set Step through wrong.js as the bundle runs it, then right.js, and watch when the plugin looks for jQuery.
Why the plugin ran before window.jQuery was set, step 6 of 6. Messages so far: The entry module to jquery.module.js: import { $ }; The entry module to badge.js: import badge.js; The entry module to jquery.module.js: via expose.js; The entry module to badge.js: import badge.js.The entry moduleright.jsjquery.module.jsbadge.js$.fn.badge addedimport { $ }import badge.jsvia expose.jsimport badge.js

The drawing shows the whole scene; its 6 steps are listed below.

Every step as text
  1. 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.
  2. 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.
  3. 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.
  4. 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.$.
  5. 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.
  6. 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

  1. One question entry.js reads: import { $ } from 'jquery'; window.jQuery = $; import './legacy-plugin.js'; and the plugin throws jQuery is not defined. What is the fix?

    Choose one answer.

    Show the answer: One question

    Answer: Move the assignment into a module of its own and import that module before the plugin

    An ES module evaluates every import, in order, before any statement of its own body. Give the global its own module, imported first.

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):

The two package.json files and the page's script

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'))}`);
}
npm ls, the bundle and the page, with two copies and with one JavaScript · copies.mjs
// 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

  1. Question 1 of 2 After the upgrade, a bundle with bootstrap-datepicker 1.10.1 grew from about 281 KB to 538 KB, and $.fn.datepicker is undefined. What happened?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: The plugin's range excludes 4.x, so npm gave it its own jquery 3.7.1; the bundle holds both, and the plugin attached to its copy

    Each copy is its own jQuery object. A plugin adds its methods to the one it required, so the other copy never sees them.

  2. Question 2 of 2 You add "overrides": { "bootstrap-datepicker": { "jquery": "$jquery" } }, and npm ls shows jquery@4.0.0 deduped. What have you established?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: Only that one copy is installed and bundled; the plugin never declared support for 4.x, so it still needs testing on 4.0.0

    Treat an override as a decision with a test attached: run the plugin's pages on 4.0.0 with Migrate's development build, and prefer a release of the plugin that accepts 4.x when one exists.

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:

The four files and the configuration

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"]
}
What TypeScript said about them JavaScript · types.mjs
// 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

  1. Question 1 of 2 Which of these did TypeScript 6.0.3 with @types/jquery 4.0.1 report?

    Choose every answer that is right.

    Show the answer: Question 1 of 2

    Answer:

    • factory(window) after import factory = require('jquery/factory')
    • $.trim(String(count))

    Types check names, arguments and module shapes. They caught three of the cart's four old lines and the factory call, and nothing that only happens when the code runs.

  2. Question 2 of 2 The cart module now type-checks with no errors against @types/jquery 4.0.1. What can still be wrong on jQuery 4.0.0?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: Behaviour the types cannot see, such as a number passed to .css() that no longer gets px

    Pair the type check with tests on 4.0.0: types find removed names early, tests find changed results.

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.

  1. One test set-up for the jQuery 3 and jQuery 4 factories To do
  2. 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, mod is what require('jquery') returns in Node.js without a window: a function that takes the window and returns jQuery.
  • On jQuery 4, mod is what require('jquery/factory') returns: an object whose jQueryFactory(window) returns jQuery.
  • When globals is true, the window's jQuery and $ 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 globals is false or 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.

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 including node_modules/jquery, the last time it appears, followed by /. node_modules/jquery/dist/jquery.js and node_modules/bootstrap-datepicker/node_modules/jquery/dist/jquery.js are 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 as jquery-ui or jquery-migrate, are not jQuery.
  • Keys without node_modules, your own src/ 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.

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.

JSON Formatter Paste the metafile of your own bundle and read its inputs: every node_modules/jquery/ folder in it is a copy.

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.

  1. Question 1 of 15 Which file does import 'jquery' give Node.js?

    Choose one answer.

    Show the answer: Question 1 of 15

    Answer: A small wrapper that imports dist/jquery.js and adds the named exports $ and jQuery

    import and require in Node.js share dist/jquery.js through that wrapper, so both module systems get the same jQuery.

  2. Question 2 of 15 A CommonJS plugin calls require('jquery') inside a bundle whose own code does import { $ } from 'jquery', with one copy installed. How many jQuery objects does the page get?

    Choose one answer.

    Show the answer: Question 2 of 15

    Answer: One, because the bundler's require gets a wrapper that requires the same ES module

    With one installed copy, the map gives every caller in a bundle the same module. Two copies come only from two installed packages.

  3. Question 3 of 15 A shop without a build step wants jQuery 4 as a module in its pages, for Chrome 89, Firefox 108, Safari 16.4 and later. Which set-up works?

    Choose one answer.

    Show the answer: Question 3 of 15

    Answer: An import map that maps "jquery" to the jQuery 4 module file, and module scripts that import { $ } from 'jquery'

    The import map is the browser's own way of resolving a bare name. Classic plugins on the same page still need window.jQuery set by a module.

  4. Question 4 of 15 What does require('jquery') do in Node.js without a global window, on jQuery 4.0.0?

    Choose one answer.

    Show the answer: Question 4 of 15

    Answer: It throws Error: jQuery requires a window with a document

    Use jquery/factory in a set-up without a global window, or put the jsdom window on globalThis first.

  5. Question 5 of 15 Which of these gives a jQuery 4 test set-up a working jQuery without the factory?

    Choose one answer.

    Show the answer: Question 5 of 15

    Answer: Putting the jsdom window on globalThis before it imports or requires jquery

    Either give jQuery a global window before it starts, or pass the window to jQueryFactory().

  6. Question 6 of 15 A set-up must load classic plugins into a jsdom window on both 3.7.1 and 4.0.0. Which difference must it handle?

    Choose one answer.

    Show the answer: Question 6 of 15

    Answer: jQuery 3's require('jquery')(window) sets window.jQuery, and jQuery 4's jQueryFactory(window) does not

    Set window.jQuery = window.$ = $ yourself after the factory, and the set-up behaves the same on both releases.

  7. Question 7 of 15 How does a classic jQuery plugin find jQuery?

    Choose one answer.

    Show the answer: Question 7 of 15

    Answer: It reads the global jQuery, which the script elements of a page share

    Anything that loads a classic plugin, a page, a bundle or a test, must give it window.jQuery first.

  8. Question 8 of 15 Why does window.jQuery = $ on line 2 of an entry module not help a plugin imported on line 3?

    Choose one answer.

    Show the answer: Question 8 of 15

    Answer: All the module's imports run before its body, so the plugin runs before line 2

    Order of evaluation, not order of lines: an import is a dependency that runs first.

  9. Question 9 of 15 A bundle uses esbuild's inject option for jQuery, and the bundled plugins work. An inline script in the page's HTML still calls jQuery(…) and fails. Why?

    Choose one answer.

    Show the answer: Question 9 of 15

    Answer: inject only rewrites the name inside the bundle; the inline script needs window.jQuery, set by a module of your own

    Keep expose.js even with inject when the page still has inline scripts or other script elements that use jQuery.

  10. Question 10 of 15 Which command lists every installed copy of jquery and the package that brought each one?

    Choose one answer.

    Show the answer: Question 10 of 15

    Answer: npm ls jquery --all

    Run it after every install that touches a plugin, or read the bundle's metafile, which shows what was really bundled.

  11. Question 11 of 15 The bundle with the plugin's own copy was 538,251 bytes, and 281,444 bytes with one copy. About how many kilobytes (1 KB = 1,000 bytes) did the second copy add?

    Type a number.

    A hint

    Subtract the two sizes and divide by 1,000.

    Show the answer: Question 11 of 15

    Answer: 257 KB (anything from 255 to 259 counts)

    538,251 − 281,444 = 256,807 bytes, about 257 KB before compression, for a jQuery that no code of yours uses.

  12. Question 12 of 15 A plugin with its own jquery 3.7.1 copy has no release that accepts 4.x, and the page needs it this week. Which plan is sound?

    Choose one answer.

    Show the answer: Question 12 of 15

    Answer: Override its jquery to the project's 4.0.0, test its pages on 4.0.0 with Migrate's development build, and plan its replacement

    One installed copy, a test run on the release you ship, and a replacement on the plan: that turns a plugin's range into a managed risk.

  13. Question 13 of 15 Where do jQuery 4's TypeScript types come from?

    Choose one answer.

    Show the answer: Question 13 of 15

    Answer: The @types/jquery package, 4.0.1 for jQuery 4

    Install @types/jquery beside jquery, and keep their major versions together.

  14. Question 14 of 15 Why does TypeScript reject factory(window) after import factory = require('jquery/factory')?

    Choose one answer.

    Show the answer: Question 14 of 15

    Answer: The module's type is an object with jQueryFactory, so it has no call signature

    Write import { jQueryFactory } from 'jquery/factory', which compiles to the same require and type-checks.

  15. Question 15 of 15 A team wants a clean TypeScript run to be the only check of its jQuery 4 upgrade. What do you add, and why?

    Choose one answer.

    Show the answer: Question 15 of 15

    Answer: Tests on 4.0.0, because types cannot see behaviour such as numbers passed to .css() or the order of focus events

    Types and tests find different things: types the removed names before anything runs, tests the changed results.

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.

  1. 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.

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  2. 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.

    Model answer, Pro. First step and follow-ups

Key takeaways

  • jQuery 4’s exports map gives Node.js require the UMD build, Node.js import a wrapper of it, bundlers the ES module, and refuses deep paths such as jquery/dist/jquery.min.js with ERR_PACKAGE_PATH_NOT_EXPORTED.
  • In Node.js without a global window, use jQueryFactory(window) from jquery/factory, and set window.jQuery yourself when classic plugins run in that window.
  • The module build sets no global and imports run first, so window.jQuery gets 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 ls or the metafile, and settle it with a plugin release that accepts 4.x or a tested override.
  • @types/jquery 4.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

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.