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

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

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:

Interactive · stage by stage One round of a gradual jQuery removal Step through one module's round, from the count to a lower ceiling, and see where a failed comparison sends it back.
One round of a gradual jQuery removal, step 7 of 7. Showing Count the calls, Choose one module, Pin what it does, Rewrite it, Compare old and new, Lower the ceiling. In focus: again.a result differsnext moduleCount the callsper moduleChoose one modulesmall and well tested firstPin what it doestests that run on the pageRewrite itDOM code, same seamsCompare old and newsame page, same stepsLower the ceilingthe gate's new baseline

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

Every step as text
  1. 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.
  2. 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.
  3. 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.
  4. Rewrite. Replace the module's jQuery calls with DOM code, keeping the same seams, so the rest of the page sees no change.
  5. 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.
  6. 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.
  7. 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

  1. Question 1 of 2 In one round of the loop, what comes before the rewrite of a module?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: Checks of what the module does today, written against its jQuery code

    Count, choose a module, pin its behaviour, rewrite, compare on the same page, lower the ceiling, ship.

  2. Question 2 of 2 Why is a removal of jQuery from every module in one release riskier than a strangler migration?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: It must reproduce every behaviour at once, and a failure cannot be reverted without reverting all of it

    Fowler's point: the value and the knowledge arrive piece by piece, and so do the mistakes, while they are small.

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:

Three of the shop's modules at the first commit, and the ESLint configuration

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,
  },
];
jQuery calls per module across five commits JavaScript · dashboard.mjs
// 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:

The CI gate, run on the five commits JavaScript · gate/gate.mjs
// 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

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.

JSON Diff & Compare Compare ESLint's JSON report of two commits to see which files gained or lost calls.

Checkpoint

Check: A count per module that may only fall

  1. Question 1 of 2 Which module made the gate fail commit 4, and why?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: reviews.js, whose count rose from 9 to 19 when the vote was added with jQuery

    With the gate in CI, the vote would have been written without jQuery before it could merge.

  2. Question 2 of 2 A new module, wishlist.js, arrives with 3 jQuery calls. What does the gate do?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: It fails the commit, because a module new to the baseline counts from 0

    New code starts without jQuery: that rule alone stops the count from growing.

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:

Timeline · 7 events in order An order of work that keeps each step small Walk through the phases in order. Each one leaves the page working, and the later ones depend on the earlier.
  1. 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. 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. 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. 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. 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. 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. 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

  1. One question Why do the utilities, $.each, $.map, $.extend and $.trim, move first?

    Choose one answer.

    Show the answer: One question

    Answer: They touch no page, and each has a native counterpart with a rule that a mistake shows quickly

    Cheap and independent first, then the work that needs more testing: selections, events, Ajax, effects, widgets.

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:

  1. 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.
  2. One owner per stored value. Never write a key with dataset that 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()).
  3. 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():

Interactive · message by message One count, two owners, and a badge that never moves Step through two clicks on Add with the old badge code and the new cart code on one page.
One count, two owners, and a badge that never moves, step 4 of 4. Messages so far: Old badge code to div#cart: .data('count'); New cart code to div#cart: dataset.count = 3; div#cart to Old badge code: still 2.Old badge codedataset: 3, then 4div#cartdata-count="3"New cart codedataset.data('count')dataset.count = 3still 2

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

Every step as text
  1. 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.
  2. 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.
  3. 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.
  4. One owner. Moving the badge to dataset in the same change gives the count one owner. The fixed page showed 3, then 4.

The cart badge with two owners of one count

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>
What the badge showed, with two owners and with one JavaScript · coexist.mjs
// 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

  1. Question 1 of 2 After the new cart code wrote dataset.count = "3", what did the old badge code's .data('count') return?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: 2, the value jQuery read and kept when the page started

    Give each stored value one owner, and move its readers and writers in the same change.

  2. Question 2 of 2 An old module announces cart:changed with .trigger(), and a new module listens with addEventListener(). Which rule makes the new module hear it?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: Announce with real events: dispatch a bubbling CustomEvent

    Moving announcements to real events first lets listeners move one at a time, in any order.

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:

The help panel before the fix, the fix's configuration, and the file it left

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>
What the fix changed, and what the panel did before and after JavaScript · autofix.mjs
// 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

  1. One question eslint --fix made these five rewrites in help.js, and ESLint reported nothing afterwards. Which ones broke the panel?

    Choose every answer that is right.

    Show the answer: One question

    Answer:

    • $('#help').hover(over, out) became $('#help').on("hover", over, out)
    • $.proxy(tour.next, tour) became tour.next.bind(tour)

    Two of five rewrites passed the linter and failed the checks: review an autofix by running the page, one rule per commit.

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:

The shop's templates

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>
The jQuery calls that ESLint never read JavaScript · scan.mjs
// 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:

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

  1. One question The gate counts 0 jQuery calls in every module. What can still call jQuery?

    Choose one answer.

    Show the answer: One question

    Answer: Inline handlers and inline scripts in server templates, which ESLint never reads

    Search the templates, and every other place code lives, before you take jQuery off the page.

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.js with the root /app/src is in cart; a file directly in root is a module of its own, named after the file without .js;
  • it counts only messages whose ruleId starts with no-jquery/; other rules, and messages without a ruleId, do not count;
  • every module of results appears 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.

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.

JavaScript Linter Lint one module in your browser and read each problem's rule and line, the fields the gate adds up.

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.

  1. Question 1 of 13 What does the strangler fig approach do?

    Choose one answer.

    Show the answer: Question 1 of 13

    Answer: It replaces a system piece by piece, while the old system keeps running

    Martin Fowler named it after the fig that grows around a host tree until it stands on its own.

  2. Question 2 of 13 GitHub's 2018 write-up describes parts of its custom jQuery build going away during the migration. Which, and when?

    Choose one answer.

    Show the answer: Question 2 of 13

    Answer: The selector engine once jQuery-only selectors were gone, and the Ajax module after the last $.ajax became fetch()

    Shrinking the build as usage disappears is both a reward and a guard against new code using what is leaving.

  3. Question 3 of 13 When does the gate replace its baseline with a commit's counts?

    Choose one answer.

    Show the answer: Question 3 of 13

    Answer: When no module of the commit rose above the baseline

    After commit 3 the baseline was cart.js 15, reviews.js 9, search.js 0, and the final one cart.js 0.

  4. Question 4 of 13 GitHub marked existing jQuery calls with eslint-disable comments and failed CI on jQuery without one. What does that achieve that a count per module does not?

    Choose one answer.

    Show the answer: Question 4 of 13

    Answer: It marks each legacy call in place, so no new call can slip in, even where an old one was removed

    Both approaches stop new jQuery in CI; GitHub also had a bot that pinged the team when someone added a new eslint-disable comment.

  5. Question 5 of 13 At commit 1, the three modules held 45 jQuery calls in 40 lines of code. How many calls per 100 lines is that, rounded?

    Type a number.

    A hint

    Divide the calls by the lines, then multiply by 100.

    Show the answer: Question 5 of 13

    Answer: 113 calls per 100 lines (anything from 112 to 114 counts)

    45 ÷ 40 × 100 = 112.5, which the dashboard rounds to 113. By commit 5 it was 20.

  6. Question 6 of 13 From which point can a page load jQuery 4's slim build instead of the full one?

    Choose one answer.

    Show the answer: Question 6 of 13

    Answer: Once nothing on the page calls Ajax, effects, Deferreds or queues, jQuery UI's widgets included

    Check what the page still calls, its plugins and widgets included, before switching builds, as this track's lesson on loading jQuery shows.

  7. Question 7 of 13 When do new features stop using jQuery during a migration?

    Choose one answer.

    Show the answer: Question 7 of 13

    Answer: From the first day, whatever phase the codebase is in

    The cheapest call to remove is the one never written.

  8. Question 8 of 13 What does the first rule of shared pages say?

    Choose one answer.

    Show the answer: Question 8 of 13

    Answer: Each element and event has one owner, a jQuery handler or a native listener, never both

    The change that adds a native listener removes the jQuery handler it replaces, in the same commit.

  9. Question 9 of 13 A team moves a widget's writes to dataset and leaves its reads on .data() "for now". What will they see?

    Choose one answer.

    Show the answer: Question 9 of 13

    Answer: The reads return the first value jQuery read, however often the attribute changes

    Move every reader and writer of a key in the same change, so the key has one owner at all times.

  10. Question 10 of 13 After $.proxy(tour.next, tour) became tour.next.bind(tour), why did .off('click', tour.next) stop removing the handler?

    Choose one answer.

    Show the answer: Question 10 of 13

    Answer: $.proxy() let .off() find the handler from the original function; bind() makes a new function that .off() cannot match

    Keep the bound function: const next = tour.next.bind(tour); .on('click', next) and later .off('click', next).

  11. Question 11 of 13 How do you run a linter's autofixes safely during a migration?

    Choose one answer.

    Show the answer: Question 11 of 13

    Answer: One rule per commit, each followed by the pages' checks

    A quiet linter says the pattern was applied, not that the page still works.

  12. Question 12 of 13 When nothing calls jQuery any more, what comes off the pages first?

    Choose one answer.

    Show the answer: Question 12 of 13

    Answer: jQuery Migrate, in a release of its own, then jQuery

    Remove one thing at a time, watch the error reports for a release cycle, then the next.

  13. Question 13 of 13 Why keep the jQuery file ready to load again for a release cycle after removing it?

    Choose one answer.

    Show the answer: Question 13 of 13

    Answer: A forgotten page, template or third-party widget may still need it, and one change then restores it

    The scan of templates found three calls the gate never counted; there may be more in places nobody searched.

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.

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

    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 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?

    Model answer, Pro. First step and follow-ups

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 all config 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

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.