Bootstrap 5 removed jQuery and found what depended on it

Bootstrap 5 shipped in May without jQuery, which was announced as a size saving and turned out to be an audit of everything that had been quietly relying on jQuery being present because Bootstrap loaded it.

The symptom

$ npm i bootstrap@5
$ npm run build && npx playwright test
  32 passed, 6 failed

#   ✗ the modal closes on the confirm button
#   ✗ the dropdown filter updates the table
#   ✗ the tooltip on a dynamically added row
#   ✗ the date picker
#   ✗ the tab remembers its selection
#   ✗ the file upload preview

# uncaught ReferenceError: $ is not defined

Six failures, of which one is Bootstrap’s own components changing and five are our code using a global that Bootstrap had been providing. None of those five files import jQuery.

Why it happens

jQuery was a peer dependency that ended up as an ambient global, and code written against it never declared the dependency because it never needed to. Removing the thing that loaded it makes every implicit user visible at once.

The fix

The Bootstrap components themselves

// 4.x — jQuery plugin API
$('#confirm').modal('hide')
$('[data-toggle="tooltip"]').tooltip()
$('#tabs a[href="#profile"]').tab('show')

// 5.x — real classes, and an instance registry
import { Modal, Tooltip, Tab } from 'bootstrap'

Modal.getInstance(document.querySelector('#confirm')).hide()

document.querySelectorAll('[data-bs-toggle="tooltip"]')
  .forEach(el => new Tooltip(el))

Tab.getOrCreateInstance(document.querySelector('#profile-tab')).show()

getInstance returning null for an element that was never initialised is the trap, and getOrCreateInstance is what you almost always want. The data attributes all gained a bs prefix at the same time, which is a separate find-and-replace with a silent failure — a trigger with the old attribute simply does nothing.

// and the events, which changed name and target
// 4.x
$('#confirm').on('hidden.bs.modal', handler)

// 5.x — a native CustomEvent on the element
document.querySelector('#confirm')
  .addEventListener('hidden.bs.modal', handler)

// the event names are unchanged. the mechanism is not:
// jQuery's event system and the DOM's are separate, so a
// jQuery .on() will not hear a native dispatch.

The event names being identical while the mechanism changed is the worst possible combination, because the code looks correct and does nothing. Any remaining jQuery in the codebase cannot hear Bootstrap 5 events at all, which is the specific failure in three of the six tests.

The code that was using jQuery incidentally

// what five files were doing
$('#filter').on('change', function () {
  $.get('/api/rows?f=' + $(this).val(), function (html) {
    $('#table-body').html(html)
  })
})

// and the native version, which is not longer
document.querySelector('#filter').addEventListener('change', async (e) => {
  const res = await fetch(`/api/rows?f=${encodeURIComponent(e.target.value)}`)
  document.querySelector('#table-body').innerHTML = await res.text()
})

The rewrite is rarely longer and is frequently better — the jQuery version here was not encoding the parameter, which nobody had noticed because the values had always been numeric. Rewriting five files found two bugs of that kind, which is a common outcome and is not a reason to do the upgrade.

The decision worth making explicitly is whether jQuery goes entirely or stays as a declared dependency for the code that uses it. Keeping it is legitimate on a large application and it has to be an import in each file rather than a global, or this happens again at the next framework upgrade.

The utility API, which replaces the hand-written half

@import "bootstrap/scss/functions";
@import "bootstrap/scss/variables";
@import "bootstrap/scss/utilities";

$utilities: map-merge($utilities, (
  "opacity": (
    property: opacity, class: opacity, responsive: true,
    values: (0: 0, 25: .25, 50: .5, 100: 1)
  ),
  "cursor": (property: cursor, class: cursor,
             values: pointer not-allowed),
));

@import "bootstrap/scss/bootstrap";

Generating the responsive variants is what makes this worth adopting: writing .opacity-md-50 by hand across four breakpoints is what nobody does consistently, so the hand-written utilities were always incomplete. The map is also where utilities get removed, which is the half people skip and is where the stylesheet size is.

The forms, which are the largest diff

<!-- 4.x -->
<div class="form-group">
  <label for="email">Email</label>
  <input class="form-control" id="email">
</div>

<!-- 5.x: .form-group is gone, .form-label is new -->
<div class="mb-3">
  <label for="email" class="form-label">Email</label>
  <input class="form-control" id="email">
</div>

<!-- and custom-control, custom-select, form-row: all gone -->

This touches every form field on the site, which is the largest mechanical change in the upgrade and the one most likely to be done partially. Defining .form-group locally as a one-line rule is a legitimate transitional move and belongs in a file with a date and a ticket number rather than in the main stylesheet.

Verifying it worked

$ grep -rn 'data-toggle|data-target|data-dismiss' resources/ | wc -l
0

$ grep -rln '$(' resources/js/ | wc -l
0

$ npx playwright test
  38 passed

$ npx backstop test
  Passed: 41   Failed: 2
  # both: 2px of label spacing. accepted and rebaselined.

$ du -h public/js/app.js
  188K        # was 274K

The visual regression run is what catches the form spacing changes, which no functional test covers and which are the visible half of this upgrade. Two accepted differences out of forty-three is a good outcome and each one needed a person to look at it.

Eighty-six kilobytes is the jQuery removal and is the number that will be quoted in the summary. It is the least important outcome — the five files that no longer depend on an ambient global are worth more.

What this costs

Every form template changes, which is a large diff with no functional content and a real chance of a partial application. Doing it in one commit with no other changes is what makes it reviewable, and reviewing it properly means looking at rendered pages rather than at the diff.

The codebase still has jQuery in three places for its own reasons, and it is now a declared import rather than a global. That is the correct outcome and it means the size saving is smaller than advertised — the honest number is fifty kilobytes rather than eighty-six, and it would have been zero if the date picker had not been replaced at the same time.