jQuery 4 (legacy code and migration to DOM APIs) Module 7 – Migrating to modern DOM APIs
Removing jQuery gradually: a strangler plan
Plan a jQuery removal module by module: a call count per module that CI lets only fall, an order of work, rules for shared pages and reviews of every autofix.
What you will learn
- Plan a jQuery removal module by module with a call count and a CI gate that lets the count only fall
- Order the work so that each step is small, tested and shipped on its own
- Run old jQuery code and new DOM code together on one page during the migration without conflicts
- Review automated rewrites, and catch the ones that change behaviour before they ship
- Remove jQuery Migrate and then jQuery only when nothing calls them, including server templates
Before you start
What you’ll learn · Before you start
What you will learn
- Plan a jQuery removal module by module with a call count and a CI gate that lets the count only fall
- Order the work so that each step is small, tested and shipped on its own
- Run old jQuery code and new DOM code together on one page during the migration without conflicts
- Review automated rewrites, and catch the ones that change behaviour before they ship
- Remove jQuery Migrate and then jQuery only when nothing calls them, including server templates
Before you start
On this page
A shop’s three modules made 45 jQuery calls when their migration began, and 9 five commits later, while the shop kept selling the whole time. One commit in the middle pushed the count up again, from 24 to 34, because a new feature was written with jQuery; a gate in CI would have refused it.
Removing jQuery from a live codebase is rarely one rewrite. It is a series of small rounds, each of which leaves the pages working. You will measure the count per module, build the gate that lets it only fall, order the work, keep old and new code safe on one page, review every automated rewrite, and finish with the last file.
A strangler migration swaps one part at a time while the page keeps working
Martin Fowler named this kind of change, a strangler migration: Replacing a system piece by piece while the old one keeps running, named after the strangler fig that grows around its host tree. Each piece is moved, compared with the old one and shipped before the next one starts., after the strangler fig, which germinates on a host tree, grows until it roots in the ground and in time no longer needs its host: new code grows around the old one, piece by piece, and the business gets value from each piece long before the old code is gone (Fowler). For jQuery, a piece is a module: one script, its handlers and its requests. Each round of the migration takes one module through the same loop:
The drawing shows the whole scene; its 7 steps are listed below.
Every step as text
- Count. ESLint's all config counts every jQuery call, file by file. The count is the size of what is left, and the number the team watches.
- Choose a module. Pick one module, not a kind of call across the codebase. A module that is small and well covered by tests makes a safe first round.
- Pin its behaviour. Before any change, write checks of what the module does on its page, against the jQuery code as it is today. They are the definition of done.
- Rewrite. Replace the module's jQuery calls with DOM code, keeping the same seams, so the rest of the page sees no change.
- Compare. Run the checks against the new code on the same page, with the same steps. A result that differs sends the module back to the rewrite.
- Lower the ceiling. When the checks agree, the module's count falls, and the gate records the new count as the most it may ever hold again.
- The next round. The page has worked at every step, and each round shipped on its own. The next module starts the same loop.
The loop’s checks come before the rewrite, written against the jQuery code as it is: they define what “the same” means for that module. A module whose new code fails them goes back, and nothing ships until they agree.
GitHub described its own removal this way in 2018: a “gradual, years-long transition”, watched through a metric that
“tracked ratio of jQuery calls used per overall line of code”, with a lint plugin that “would fail CI checks if anyone
tried to use jQuery features”, and the existing calls “annotated with specific eslint-disable rules in code comments”
(GitHub Blog). As each
kind of call disappeared, the matching part of its custom jQuery build went too: the selector engine once the last
jQuery-only selector was gone, and the Ajax module after the last $.ajax call became fetch().
Not affiliated
Based on public information about GitHub. MySmartCoPilot is not affiliated with GitHub, and GitHub’s real engineering history may differ from what this lesson describes.
Key idea
Migrate module by module: pin what a module does, rewrite it, compare the old and new on the same page, and ship it before you start the next one.
Checkpoint
Check: One module per round, the page working throughout
The count falls module by module, and the gate keeps it falling
A migration needs one number that only goes down. ESLint’s eslint-plugin-no-jquery gives it: its all config
reports every jQuery method and utility a file calls, and ESLint’s json formatter “Outputs JSON-serialized results”
(ESLint) that a script can add up per module. The shop’s repository
has three modules, and this is its count after each of five commits:
repo/commit-1/cart.js
// cart.js: the cart drawer.
jQuery(function ($) {
var $drawer = $('#cart-drawer');
$('#open-cart').on('click', function () {
$drawer.addClass('open').attr('aria-hidden', 'false');
});
$drawer.on('click', '.remove', function () {
var $row = $(this).closest('li');
$.post('/api/cart/remove', { sku: $row.data('sku') }).done(function (cart) {
$row.remove();
$('#cart-count').text(cart.count);
});
});
$.each($drawer.find('.price'), function (i, price) {
$(price).text($.trim($(price).text()));
});
}); repo/commit-1/search.js
// search.js: suggestions under the search box.
jQuery(function ($) {
var $results = $('#results');
$('#q').on('input', function () {
var words = $.trim($(this).val());
if (!words) {
$results.empty();
return;
}
$.getJSON('/api/search', { q: words }, function (items) {
$results.empty().append($.map(items, function (item) {
return $('<li>').text(item.name);
}));
});
});
}); repo/commit-1/reviews.js
// reviews.js: long reviews open in place.
jQuery(function ($) {
$('.review .more').on('click', function () {
$(this).closest('.review').find('.full').slideDown(200);
$(this).hide();
});
}); lint/eslint.all.mjs
// eslint.all.mjs: the plugin's "all" config, which reports every jQuery method and utility a file calls. The modules
// mix classic jQuery scripts and ES modules, so the files are read as modules, which both kinds parse as.
import noJquery from 'eslint-plugin-no-jquery';
export default [
{
files: ['**/*.js'],
languageOptions: { ecmaVersion: 'latest', sourceType: 'module' },
plugins: { 'no-jquery': noJquery },
rules: noJquery.configs.all.rules,
},
]; // dashboard.mjs: the migration's metric across the five commits of repo/, as ESLint's JSON formatter reported it with
// the "all" config (recorded.mjs): jQuery calls per module, the total, and calls per 100 lines of code.
import { recording, unchanged } from './show.mjs';
const { tools, files, commits } = recording;
unchanged(files);
console.log(`ESLint ${tools.eslint}, eslint-plugin-no-jquery ${tools.plugin}, the "all" config`);
const modules = Object.keys(commits[0].modules);
console.log(`\n${'module'.padEnd(12)}${commits.map((c, i) => `#${i + 1}`.padStart(6)).join('')}`);
for (const m of modules) console.log(`${m.padEnd(12)}${commits.map((c) => String(c.modules[m].calls).padStart(6)).join('')}`);
const total = (c) => Object.values(c.modules).reduce((sum, x) => sum + x.calls, 0);
const lines = (c) => Object.values(c.modules).reduce((sum, x) => sum + x.lines, 0);
console.log(`${'all'.padEnd(12)}${commits.map((c) => String(total(c)).padStart(6)).join('')}`);
console.log(`${'per 100 ln'.padEnd(12)}${commits.map((c) => ((100 * total(c)) / lines(c)).toFixed(0).padStart(6)).join('')}`);
console.log('');
commits.forEach((c, i) => console.log(`#${i + 1} ${c.did}`)); Output
ESLint 10.13.0, eslint-plugin-no-jquery 6.0.1, the "all" config module #1 #2 #3 #4 #5 cart.js 22 15 15 15 0 reviews.js 9 9 9 19 9 search.js 14 10 0 0 0 all 45 34 24 34 9 per 100 ln 113 85 67 77 20 #1 the start #2 utilities moved #3 search.js moved #4 a vote added with jQuery #5 cart.js and the vote moved
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node dashboard.mjs
The count fell from 45 to 34 when the utilities moved, and to 24 when search.js moved entirely. Then commit 4 added
a “helpful” vote to reviews.js, written with jQuery, and its count rose from 9 to 19. The line “per 100 ln” is GitHub’s
kind of metric, calls per hundred lines of code, and it rose with it. Commit 5 moved the cart and rewrote the vote, and
the shop ended at 9 calls in one module.
A dashboard shows a rise after it happens; a CI gate: A check that the continuous integration server runs on every proposed change and that fails the change when a rule is broken, so it cannot be merged, such as a count of jQuery calls that may not rise. stops it from merging. The gate below keeps a baseline per module, fails a commit in which any module rose above it, and takes the new counts as the baseline when nothing rose. Press Edit and add a module, or raise a count, and run it again:
// gate.mjs: the CI gate of the migration, run on the five commits that dashboard.mjs counted. The count of each module
// may only fall: a commit passes when no module rose above its baseline, and its counts then become the new baseline.
// Press Edit and change a count, or add a module, to see what the gate does with it.
const COMMITS = [
{ did: 'the start', calls: { 'cart.js': 22, 'reviews.js': 9, 'search.js': 14 } },
{ did: 'utilities moved', calls: { 'cart.js': 15, 'reviews.js': 9, 'search.js': 10 } },
{ did: 'search.js moved', calls: { 'cart.js': 15, 'reviews.js': 9, 'search.js': 0 } },
{ did: 'a vote added with jQuery', calls: { 'cart.js': 15, 'reviews.js': 19, 'search.js': 0 } },
{ did: 'cart.js and the vote moved', calls: { 'cart.js': 0, 'reviews.js': 9, 'search.js': 0 } },
];
/** Compares a commit's counts with the baseline. A module that is new to the baseline may hold no jQuery at all. */
function gate(baseline, calls) {
const rises = Object.entries(calls)
.filter(([module, count]) => count > (baseline[module] ?? 0))
.map(([module, count]) => `${module} rose from ${baseline[module] ?? 0} to ${count}`);
return { ok: rises.length === 0, rises, baseline: rises.length ? baseline : { ...calls } };
}
let baseline = COMMITS[0].calls;
console.log(`#1 baseline: ${Object.entries(baseline).map(([m, c]) => `${m} ${c}`).join(', ')}`);
COMMITS.slice(1).forEach((commit, i) => {
const result = gate(baseline, commit.calls);
console.log(`#${i + 2} ${result.ok ? 'pass' : 'FAIL'} ${commit.did}${result.ok ? '' : `: ${result.rises.join('; ')}`}`);
baseline = result.baseline;
});
console.log(`final baseline: ${Object.entries(baseline).map(([m, c]) => `${m} ${c}`).join(', ')}`); Output
#1 baseline: cart.js 22, reviews.js 9, search.js 14 #2 pass utilities moved #3 pass search.js moved #4 FAIL a vote added with jQuery: reviews.js rose from 9 to 19 #5 pass cart.js and the vote moved final baseline: cart.js 0, reviews.js 9, search.js 0
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node gate.mjs
Runs on this device, in your browser. The first run downloads JavaScript (about 0.6 MB), which is kept for the next runs.
Your run, in this browser
Commit 4 failed with “reviews.js rose from 9 to 19”, so with the gate in CI the vote would have been written without
jQuery from the start. A module that is new to the baseline counts from 0, so new files cannot hold any jQuery at all.
GitHub’s per-line eslint-disable comments achieve the same with lint alone: every old call carries a marker, and a
call without one fails.
Checkpoint
Check: A count per module that may only fall
An order of work that keeps each step small
A sensible order starts where calls have exact native counterparts and nothing else depends on them, and ends with the work that needs the most testing. Each phase below is a set of rounds of the loop, module by module:
- 1 Utilities
$.each, $.map, $.extend, $.trim no page involved
Utilities touch no page at all, and each has a native counterpart with a rule of its own, such as forEach's argument order. They are the cheapest calls to move and to test.
- 2 Selecting and DOM
Selectors, traversal, content, classes
$() becomes querySelector() and querySelectorAll() with :scope, and .text(), .attr() and .addClass() their DOM properties and loops. Each change stays inside one module.
- 3 Events
Handlers, delegation, triggered events
Events cross module lines, so this phase follows the rules of coexistence: one owner per element, a binding moved with its removal, and announcements as bubbling events.
- 4 Ajax
$.ajax() and its shorthands
Every request moves to one fetch() wrapper that keeps what the server expects, and code that waited on a jqXHR or a $.Deferred() gets a native Promise instead.
- 5 Effects
fadeIn, slideDown, animate slim build possible
Effects become CSS transitions or el.animate(), with the reader's choice of reduced motion respected, and each animated widget is checked on screen. A page on which nothing calls Ajax, effects, Deferreds or queues any more, jQuery UI included, can then load jQuery's slim build, 19,461 bytes with gzip against 27,530.
- 6 Plugins and widgets
jQuery UI and third-party plugins
The largest step, one widget at a time, each replaced by native HTML or a small script with its keyboard and ARIA behaviour tested. This phase usually takes most of the hours.
- 7 The last file
Migrate first, then jQuery watch the errors
When ESLint counts zero calls and the templates hold none, remove jQuery Migrate, ship and watch the error reports; then remove jQuery itself the same way, with both files kept ready to put back.
The timeline has 7 events, from 1 Utilities to 7 The last file.
The order matters for two reasons. The early phases are cheap and teach the team the native rules, forEach()’s
argument order or :scope, on code where a mistake shows quickly. And the later phases depend on the earlier ones:
events need the rules of the next section, Ajax needs one wrapper, and widgets need everything else in place. The
later phases also pay off along the way. Each widget replaced takes its share of jQuery UI’s weight with it. And once
nothing on a page calls Ajax, effects, Deferreds or queues any more, the page can load jQuery’s slim build, which in
jQuery 4 leaves all of them out (upgrade guide). jQuery UI’s widgets show
and hide through those queues (widget.js), so a page
keeps the full build until its last widget is gone.
Write every new feature without jQuery from the first day, whatever phase the codebase is in, as the gate demands. This track’s lesson on translations gives the rule for each kind of call, phase by phase.
Checkpoint
Check: The order of the phases
Old and new code share a page by three rules
For months, most pages run old jQuery code next to new DOM code, and the bugs of a migration live where the two touch. Three rules prevent most of them:
- One owner per element and event. Either a jQuery handler or a native listener handles a given event on a given element, never both, and the change that adds the new one deletes the old one.
- One owner per stored value. Never write a key with
datasetthat other code reads with.data(): jQuery reads data-* attributes “the first time the data() method is invoked upon it, and then are no longer accessed or mutated” (.data()). - Announce with real events. Publish every announcement as a bubbling
CustomEvent, which jQuery handlers and native listeners both hear;.trigger()reaches jQuery handlers only.
Rule 2 is the quietest. The shop’s cart moved to native code and writes its count with dataset, while the old badge
code still reads it with .data():
The drawing shows the whole scene; its 4 steps are listed below.
Every step as text
- The badge reads once. When the page starts, the old badge code calls .data('count'). jQuery reads the attribute, turns "2" into 2, and keeps 2 in its own data store.
- The new code writes. A click on Add runs the new cart code, which writes dataset.count = "3". The attribute changes; jQuery's stored value does not.
- The badge reads again. The cart's event makes the badge read .data('count') again. jQuery answers from its store, so the badge still shows 2.
- One owner. Moving the badge to dataset in the same change gives the count one owner. The fixed page showed 3, then 4.
coexist/old-badge.js
// old-badge.js: the old badge code, still jQuery. It reads the count with .data() when the page starts and again on
// every change.
jQuery(function ($) {
var $cart = $('#cart');
$('#badge').text($cart.data('count'));
$(document).on('cart:changed', function () {
$('#badge').text($cart.data('count'));
console.log('the badge, read with .data(), shows ' + $('#badge').text());
});
}); coexist/new-cart.js
// new-cart.js: the new cart code, without jQuery. It writes the count with dataset and announces the change.
const cart = document.querySelector('#cart');
document.querySelector('#add').addEventListener('click', () => {
cart.dataset.count = String(Number(cart.dataset.count) + 1);
console.log(`the cart, written with dataset, holds ${cart.dataset.count}`);
cart.dispatchEvent(new CustomEvent('cart:changed', { bubbles: true }));
}); coexist/fixed-badge.js
// fixed-badge.js: the badge moved in the same change as the cart: dataset is the one owner of the count now.
const badge = document.querySelector('#badge');
const cartEl = document.querySelector('#cart');
badge.textContent = cartEl.dataset.count;
document.addEventListener('cart:changed', () => {
badge.textContent = cartEl.dataset.count;
console.log(`the badge, read with dataset, shows ${badge.textContent}`);
}); coexist/badge.html
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Cart badge: two owners of one count</title>
</head>
<body>
<div id="cart" data-count="2">Cart <span id="badge"></span> <button type="button" id="add">Add a kettle</button></div>
<script src="https://code.jquery.com/jquery-4.0.0.min.js" integrity="sha256-OaVG6prZf4v69dPg6PhVattBXkcOWQB62pdZ3ORyrao=" crossorigin="anonymous"></script>
<script src="old-badge.js"></script>
<script src="new-cart.js"></script>
</body>
</html> // coexist.mjs: the cart badge with two owners of one count, then with one, in jsdom on jQuery 4.0.0 (recorded.mjs).
import { showPage } from './show.mjs';
console.log('Old badge with .data(), new cart with dataset:');
showPage('coexist/badge.html', ' ');
console.log('\nBoth with dataset, moved in one change:');
showPage('coexist/badge-fixed.html', ' '); Output
Old badge with .data(), new cart with dataset: ▸ click Add a kettle the cart, written with dataset, holds 3 the badge, read with .data(), shows 2 ▸ click Add a kettle again the cart, written with dataset, holds 4 the badge, read with .data(), shows 2 Both with dataset, moved in one change: ▸ click Add a kettle the cart, written with dataset, holds 3 the badge, read with dataset, shows 3 ▸ click Add a kettle again the cart, written with dataset, holds 4 the badge, read with dataset, shows 4
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node coexist.mjs
The cart held 3, then 4, and the badge showed 2 both times, with no error anywhere. Moved to dataset in the same
change, it showed 3 and 4. Rules 1 and 3 have a lesson of their own, with the handler counts and the event paths:
running jQuery and native event code side by side.
Checkpoint
Check: One owner per element, event and value
Every autofix is a behaviour change until a test says otherwise
An autofix: A change a linter makes to the source by itself, when it runs with --fix, for a rule that knows how to rewrite what it reports. It is only as right as the rule, so every rewritten line still needs a reading and a test. More in “APIs removed in jQuery 4 and their replacements” rewrites code by a pattern, and a pattern does not know what the code meant. The
help panel below ran eslint --fix with four of the plugin’s fixable rules, the kind a team switches on to clear old
APIs before a migration, and its behaviour was checked before and after on jQuery 3.7.1:
autofix/help.js
// help.js: the help panel of the account page, as it was before eslint --fix. It runs on jQuery 3.7.1.
var tour = {
step: 0,
next: function () {
this.step += 1;
$('#tour-step').text('Step ' + this.step);
}
};
$(document).ready(function () {
$('#help').hover(
function () { $('#tip').addClass('open'); },
function () { $('#tip').removeClass('open'); }
);
$('#close-tip').click(function () {
$('#tip').removeClass('open');
});
var saved = $.parseJSON($('#help').attr('data-saved'));
$('#seen').text(saved.seen.length + ' tips seen');
$('#tour-next').on('click', $.proxy(tour.next, tour));
$('#tour-stop').on('click', function () {
$('#tour-next').off('click', tour.next);
$('#tour-step').text('Tour stopped');
});
}); lint/eslint.fix.mjs
// eslint.fix.mjs: four of the plugin's rules that can rewrite what they report, as a team would switch them on for a
// first round of eslint --fix: ready() and event shorthands, $.parseJSON() and $.proxy().
import noJquery from 'eslint-plugin-no-jquery';
export default [
{
files: ['**/*.js'],
languageOptions: { ecmaVersion: 2015, sourceType: 'script', globals: { $: 'readonly', jQuery: 'readonly' } },
plugins: { 'no-jquery': noJquery },
rules: {
'no-jquery/no-ready-shorthand': 'error',
'no-jquery/no-event-shorthand': 'error',
'no-jquery/no-parse-json': 'error',
'no-jquery/no-proxy': 'error',
},
},
]; autofix/help.fixed.js
// help.fixed.js: the help panel of the account page, as eslint --fix left it. It runs on jQuery 3.7.1.
var tour = {
step: 0,
next: function () {
this.step += 1;
$('#tour-step').text('Step ' + this.step);
}
};
$(function () {
$('#help').on(
"hover", function () { $('#tip').addClass('open'); },
function () { $('#tip').removeClass('open'); }
);
$('#close-tip').on("click", function () {
$('#tip').removeClass('open');
});
var saved = JSON.parse($('#help').attr('data-saved'));
$('#seen').text(saved.seen.length + ' tips seen');
$('#tour-next').on('click', tour.next.bind(tour));
$('#tour-stop').on('click', function () {
$('#tour-next').off('click', tour.next);
$('#tour-step').text('Tour stopped');
});
}); autofix/help.html
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Account: help panel</title>
</head>
<body>
<button type="button" id="help" data-saved='{"seen":["export","alerts"]}'>Help</button>
<div id="tip" role="tooltip">Export your orders as a spreadsheet. <button type="button" id="close-tip">Close</button></div>
<p id="seen"></p>
<p><span id="tour-step">Tour not started</span> <button type="button" id="tour-next">Next</button> <button type="button" id="tour-stop">Stop the tour</button></p>
<script src="https://code.jquery.com/jquery-3.7.1.min.js" integrity="sha256-/JqT3SQfawRcv/BIHPThkBvs0OEvtFFmqPF/lYI/Cxo=" crossorigin="anonymous"></script>
<script src="help.js"></script>
</body>
</html> // autofix.mjs: what eslint --fix with lint/eslint.fix.mjs changed in autofix/help.js, and what the help panel did on
// jQuery 3.7.1 before and after the fix, in jsdom (recorded.mjs).
import { recording, showPage, unchanged } from './show.mjs';
const { files, fix } = recording;
unchanged(files);
console.log(`eslint --fix changed ${fix.changes.length} lines:`);
for (const c of fix.changes) console.log(` line ${c.line}\n before: ${c.before}\n after: ${c.after}`);
console.log(`and then reported ${fix.report || 'nothing'}`);
console.log('\nThe panel with help.js, as written:');
showPage('autofix/help.html', ' ');
console.log('\nThe panel with help.fixed.js, after the fix:');
showPage('autofix/help-fixed.html', ' '); Output
eslint --fix changed 6 lines:
line 10
before: $(document).ready(function () {
after: $(function () {
line 11
before: $('#help').hover(
after: $('#help').on(
line 12
before: function () { $('#tip').addClass('open'); },
after: "hover", function () { $('#tip').addClass('open'); },
line 15
before: $('#close-tip').click(function () {
after: $('#close-tip').on("click", function () {
line 18
before: var saved = $.parseJSON($('#help').attr('data-saved'));
after: var saved = JSON.parse($('#help').attr('data-saved'));
line 20
before: $('#tour-next').on('click', $.proxy(tour.next, tour));
after: $('#tour-next').on('click', tour.next.bind(tour));
and then reported nothing
The panel with help.js, as written:
▸ read the panel
tip closed, 2 tips seen, tour: Tour not started
▸ move the pointer onto Help
▸ read the panel
tip open, 2 tips seen, tour: Tour not started
▸ move the pointer away
▸ open the tip from a script
▸ read the panel
tip open, 2 tips seen, tour: Tour not started
▸ click Close in the tip
▸ read the panel
tip closed, 2 tips seen, tour: Tour not started
▸ click Next in the tour
▸ click Stop the tour
▸ click Next again
▸ read the panel
tip closed, 2 tips seen, tour: Tour stopped
The panel with help.fixed.js, after the fix:
▸ read the panel
tip closed, 2 tips seen, tour: Tour not started
▸ move the pointer onto Help
▸ read the panel
tip closed, 2 tips seen, tour: Tour not started
▸ move the pointer away
▸ open the tip from a script
▸ read the panel
tip open, 2 tips seen, tour: Tour not started
▸ click Close in the tip
▸ read the panel
tip closed, 2 tips seen, tour: Tour not started
▸ click Next in the tour
▸ click Stop the tour
▸ click Next again
▸ read the panel
tip closed, 2 tips seen, tour: Step 2
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node autofix.mjs
Five rewrites, and ESLint reported nothing afterwards. Three kept the behaviour: $(function), .on("click") and
JSON.parse(), so the fixed panel still read its two saved tips and its Close button still closed the tip. Two did
not. .on("hover", over, out) binds an event called hover, which no browser sends: since
jQuery 1.9, “hover” is “no longer supported as a synonym” for mouseenter and mouseleave
(1.9 upgrade guide), so the tip never opened. The right rewrite is the one the
.hover() page gives, .on("mouseenter", over).on("mouseleave", out). And tour.next.bind(tour) lost a guarantee of $.proxy(): jQuery “will still unbind the
correct function if passed the original” (jQuery.proxy()), so
.off('click', tour.next) used to stop the tour, and after the fix the tour went on to step 2.
Merging an autofix because the linter is quiet
Wrong: eslint --fix over the codebase in one commit, merged because ESLint reports nothing. Right: one commit per
rule, each followed by the pages’ checks; the hover and proxy rewrites above passed the linter and failed the checks.
The same review applies to any codemod that rewrites jQuery as DOM code: run the old and the new version on the same page, as the translation suite does, before trusting a pattern.
Checkpoint
Check: Reviewing an autofix by its behaviour
The last call goes, then Migrate, then jQuery
When the gate shows 0 for every module, jQuery may still be in use, because ESLint counted only the files it read. Server templates hold inline handlers and inline scripts that no JavaScript linter sees. A search of the shop’s templates finds them:
templates/checkout.php
<?php /* checkout.php: the checkout template. The page's own scripts no longer use jQuery. */ ?>
<form id="checkout" method="post" action="/checkout">
<label><input type="checkbox" id="terms" name="terms"> I accept the terms</label>
<button type="button" onclick="jQuery('#terms-text').slideToggle()">Read the terms</button>
<div id="terms-text" hidden><?= htmlspecialchars($terms) ?></div>
<button type="submit">Pay</button>
</form>
<script>
$('#checkout').on('submit', function () { $(this).find('[type=submit]').prop('disabled', true); });
</script> templates/account.php
<?php /* account.php: the account template. */ ?>
<section id="account">
<h1>Your account</h1>
<a href="/orders" class="orders" onclick="$('#spinner').show()">Your orders</a>
<div id="spinner" hidden>Loading…</div>
</section> templates/footer.php
<?php /* footer.php: the footer template, with no script of its own. */ ?>
<footer>
<p>Questions? Write to the shop's support team.</p>
</footer> // scan.mjs: the jQuery calls that ESLint never sees, because they live in server templates: inline event handlers and
// inline scripts. It reads every file of templates/ and prints each line that calls jQuery or $.
import { readFileSync, readdirSync } from 'node:fs';
const CALL = /(?:\bjQuery|\$)(?:\(|\.[A-Za-z])/;
let found = 0;
for (const file of readdirSync(new URL('./templates/', import.meta.url)).sort()) {
const lines = readFileSync(new URL(`./templates/${file}`, import.meta.url), 'utf8').split('\n');
lines.forEach((line, i) => {
if (!CALL.test(line)) return;
found += 1;
console.log(`templates/${file}:${i + 1} ${line.trim()}`);
});
}
console.log(`\n${found} lines in templates still call jQuery.`); Output
templates/account.php:4 <a href="/orders" class="orders" onclick="$('#spinner').show()">Your orders</a>
templates/checkout.php:4 <button type="button" onclick="jQuery('#terms-text').slideToggle()">Read the terms</button>
templates/checkout.php:9 $('#checkout').on('submit', function () { $(this).find('[type=submit]').prop('disabled', true); });
3 lines in templates still call jQuery.
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node scan.mjs
Three lines in two templates still call jQuery. Without jQuery on the page, each would throw a ReferenceError, as
$ or jQuery would not be defined: the inline script as the checkout loads, the two handlers on their first click.
Move them like any other module, or out of the templates into script files, which a strict Content Security
Policy asks for anyway. Then finish in two releases, each watched through the site’s error reports for a full release
cycle:
- Remove jQuery Migrate: A plugin from the jQuery team, loaded right after jQuery, that brings back APIs a newer jQuery removed or changed and, in its development build, logs a warning each time code uses one, so the code can be fixed before the upgrade. More in “jQuery in 2026: versions, support and where it runs”, if the pages still load it, and ship.
- Remove jQuery itself, and ship.
Keep both files where the pages can load them again with one change, because an error report of a third-party widget or a forgotten page is the most likely thing to come back.
Checkpoint
Check: The last calls, then Migrate, then jQuery
Practise: count calls per module for the gate
The gate needs one number per module from ESLint’s JSON report. Write callsPerModule(); the sample tests run in your
browser with reports shaped like ESLint’s:
Exercise · Medium · JavaScript
Count jQuery calls per module from ESLint's JSON report
The migration's CI gate needs one number per module: how many jQuery calls it still makes. ESLint's JSON formatter gives an array with one result per file, { filePath, messages }, where each message has a ruleId such as 'no-jquery/no-ajax'. Write callsPerModule(results, root) in calls.mjs:
- a file's module is the first folder of its path below
root, so/app/src/cart/drawer.jswith the root/app/srcis incart; a file directly inrootis a module of its own, named after the file without.js; - it counts only messages whose
ruleIdstarts withno-jquery/; other rules, and messages without aruleId, do not count; - every module of
resultsappears in the answer, with 0 when it has no jQuery call left, so that the gate sees a module that has just reached zero; - it returns a plain object whose keys are in alphabetical order.
Starter code · calls.mjs
/**
* The jQuery calls each module still makes, from the results of ESLint's JSON formatter.
* @param {{ filePath: string, messages: { ruleId: string | null }[] }[]} results
* @param {string} root the folder whose first level holds the modules
* @returns {Record<string, number>}
*/
export function callsPerModule(results, root) {
const counts = {};
for (const result of results) {
const name = result.filePath.split('/').pop().replace('.js', '');
counts[name] = result.messages.length;
}
return counts;
} The sample tests · calls.test.mjs
import { test, assert } from 'mysmartcopilot:test';
import { callsPerModule } from './calls.mjs';
const message = (ruleId) => ({ ruleId, severity: 1, message: 'a finding', line: 1, column: 1 });
const result = (filePath, ...rules) => ({ filePath, messages: rules.map(message) });
test('files in one folder add up to one module', () => {
const results = [
result('/app/src/cart/drawer.js', 'no-jquery/no-ajax', 'no-jquery/no-class'),
result('/app/src/cart/badge.js', 'no-jquery/no-text'),
];
assert.deepEqual(callsPerModule(results, '/app/src'), { cart: 3 });
});
test('a file directly in the root is a module of its own', () => {
const results = [result('/app/src/search.js', 'no-jquery/no-jquery-constructor')];
assert.deepEqual(callsPerModule(results, '/app/src'), { search: 1 });
});
test('only no-jquery rules count', () => {
const results = [result('/app/src/reviews/vote.js', 'no-jquery/no-ajax', 'no-unused-vars', null)];
assert.deepEqual(callsPerModule(results, '/app/src'), { reviews: 1 });
});
test('a module with no jQuery call left is reported with 0', () => {
const results = [result('/app/src/search/suggest.js'), result('/app/src/cart/drawer.js', 'no-jquery/no-ajax')];
assert.deepEqual(callsPerModule(results, '/app/src'), { cart: 1, search: 0 });
});
test('the modules come in alphabetical order', () => {
const results = [result('/app/src/search.js'), result('/app/src/account/login.js'), result('/app/src/cart.js')];
assert.deepEqual(Object.keys(callsPerModule(results, '/app/src')), ['account', 'cart', 'search']);
});
test('a root given with a trailing slash works the same', () => {
const results = [result('/app/src/cart/drawer.js', 'no-jquery/no-ajax')];
assert.deepEqual(callsPerModule(results, '/app/src/'), { cart: 1 });
}); A hint
Cut root + '/' from the front of each filePath and split the rest at /: with more than one piece, the first is the module; with one, the file name without .js is. Create every module with 0 before you count its messages.
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 put the plan to work on new cases. The questions begin wherever your earlier answers were least sure, and every wrong choice says what it would cost the migration:
Practice that adapts to you
Practise this lesson
13 questions about 6 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
Interview questions
Warm-up (2–5 years): what is a strangler migration, and why use one to remove jQuery?
It replaces a system piece by piece while the old one keeps running, instead of rewriting it at once; Martin Fowler named it after the strangler fig, which grows around its host. For jQuery, each piece is a module: you pin what it does with checks on its page, rewrite it with DOM code, compare the two, and ship it before starting the next. The pages work after every round, each round can be reverted alone, and the business sees results along the way. A rewrite of everything at once has to reproduce every behaviour at the same time, which is where regressions hide. A count of jQuery calls per module, with a CI gate that lets it only fall, keeps the migration moving and stops new jQuery.
Two harder questions of this module start from this lesson: a plan for a large codebase, and a badge that stopped updating halfway through. Think through your own approach to each before you read its first step:
Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.
-
Senior to staff
Your team must remove jQuery from a 200-file front end over the next year while shipping features every week. Outline the plan, the metric and the CI rules you would put in place, and how you would know you are done.
-
Mid to senior
Halfway through a migration, the cart badge stopped updating: adding items changes the cart, the badge keeps its first number, and there is no error. The cart code was moved to native code last week. How do you find and fix it?
Key takeaways
- A strangler migration moves one module at a time through the same loop: pin its behaviour, rewrite it, compare old and new on the same page, ship it.
- Count jQuery calls per module with ESLint’s
allconfig and JSON formatter, and let a CI gate fail any commit in which a module’s count rises; a new module starts at zero. - Work from utilities to selectors, events, Ajax, effects and widgets, and write every new feature without jQuery.
- On shared pages, give each element, event and stored value one owner, and announce with bubbling
CustomEvents. - Treat every autofix as a behaviour change: in the help panel, two of five rewrites passed the linter and broke the page.
- Finish by searching templates for calls ESLint never read, then remove Migrate and jQuery in two watched releases.
Terms in this lesson
- CI gate
- A check that the continuous integration server runs on every proposed change and that fails the change when a rule is broken, so it cannot be merged, such as a count of jQuery calls that may not rise.
- strangler migration
- Replacing a system piece by piece while the old one keeps running, named after the strangler fig that grows around its host tree. Each piece is moved, compared with the old one and shipped before the next one starts.
References
- StranglerFigApplication (Martin Fowler)
- Removing jQuery from GitHub.com frontend (GitHub Blog)
- eslint-plugin-no-jquery (Wikimedia Foundation)
- ESLint: formatters (OpenJS Foundation (ESLint))
- .hover() (OpenJS Foundation (jQuery API documentation))
- jQuery 1.9 Upgrade Guide: the "hover" pseudo-event (OpenJS Foundation (jQuery))
- jQuery.proxy() (OpenJS Foundation (jQuery API documentation))
- .data() (OpenJS Foundation (jQuery API documentation))
- jQuery Core 4.0 Upgrade Guide (OpenJS Foundation (jQuery))
- jQuery UI 1.14.2 source: ui/widget.js (OpenJS Foundation (jQuery UI))
- jsdom (jsdom project)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress