jQuery 3 → jQuery 4 migration (Drupal theme)
Drupal 11 ships jQuery 4. All APIs deprecated since jQuery 3.x are
permanently removed. Custom JS (typically under
web/themes/custom/<theme>/src/js/) must be migrated. This works
independently of the core bump: native replacements (Array.isArray,
String.prototype.trim, Date.now…) exist regardless of the jQuery version, so
you can migrate and test while still on Drupal 10 (jQuery 3.x + polyfill).
Golden rule: everything goes through the Makefile
Never run npm/gulp directly on the host — use the targets inside the node
container:
| Need | Make target |
|---|---|
| Build assets | make npm-build |
| Deploy built assets | make gulp-deploy |
| Clear Drupal cache | make cr |
| Shell in a container | make shell |
The polyfill: understand before acting
Many D10 projects include a polyfill re-implementing removed jQuery
functions (jquery-deprecated.js / polyfill.js). Its presence means:
- Theme code likely still depends on these APIs.
- Two strategies:
- (A) Keep the polyfill temporarily, migrate the calling code progressively, remove it at the end. Safest — recommended.
- (B) Migrate everything to native APIs and remove the polyfill at once. Cleaner but riskier.
Start with (A); remove the polyfill once grep confirms zero remaining usages.
Removed API conversion table
| Removed API | Native / jQuery 4 replacement |
|---|---|
$.isFunction(x) / $.fn.isFunction |
typeof x === 'function' |
$.type(x) |
native typeof, or Array.isArray(), === null, etc. |
$.trim(s) |
s.trim() (non-null string) or (s ?? '').trim() |
$.isArray(x) |
Array.isArray(x) |
$.isWindow(x) |
x != null && x === x.window |
$.nodeName(el, n) |
el.nodeName?.toLowerCase() === n.toLowerCase() |
$.isNumeric(x) |
!Number.isNaN(parseFloat(x)) && Number.isFinite(x) |
$.now() |
Date.now() |
$.parseJSON(s) |
JSON.parse(s) |
$.unique(arr) / $.fn.unique |
$.uniqueSort(arr) |
$.camelCase(s) |
Internal API — replace with your own function |
$.fx.interval |
Removed — delete any write to this property |
$.cssProps.float = 'styleFloat' |
styleFloat no longer exists — remove |
Other jQuery 4 changes to check
$.ajax:success/error/complete→.done()/.fail()/.always()or promises..bind()/.unbind()/.delegate()/.undelegate()→.on()/.off()..load()/.unload()/.error()event shorthands →.on('load'…).$.isEmptyObject,$.proxy,$.uniqueSort: still shipped by jQuery 4.0.0 ($.proxyis deprecated, not removed). Verify in the core file rather than assuming —grep -n 'jQuery\.proxy = ' web/core/assets/vendor/jquery/jquery.js. A library that only calls these needs no shim at all.jQuery.Deferred: behaviour aligned with native Promises.hoverpseudo-events →mouseenter/mouseleave.
Scanning custom JS
THEME_JS=web/themes/custom/*/src/js
grep -rn '\$\.isFunction\|\.isFunction(' $THEME_JS
grep -rn '\$\.trim\|jQuery\.trim' $THEME_JS
grep -rn '\$\.isArray' $THEME_JS
grep -rn '\$\.type(' $THEME_JS
grep -rn '\$\.parseJSON\|jQuery\.parseJSON' $THEME_JS
grep -rn '\$\.now\|jQuery\.now' $THEME_JS
grep -rn '\$\.isWindow' $THEME_JS
grep -rn '\$\.nodeName' $THEME_JS
grep -rn '\$\.isNumeric' $THEME_JS
grep -rn '\$\.unique\b' $THEME_JS
grep -rn '\.bind(\|\.unbind(\|\.delegate(\|\.undelegate(' $THEME_JS
grep -rn 'fx\.interval\|cssProps' $THEME_JS
Scanning third-party / minified vendor — the grep blind spot
The greps above only cover readable custom source. Minified/bundled vendor
libraries alias jQuery to a local variable — (function(a){… a.type(…) …})(jQuery) —
so $.type / $.isArray etc. appear as a.type( / a.isArray( and the
\$\.-anchored greps miss them entirely. This is exactly how a removed
static like $.type (called by slick-carousel's registerBreakpoints) slips
past the scan and only surfaces at runtime.
Scan the vendor too, on the method token alone (and prefer each lib's unminified source, where the calls are literal):
VENDOR='node_modules/slick-carousel/slick/slick.js web/themes/custom/*/js/*.min.js'
grep -oE '\.(type|isFunction|isArray|isWindow|isNumeric|trim|nodeName|parseJSON|proxy|now|unique|camelCase)\(' $VENDOR | sort | uniq -c
grep -oE '\.(bind|unbind|delegate|undelegate)\(' $VENDOR | sort | uniq -c
A clean grep is not proof — aliased minified calls are invisible to it.
The authoritative check is the browser (see the verification note below).
Migration workflow — custom JS
- Run the scan, list all affected files/lines.
- Apply the native conversion from the table for each occurrence.
- Rebuild:
make npm-buildthenmake gulp-deploy. - Test every JS interaction (modals, sliders, AJAX, forms).
- Once
grepshows zero usages, delete the polyfill file and its line in the theme's*.libraries.yml. make crand final re-test.
Verify in a real browser, not just grep. Source greps cannot see aliased minified vendor calls, so load the site under the target jQuery (D11 core = jQuery 4) and watch the console for
TypeError: … is not a function. Confirmwindow.jQuery.fn.jqueryis4.x, that removed statics resolve (typeof window.jQuery.type), and that each plugin still initialises (e.g. a slider gains its.slick-initializedclass). Zero console errors on a clean reload is the real pass signal.
After removing the polyfill, drop any explicit dependency on a pinned jQuery version (let D11 core provide jQuery 4):
grep -rn 'jquery' web/themes/custom/*/*.libraries.yml
grep -rn 'core/jquery' web/themes/custom/*/
Third-party & contrib libraries (in parallel)
Custom JS is not the only source of breakage. Third-party libraries declared
in theme .libraries.yml files (slick.js, lightboxes, accordions…) may also
call removed APIs. Per library, in this order: prove no compatible release
exists → update if one does → only then generate a scoped local polyfill
(never pull in core/jquery.migrate, which re-adds the entire deprecated
surface). Also replace external CDN references and any core/modernizr
dependency (removed in D11).
Gate: prove no compatible release exists before writing a shim
A shim is the fallback, never the first move — and the npm registry is not the
source of truth. A package that looks abandoned there can have a live
repository whose jQuery-4 fix ships as a git tag only. npm view <pkg> version
returning a release from years ago proves nothing; it is the single most common
way a project ends up maintaining a polyfill it never needed.
Walk all four levels before concluding "no compatible version exists":
PKG=<npm-package>; REPO=<owner>/<repo>
# 1. registry — what npm/yarn would actually install
npm view "$PKG" version time.modified
# 2. tags and releases — routinely ahead of the registry
curl -s "https://api.github.com/repos/$REPO/tags?per_page=10" | grep '"name"'
curl -s "https://api.github.com/repos/$REPO/releases?per_page=5" | grep '"tag_name"\|"published_at"'
# 3. default-branch HEAD — the fix may not be tagged yet
curl -s "https://api.github.com/repos/$REPO/commits/HEAD" | grep -m1 '"date"'
# 4. active forks — only when upstream is genuinely dead
curl -s "https://api.github.com/repos/$REPO/forks?sort=stargazers&per_page=5" | grep '"full_name"'
Then audit the candidate itself, never its changelog: download it and rerun
the token scan. Compatible means the token scan comes back empty of removed
APIs — remaining hits on $.proxy / $.isEmptyObject / $.uniqueSort are fine,
those still ship with jQuery 4.
V=<tag>
curl -sSL -o /tmp/lib.js "https://cdn.jsdelivr.net/gh/$REPO@$V/<path>/lib.js"
grep -oE '\.(type|isFunction|isArray|isWindow|isNumeric|trim|nodeName|parseJSON|now|unique|camelCase|bind|unbind|delegate|undelegate)\(' /tmp/lib.js | sort | uniq -c
Also diff the candidate against the pinned version to size the upgrade, and diff the pinned version against its own upstream release first — a vendored file that was patched locally makes the swap a merge, not a replacement:
curl -sSL -o /tmp/pinned.js "https://cdn.jsdelivr.net/gh/$REPO@<current-tag>/<path>/lib.js"
diff /tmp/pinned.js <theme>/js/lib.js # empty = stock, safe to replace outright
diff /tmp/pinned.js /tmp/lib.js | head -100
Write the verdict down. "No compatible version exists" is a claim that belongs in the merge request with the four checks behind it, because it is the claim that justifies shipping a polyfill.
What the scoped shim contains. For a bundled/abandoned plugin you cannot
rewrite (slick, mCustomScrollbar…), re-add only the removed members it
actually calls, each guarded (if (typeof $.fn.bind !== 'function'),
if (typeof $.type !== 'function')…) so it is a no-op under jQuery 3:
- event aliases:
.bind/.unbind/.delegate/.undelegate→.on/.off - static helpers (the ones the source grep misses in minified vendor):
$.type,$.isFunction,$.isArray,$.isNumeric,$.trim,$.isWindow,$.nodeName,$.now,$.parseJSON— faithful jQuery-3 reimplementations ($.typeneeds theclass2typelookup table) - if the theme's own scripts use the bare global
$(Drupal core runsjQuery.noConflict()), also restorewindow.$ = window.jQuery
Declare the shim as its own library depending on core/jquery and
core/drupal (so it runs after noConflict()) and load it before the
plugin bundle — via a dependency, and/or a weight that lands it after
jquery/drupal.init but before the theme bundle. Verify the actual <script>
order in the rendered page.
The full playbook — polyfill catalog with inter-dependencies, how to declare and
attach it in .libraries.yml, CDN / core/modernizr replacement, and the final
verification greps — is in references/polyfill-catalog.md. Load it when you
reach the third-party stage.