jQuery 4 (legacy code and migration to DOM APIs) Module 4 – Ajax, Deferreds and forms
What changed in jQuery 4 Ajax: JSONP, scripts
The jQuery 4 Ajax changes, run on 3.7.1 and 4.0.0: no JSONP promotion, scripts that run only when asked, script elements for CSP, and FormData sent as is.
What you will learn
- List the Ajax changes of jQuery 4 and find the calls in a codebase that depend on the old behaviour
- Replace JSONP with CORS, with an allowlist of origins on the server
- Load scripts in a way that a strict Content Security Policy allows, with dataType 'script' or scriptAttrs
- Send FormData and binary data with jQuery 4, without the processData and contentType settings of jQuery 3
Before you start
What you’ll learn · Before you start
What you will learn
- List the Ajax changes of jQuery 4 and find the calls in a codebase that depend on the old behaviour
- Replace JSONP with CORS, with an allowlist of origins on the server
- Load scripts in a way that a strict Content Security Policy allows, with dataType 'script' or scriptAttrs
- Send FormData and binary data with jQuery 4, without the processData and contentType settings of jQuery 3
Before you start
On this page
Move a shop page from jQuery 3.7.1 to 4.0.0 and two of its widgets can change without one line of the page changing: the exchange-rates widget stops running the rates API’s code, and the rating widget stops running its own. Four changes to Ajax in jQuery 4 account for both, and the upgrade guide gives a security risk or a Content Security Policy error as the reason for each (4.0 upgrade guide).
The widgets and the upload form here ran in jsdom on both versions, and the test server logged each request together with what made it, a script element or XMLHttpRequest. You will find the calls an upgrade changes, move a JSONP endpoint to CORS with an allowlist, load scripts in a way a strict policy allows, and send FormData and binary bodies without the jQuery 3 workaround.
A callback=? URL no longer turns a JSON request into JSONP
JSONP: A way to read data from another origin without CORS: the page adds a script element whose URL names a function, and the server answers with code that calls it with the data. The answer runs as code in the page. is how pages read data from another origin before CORS: the page adds a script element
whose URL names a function, and the server answers with code that calls that function with the data. jQuery 3 made
JSONP out of any JSON request whose URL had callback=?, filling in a function name of its own. jQuery 4 stopped:
the upgrade guide calls the promotion a security issue, because the developer may be unaware that the page runs code
from a remote domain, and asks for dataType: 'jsonp' when JSONP is wanted. Step through the widget’s calls on both
versions:
The drawing shows the whole scene; its 5 steps are listed below.
Every step as text
- 3.7.1, own server. On 3.7.1, callback=? in a JSON request turns it into JSONP: jQuery replaces the ? with a function name of its own, jQuery<number>_<number>, and the server answers with code that calls it.
- 3.7.1, the API. For the API on another origin, 3.7.1 adds a script element with that URL. The API's answer runs in the page as code, with every power the page's own scripts have.
- 4.0.0, own server. On 4.0.0 the same call stays a JSON request. The ? goes to the server as it is, callback=?, and the server, given no usable name, answers plain JSON.
- 4.0.0, the API. The request to the API is now an XMLHttpRequest with an Origin header. It works because the API sends CORS headers that name the shop; without them the call would fail.
- JSONP on purpose. dataType: "jsonp" still makes JSONP on 4.0.0, with a script element for the other origin. It works, but it runs the API's code in the page, which CORS never does.
The widget asks its own server and the rates API with callback=?, then the API once more with dataType: 'jsonp':
shop/jsonp.js
// The rates widget asks for rates with ?callback=? in the URL, from its own server and from the rates API on another
// origin, then once more with dataType: 'jsonp'. The test server logs each request and what made it.
const outcome = async (label, request) => {
try {
console.log(`${label}: ${JSON.stringify(await request())}`);
} catch (jqXHR) {
console.log(`${label} failed: status ${jqXHR.status}`);
}
};
(async () => {
await outcome('own server, callback=?', () => $.getJSON('/srv/rates?callback=?'));
await outcome('rates API, callback=?', () => $.getJSON('https://api.example.in/rates?callback=?'));
await outcome("rates API, dataType 'jsonp'", () => $.ajax({ url: 'https://api.example.in/rates', dataType: 'jsonp' }));
})(); shop/jsonp.html
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Exchange rates</title>
</head>
<body>
<script src="https://code.jquery.com/jquery-4.0.0.min.js" integrity="sha256-OaVG6prZf4v69dPg6PhVattBXkcOWQB62pdZ3ORyrao=" crossorigin="anonymous"></script>
<script src="jsonp.js"></script>
</body>
</html> // Prints what shop/jsonp.js logged in jsdom and what the test server received (recorded.mjs).
import { show } from './show.mjs';
show('shop/jsonp.html'); Output
server: GET /srv/rates?callback=? from XMLHttpRequest, answered 200
X-Requested-With: XMLHttpRequest
own server, callback=?: {"INR":1,"USD":0.0113}
server: GET https://api.example.in/rates?callback=? from XMLHttpRequest, answered 200
Origin: https://shop.example
rates API, callback=?: {"INR":1,"USD":0.0113}
server: GET https://api.example.in/rates?callback=jQuery<number>_<number>&_=<number> from <script>, answered 200
rates API, dataType 'jsonp': {"INR":1,"USD":0.0113}
(jQuery 3.7.1:)
server: GET /srv/rates?callback=jQuery<number>_<number>&_=<number> from XMLHttpRequest, answered 200
X-Requested-With: XMLHttpRequest
own server, callback=?: {"INR":1,"USD":0.0113}
server: GET https://api.example.in/rates?callback=jQuery<number>_<number>&_=<number> from <script>, answered 200
rates API, callback=?: {"INR":1,"USD":0.0113}
server: GET https://api.example.in/rates?callback=jQuery<number>_<number>&_=<number> from <script>, answered 200
rates API, dataType 'jsonp': {"INR":1,"USD":0.0113}
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node jsonp.mjs
On 4.0.0 both callback=? calls stayed JSON requests made by XMLHttpRequest, with the ? sent as it is, and the
call to the API carried an Origin header. It worked because the API answers with CORS headers that name the shop.
On 3.7.1 the same calls became JSONP: jQuery wrote callback=jQuery<number>_<number> into the URL, fetched the shop’s
own JSONP with XMLHttpRequest and ran it, and fetched the API’s with a script element. Only the explicit
dataType: 'jsonp' call made JSONP on both versions.
So the change breaks a call wherever the server answers callback=? with JSONP alone. From another origin without
CORS headers, the browser refuses the JSON request of jQuery 4; and a server that wraps its JSON in a call to whatever
name it is given, ? included, sends code where jQuery 4 parses JSON, which ends in a parse error
(jQuery.ajax()). The fix that lasts is on the server.
Checkpoint
Check: The end of JSON-to-JSONP promotion
CORS replaces JSONP: the server names the origins it trusts
With CORS: Cross-Origin Resource Sharing: the HTTP headers with which a server lets pages from other sites read its responses. A script from another site with an integrity value is fetched with CORS, which crossorigin="anonymous" asks for. More in “Loading jQuery: CDN, SRI, npm, slim and noConflict”, the API answers JSON and says which pages may read it, in the
Access-Control-Allow-Origin header; a page on any other origin gets the answer withheld by its browser
(Fetch Standard). What the API puts in that header is the whole
decision. The * wildcard lets every page read the answer, and browsers refuse it for requests that carry cookies,
which must name one origin instead (MDN’s CORS guide).
Copying the request’s Origin into the answer looks like naming one, but trusts whoever asks: OWASP’s cheat sheet says
to “not use * wildcard nor blindly return the Origin header content without any checks”
(HTML5 Security Cheat Sheet). The
rates API of this lesson keeps a list, in cors.mjs:
// cors.mjs: how the rates API decides which pages may read its answers. It names the origins it trusts and answers
// any other origin without CORS headers, so the browser keeps the answer from that page.
const ALLOWED = new Set(['https://shop.example', 'https://admin.shop.example']);
/** The CORS headers for a request from `origin` (the Origin header, or undefined). */
export function corsHeaders(origin) {
if (!origin || !ALLOWED.has(origin)) return { vary: 'Origin' };
return {
'access-control-allow-origin': origin,
'access-control-allow-methods': 'GET',
'access-control-allow-headers': 'X-Requested-With',
vary: 'Origin',
};
} This program tests it against a version that copies the Origin, for the shop, its admin site, a lookalike domain,
the null origin of sandboxed frames and a request without an Origin header, and then checks that no origin outside
the list may read the rates:
// cors-check.mjs: the rates API's allowlist (cors.mjs) next to a version that copies any Origin into its answer. For
// each origin it prints whether a page there may read the answer; then it checks that pages the API does not trust get
// nothing, which the copying version fails.
import { corsHeaders } from './cors.mjs';
/** The weak version: it trusts whatever origin asks, and allows cookies. It is here to be tested, never to be used. */
const copyingHeaders = (origin) => (origin ? { 'access-control-allow-origin': origin, 'access-control-allow-credentials': 'true' } : {});
const reads = (headers, origin) => origin !== undefined && headers['access-control-allow-origin'] === origin;
const ORIGINS = ['https://shop.example', 'https://admin.shop.example', 'https://notshop.example', 'null', undefined];
const UNTRUSTED = ['https://notshop.example', 'null'];
for (const origin of ORIGINS) {
const label = origin === undefined ? '(no Origin header)' : origin;
const verdict = (headers) => (reads(headers, origin) ? 'may read' : 'no access').padEnd(10);
console.log(`${label.padEnd(27)} allowlist: ${verdict(corsHeaders(origin))} copying: ${verdict(copyingHeaders(origin)).trimEnd()}`);
}
for (const [name, headersFor] of [['allowlist', corsHeaders], ['copying', copyingHeaders]]) {
const open = UNTRUSTED.filter((origin) => reads(headersFor(origin), origin));
console.log(`check, ${name}: ${open.length ? `FAIL, ${open.join(' and ')} may read the rates` : 'pass, no untrusted origin may read the rates'}`);
}
console.log(`Vary on every answer of the allowlist: ${ORIGINS.every((origin) => corsHeaders(origin).vary === 'Origin')}`); Output
https://shop.example allowlist: may read copying: may read https://admin.shop.example allowlist: may read copying: may read https://notshop.example allowlist: no access copying: may read null allowlist: no access copying: may read (no Origin header) allowlist: no access copying: no access check, allowlist: pass, no untrusted origin may read the rates check, copying: FAIL, https://notshop.example and null may read the rates Vary on every answer of the allowlist: true
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node cors-check.mjs
The allowlist passed: only the two listed origins may read. The copying version failed, because it let
https://notshop.example and null read the rates, with cookies allowed. null is the origin of sandboxed frames and
other opaque contexts, so it stands for many unrelated pages and never belongs on a list
(Origin). The allowlist also sends
Vary: Origin, so that a shared cache keeps the answers for different origins apart, as MDN’s guide advises. Answer
two questions about your own API, and the table marks the answer that fits:
Answer the questions above: the table then marks what fits your case and says why the rest does not.
| The API's answer | Pages you do not know | Requests with cookies | Shared caches |
|---|---|---|---|
| A wildcard, * Sends: Access-Control-Allow-Origin: * | Poor every site may read the answer, so only public data belongs behind it | Poor browsers refuse * for a request with credentials | Good one answer for every origin, so caching is simple |
| Copy the Origin Sends: whatever Origin the request had | Poor any site may read it: notshop.example and null could in the check | Poor with credentials allowed, any site reads the user's data | Mixed needs Vary: Origin, or a cache serves one origin's answer to another |
| An allowlist Sends: the Origin only if it is listed | Good only listed origins may read it; the check passed | Good names the exact origin, as credentials require | Mixed needs Vary: Origin, which cors.mjs sends on every answer |
How the answers decide
- “Do the requests carry cookies or other credentials?” Yes, the answer depends on the user: rules out A wildcard, * and Copy the Origin. A credentialed answer must name one origin, and copying any Origin would let every site read the user's data.
- “Must sites you do not know be able to read the answer?” No, only our own sites: rules out A wildcard, * and Copy the Origin. Both let pages you do not know read the answer; only a list of the origins you trust does not.
- “Do the requests carry cookies or other credentials?” No, the same answer for everyone and “Must sites you do not know be able to read the answer?” Yes, it is public data: adds a caveat to An allowlist. For public data without cookies, * is enough; a list would keep out sites that may read it anyway.
- “Do the requests carry cookies or other credentials?” Yes, the answer depends on the user and “Must sites you do not know be able to read the answer?” Yes, it is public data: adds a caveat to An allowlist. An answer that depends on the user is not public data: serve the public part without cookies under *, and keep the user's part to the listed origins.
Compare the whole origin, exactly
Keep the trusted origins in a set and compare the request’s Origin with each one exactly. Tests such as
origin.endsWith('shop.example') let in https://notshop.example, and copying the Origin lets in everyone.
Checkpoint
Check: CORS with an allowlist instead of JSONP
A script runs only when the request asks for one
jQuery 3.0 stopped running scripts fetched from another origin unless the request said dataType: 'script', to block
a server that later starts serving code (3.0 upgrade guide). jQuery 4 applies
the same rule to the page’s own origin: $.ajax('/widgets/rating.js') used to run the file because its answer said
text/javascript; now it only returns the text, and fetching and running needs dataType: 'script' or
$.getScript() (4.0 upgrade guide). The rating widget fetches its script four
ways, and the page logs each script element that jQuery adds to document.head:
shop/scripts.js
// The rating widget's script, fetched four ways. The page watches document.head and logs each script element that
// jQuery adds: one with a src, or an inline one with the code in it.
new MutationObserver((records) => {
for (const record of records) {
for (const node of record.addedNodes) {
if (node.nodeName !== 'SCRIPT') continue;
const what = node.src ? `with src ${new URL(node.src).pathname}` : `inline, ${node.text.length} characters of code`;
console.log(` a script element was added, ${what}${node.hasAttribute('nonce') ? ', with a nonce' : ''}`);
}
}
}).observe(document.head, { childList: true });
const settle = (jqXHR) => new Promise((done) => jqXHR.always(() => done()));
(async () => {
console.log("$.ajax('/widgets/rating.js'), no dataType:");
await settle($.ajax('/widgets/rating.js').done((data) => console.log(` done() got ${typeof data}, ${data.length} characters`)));
console.log("$.getScript('/widgets/rating.js'):");
await settle($.getScript('/widgets/rating.js'));
console.log("dataType 'script' with headers:");
await settle($.ajax({ url: '/widgets/rating.js', dataType: 'script', headers: { 'X-Widget': 'rating' } }));
console.log("dataType 'script' with scriptAttrs:");
await settle($.ajax({ url: '/widgets/rating.js', dataType: 'script', scriptAttrs: { nonce: 'rAnd0m' } }));
await new Promise((done) => setTimeout(done, 20));
console.log(`rating.js ran ${window.ratings} times`);
})(); shop/scripts.html
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>The rating widget</title>
</head>
<body>
<script src="https://code.jquery.com/jquery-4.0.0.min.js" integrity="sha256-OaVG6prZf4v69dPg6PhVattBXkcOWQB62pdZ3ORyrao=" crossorigin="anonymous"></script>
<script src="scripts.js"></script>
</body>
</html> // Prints what shop/scripts.js logged in jsdom and what the test server received (recorded.mjs).
import { show } from './show.mjs';
show('shop/scripts.html'); Output
$.ajax('/widgets/rating.js'), no dataType:
server: GET /widgets/rating.js from XMLHttpRequest, answered 200
X-Requested-With: XMLHttpRequest
done() got string, 101 characters
$.getScript('/widgets/rating.js'):
a script element was added, with src /widgets/rating.js
server: GET /widgets/rating.js?_=<number> from <script>, answered 200
rating.js ran (1)
dataType 'script' with headers:
server: GET /widgets/rating.js?_=<number> from XMLHttpRequest, answered 200
X-Requested-With: XMLHttpRequest
X-Widget: rating
rating.js ran (2)
a script element was added, inline, 101 characters of code
dataType 'script' with scriptAttrs:
a script element was added, with src /widgets/rating.js, with a nonce
server: GET /widgets/rating.js?_=<number> from <script>, answered 200
rating.js ran (3)
rating.js ran 3 times
(jQuery 3.7.1:)
$.ajax('/widgets/rating.js'), no dataType:
server: GET /widgets/rating.js from XMLHttpRequest, answered 200
X-Requested-With: XMLHttpRequest
rating.js ran (1)
done() got string, 101 characters
a script element was added, inline, 101 characters of code
$.getScript('/widgets/rating.js'):
server: GET /widgets/rating.js?_=<number> from XMLHttpRequest, answered 200
X-Requested-With: XMLHttpRequest
rating.js ran (2)
a script element was added, inline, 101 characters of code
dataType 'script' with headers:
server: GET /widgets/rating.js?_=<number> from XMLHttpRequest, answered 200
X-Requested-With: XMLHttpRequest
X-Widget: rating
rating.js ran (3)
a script element was added, inline, 101 characters of code
dataType 'script' with scriptAttrs:
a script element was added, with src /widgets/rating.js, with a nonce
server: GET /widgets/rating.js?_=<number> from <script>, answered 200
rating.js ran (4)
rating.js ran 4 times
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node scripts.mjs
The first call shows the change: on 4.0.0, done() got the code as a string of 101 characters and nothing ran, while
3.7.1 ran it and added an inline script element to do so. The script ran three times on 4.0.0 and four times on
3.7.1. The other three calls asked for a script and ran it on both versions, but not in the same way, which the next
section takes up.
Loading code with $.get()
Wrong: $.get('/widgets/rating.js') or $.ajax(url) to load a widget, which runs on jQuery 3 and silently stops on
4. Right: $.getScript(url), or dataType: 'script', which say what the request is for and work on both.
Async script requests now use a script element, which a strict CSP allows
A Content Security Policy (CSP): A response header, Content-Security-Policy, that tells the browser which scripts and other resources a page may load and run. A policy such as script-src 'self' allows scripts from the page's origin and blocks inline code. such as script-src 'self' allows a script element
whose src is on the page’s origin and blocks inline code unless the policy names it
(CSP Level 3, CSP Cheat Sheet).
jQuery 3 fetched a same-origin script with XMLHttpRequest and ran the text by adding an inline script, which such a
policy blocks. jQuery 4 always uses a script element for async requests “to avoid any Content Security Policy errors”
(4.0 upgrade guide), with exceptions written in its source. Follow the page’s
calls through them:
The drawing shows the whole scene; its 5 steps are listed below.
Every step as text
- No dataType at all. $.ajax('/widgets/rating.js') asks for no script, so none of this applies on 4.0.0: done() got the code as a string of 101 characters, and nothing ran. On 3.7.1 the same call ran it.
- $.getScript(). $.getScript() sets dataType "script". It has no scriptAttrs or headers, the URL is on the same origin, and it is async, so jQuery 4 adds a script element with src: no X-Requested-With reached the server.
- With headers. A script element cannot send headers, so dataType "script" with headers goes through XMLHttpRequest, and jQuery runs the code by adding an inline script: the run logged an inline element of 101 characters.
- With scriptAttrs. scriptAttrs come first: with { nonce: "rAnd0m" } jQuery adds a script element with src and the nonce, as the run logged, whatever else is set.
- Why it matters. A strict Content Security Policy such as script-src 'self' allows a script element whose src is on the page's origin and blocks inline code, so only the element path loads the widget under it.
The run above shows each path. On 4.0.0, $.getScript() added a script element with src, and the request reached
the server from <script> with no X-Requested-With; on 3.7.1 it came from XMLHttpRequest and ran inline. A request
with headers went through XMLHttpRequest and an inline script on both versions, since a script element cannot send
headers (src/ajax/script.js). With scriptAttrs,
both versions added a script element carrying the nonce. JSONP to the page’s own origin is the remaining exception:
jQuery 4 keeps it on XMLHttpRequest, and its source notes that such requests violate a strict script-src, with CORS
as the proper solution.
| The request | 3.7.1 | 4.0.0 |
|---|---|---|
$.getScript(url), same origin |
XMLHttpRequest, then inline code | a script element with src |
dataType: 'script' with headers |
XMLHttpRequest, then inline code | XMLHttpRequest, then inline code |
dataType: 'script' with scriptAttrs |
a script element | a script element |
$.ajax(url) for a .js file, no dataType |
runs the code inline | returns the text, runs nothing |
Key idea
On jQuery 4 a script request reaches a strict policy as a script element with src, unless it sends headers or is
same-origin JSONP; drop the headers, or pass attributes such as a nonce through scriptAttrs.
Checkpoint
Check: Scripts that run only when asked · Script elements and a strict CSP
FormData and binary data go to the server as they are
jQuery 3 encoded every object it was given as data with $.param(), a FormData object or a Blob included, and threw
while doing so. Code that uploaded files therefore set processData: false, so jQuery would leave the data alone, and
contentType: false, so the browser would write the Content-Type with its boundary. jQuery 4 does both itself for
FormData, and leaves alone any data that is not a string, a plain object or an array
(src/ajax/binary.js); the upgrade guide calls it
support for binary data. The upload form sends a price list as FormData with the defaults, then with the jQuery 3
settings, then as a CSV Blob:
// A price list sent three ways: a FormData object with the defaults, the same with the settings jQuery 3 code uses,
// and a CSV Blob as the whole body.
const form = new FormData();
form.append('shop', 'Pune');
form.append('list', new Blob(['sku,price\nK-7310,1499\n'], { type: 'text/csv' }), 'prices.csv');
const send = async (label, settings) => {
try {
const answer = await $.ajax({ url: '/srv/upload', method: 'POST', ...settings });
console.log(`${label}: ${JSON.stringify(answer)}`);
} catch (error) {
console.log(`${label}: ${error instanceof Error ? `threw ${error.name}` : `failed with status ${error.status}`}`);
}
};
(async () => {
await send('FormData, the defaults', { data: form });
await send('FormData, processData and contentType false', { data: form, processData: false, contentType: false });
await send('a CSV Blob', { data: new Blob(['sku,price\nK-7310,1499\n']), contentType: 'text/csv' });
})(); // Prints what shop/formdata.js logged in jsdom and what the test server received (recorded.mjs).
import { show } from './show.mjs';
show('shop/formdata.html'); Output
server: POST /srv/upload from XMLHttpRequest, answered 201
Content-Type: multipart/form-data; boundary=BOUNDARY
X-Requested-With: XMLHttpRequest
body: --BOUNDARY
Content-Disposition: form-data; name="shop"
Pune
--BOUNDARY
Content-Disposition: form-data; name="list"; filename="prices.csv"
Content-Type: text/csv
sku,price
K-7310,1499
--BOUNDARY--
FormData, the defaults: {"saved":true}
server: POST /srv/upload from XMLHttpRequest, answered 201
Content-Type: multipart/form-data; boundary=BOUNDARY
X-Requested-With: XMLHttpRequest
body: --BOUNDARY
Content-Disposition: form-data; name="shop"
Pune
--BOUNDARY
Content-Disposition: form-data; name="list"; filename="prices.csv"
Content-Type: text/csv
sku,price
K-7310,1499
--BOUNDARY--
FormData, processData and contentType false: {"saved":true}
server: POST /srv/upload from XMLHttpRequest, answered 201
Content-Type: text/csv
X-Requested-With: XMLHttpRequest
body: sku,price
K-7310,1499
a CSV Blob: {"saved":true}
(jQuery 3.7.1:)
FormData, the defaults: threw TypeError
server: POST /srv/upload from XMLHttpRequest, answered 201
Content-Type: multipart/form-data; boundary=BOUNDARY
X-Requested-With: XMLHttpRequest
body: --BOUNDARY
Content-Disposition: form-data; name="shop"
Pune
--BOUNDARY
Content-Disposition: form-data; name="list"; filename="prices.csv"
Content-Type: text/csv
sku,price
K-7310,1499
--BOUNDARY--
FormData, processData and contentType false: {"saved":true}
a CSV Blob: threw TypeError
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node formdata.mjs
On 4.0.0 all three went through: the FormData as multipart/form-data with the file in its own part, twice, and the
Blob as a body of two CSV lines labelled text/csv, the contentType the call gave it. That setting stays necessary:
jQuery 4 drops its form Content-Type only for FormData, so a Blob sent without it goes out labelled as a form. On
3.7.1 only the call with the two settings reached the server; the other two threw a TypeError before anything was
sent. The settings are harmless on jQuery 4, so code that has to run on both versions keeps them until the last page
is upgraded.
Prefilters run before the data is encoded
jQuery 4 converts data to a string after the prefilters run, not before, so a prefilter that read options.data
as a string now sees the object; the upgrade guide expects prefilters written for the old order to be what notices
the change.
Checkpoint
Check: FormData and binary bodies on jQuery 4
Finding the calls an upgrade changes
Every Ajax change between the versions a codebase may have run is on this timeline; the 4.0 events are the ones an upgrade has to check:
- 1.5
$.ajax() returns a jqXHR new API
Every request gets a jqXHR, a promise with done(), fail() and then(), on which several handlers can wait.
- 1.9
Empty JSON is an error
An empty answer to a JSON request is rejected; the documentation asks servers to send null or {} instead.
- 3.0
Cross-origin scripts need dataType breaking
A script from another origin runs only with dataType "script", and the %20 to + conversion of form bodies moves into $.ajax().
- 3.5
Event shorthands deprecated
.ajaxStart(), .ajaxError() and the other four shorthands are deprecated in favour of .on("ajaxStart", …); jQuery 4 still has them.
- 4.0
No JSON-to-JSONP promotion breaking
A JSON request with a callback is no longer turned into JSONP; JSONP needs dataType "jsonp", and the upgrade guide points to CORS.
- 4.0
Scripts run only when asked breaking
A same-origin script fetched without dataType "script" is no longer run; use $.getScript() or the dataType.
- 4.0
Script elements for async requests breaking
Async script requests use a script element, to avoid Content Security Policy errors; headers still force XMLHttpRequest.
- 4.0
Binary data and FormData
FormData, Blobs and other data that is not a plain object or a string go to the server as they are, with no processData or contentType settings.
- 4.1 (documented)
jqXHR.responseURL
The API documentation already describes responseURL "as of jQuery 4.1". jQuery 4.0.0, the version this track runs, has no such property, so check your version before you rely on it.
The timeline has 9 events, from 1.5 to 4.1 (documented).
Searching a codebase for those patterns is a first pass that a program can do. This one takes every $.ajax,
$.get, $.getJSON, $.post and $.getScript call of the files in a folder, with its parentheses balanced, and
tests each call against the 4.0 changes; it also lists the deprecated event shorthands. Its sample folder holds four
files of an old shop theme:
legacy/rates-widget.js
// The exchange-rates widget of an old shop theme.
$(function () {
$.getJSON('https://api.example.in/rates?callback=?', function (rates) {
$('#rate').text(rates.USD);
});
$(document).ajaxError(function () {
$('#rate').text('unavailable');
});
}); legacy/widgets.js
// Widgets that load their own code when they come into view.
function loadRatingWidget() {
return $.ajax({ url: '/widgets/rating.js' });
}
function loadMapWidget() {
return $.ajax({
url: '/widgets/map.js',
dataType: 'script',
headers: { 'X-Widget': 'map' },
});
}
function loadChatWidget() {
return $.getScript('/widgets/chat.js');
} legacy/uploads.js
// The price-list upload of the admin pages.
function uploadPriceList(form) {
return $.ajax({
url: '/admin/prices',
method: 'POST',
data: new FormData(form),
processData: false,
contentType: false,
});
} legacy/stats.js
// Visitor statistics from a partner's API.
function loadStats(done) {
$.ajax({ url: 'https://stats.example.org/today', dataType: 'jsonp', success: done });
} // audit.mjs: finds the Ajax calls of a codebase that jQuery 4 runs differently, and the settings it no longer needs.
// It reads every .js file of a folder (legacy/ by default), takes each call of $.ajax, $.get, $.getJSON, $.post and
// $.getScript with its parentheses balanced, and tests the call's text against the rules below. It reads text, so it
// is a first pass: a URL built at run time, or settings in a variable, need a person to look at them.
import { readdirSync, readFileSync } from 'node:fs';
import { join } from 'node:path';
const RULES = [
{
id: 'jsonp-promotion',
test: (call) => /callback=\?/.test(call) && !/dataType\s*:\s*['"]jsonp['"]/.test(call),
advice: "callback=? no longer makes JSONP: the request goes as JSON, which needs CORS on the server",
},
{
id: 'jsonp',
test: (call) => /dataType\s*:\s*['"]jsonp['"]/.test(call),
advice: "JSONP still works, but runs the other server's code in the page: move to CORS",
},
{
id: 'script-not-run',
test: (call) => /^\$\.(ajax|get)\(/.test(call) && /\.js['"?]/.test(call) && !/dataType\s*:\s*['"]script['"]/.test(call),
advice: "a script is no longer run without dataType: 'script': use $.getScript() or add the dataType",
},
{
id: 'script-with-headers',
test: (call) => /dataType\s*:\s*['"]script['"]/.test(call) && /headers\s*:/.test(call) && !/scriptAttrs\s*:/.test(call),
advice: 'headers make the request go through XMLHttpRequest and an inline script, which a strict CSP blocks',
},
{
id: 'formdata-settings',
test: (call) => /processData\s*:\s*false/.test(call) && /contentType\s*:\s*false/.test(call),
advice: 'jQuery 4 sets both for FormData by itself: the two lines can go once every page runs jQuery 4',
},
];
const CALL = /\$\.(?:ajax|get|getJSON|post|getScript)\(/g;
const SHORTHAND = /\.(ajaxStart|ajaxStop|ajaxComplete|ajaxError|ajaxSuccess|ajaxSend)\(/g;
/** The text of a call from its name to the parenthesis that closes it, skipping parentheses inside strings. */
function callAt(text, start) {
let depth = 0;
let quote = null;
for (let i = text.indexOf('(', start); i < text.length; i++) {
const c = text[i];
if (quote) {
if (c === '\\') i++;
else if (c === quote) quote = null;
} else if (c === "'" || c === '"' || c === '`') quote = c;
else if (c === '(') depth++;
else if (c === ')' && --depth === 0) return text.slice(start, i + 1);
}
return text.slice(start);
}
const lineOf = (text, index) => text.slice(0, index).split('\n').length;
const folder = process.argv[2] ?? new URL('./legacy/', import.meta.url).pathname;
let findings = 0;
for (const file of readdirSync(folder).filter((f) => f.endsWith('.js')).sort()) {
const text = readFileSync(join(folder, file), 'utf8');
for (const m of text.matchAll(CALL)) {
const call = callAt(text, m.index);
for (const rule of RULES.filter((r) => r.test(call))) {
findings++;
console.log(`${file}:${lineOf(text, m.index)} ${rule.id}: ${rule.advice}`);
}
}
for (const m of text.matchAll(SHORTHAND)) {
findings++;
console.log(`${file}:${lineOf(text, m.index)} event-shorthand: .${m[1]}() is deprecated since 3.5 (still in 4.0): use .on('${m[1]}', …)`);
}
}
console.log(`${findings} findings`); Output
rates-widget.js:3 jsonp-promotion: callback=? no longer makes JSONP: the request goes as JSON, which needs CORS on the server
rates-widget.js:6 event-shorthand: .ajaxError() is deprecated since 3.5 (still in 4.0): use .on('ajaxError', …)
stats.js:3 jsonp: JSONP still works, but runs the other server's code in the page: move to CORS
uploads.js:3 formdata-settings: jQuery 4 sets both for FormData by itself: the two lines can go once every page runs jQuery 4
widgets.js:3 script-not-run: a script is no longer run without dataType: 'script': use $.getScript() or add the dataType
widgets.js:6 script-with-headers: headers make the request go through XMLHttpRequest and an inline script, which a strict CSP blocks
6 findings
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node audit.mjs
Two findings change what a page does on jQuery 4: the callback=? call of the rates widget and the rating widget’s
$.ajax() without a dataType. The map widget’s headers will make a strict policy block it. The explicit JSONP of the
statistics call still works and is a candidate for CORS; the upload’s two settings and the .ajaxError() shorthand,
deprecated since 3.5 but still in 4.0 (.ajaxError()), can wait. A text
search misses calls whose settings are built elsewhere, so test the pages that matter on both versions as well.
Checkpoint
Check: Finding the calls an upgrade changes
Practise: write the upgrade notes
An upgrade script collects the settings object of every call and needs a function that says which jQuery 4 change applies to each. The exercise has starting code, a first hint and sample tests on settings objects like the audit’s findings:
Exercise · Medium · JavaScript
Flag the $.ajax settings that jQuery 4 treats differently
An upgrade script collects the settings object of every $.ajax() call in a codebase (a $.getJSON() call is collected as its settings, with dataType: 'json'). Write upgradeNotes(settings) in notes.mjs: it returns the notes that apply to one settings object, in this order, as an array of these ids:
| id | When | | --- | --- | | 'jsonp-promotion' | dataType is 'json' and url, or data when it is a string, contains =?: jQuery 4 no longer turns it into JSONP | | 'script-not-run' | the path of url, without its query string, ends in .js, and dataType is not 'script': jQuery 4 does not run it | | 'script-with-headers' | dataType is 'script', headers has at least one header and scriptAttrs is not set: the request goes through XMLHttpRequest and an inline script, which a strict CSP blocks | | 'formdata-settings' | processData and contentType are both false: jQuery 4 sets them itself for FormData, so they can go |
A call that jQuery 4 runs as jQuery 3 did, such as an explicit dataType: 'jsonp' or $.getScript(), gets an empty array. The tests pass plain settings objects, so they run without jQuery.
Starter code · notes.mjs
/**
* The upgrade notes of one $.ajax() settings object: which jQuery 4 changes apply to it.
* @param {{ url?: string, dataType?: string, data?: unknown, headers?: Record<string, string>, scriptAttrs?: object,
* processData?: boolean, contentType?: string | false }} settings
* @returns {string[]}
*/
export function upgradeNotes(settings) {
const notes = [];
if (settings.url.includes('callback=?')) notes.push('jsonp-promotion');
return notes;
} The sample tests · notes.test.mjs
import { test, assert } from 'mysmartcopilot:test';
import { upgradeNotes } from './notes.mjs';
test('flags a JSON request with =? in its URL or its data', () => {
assert.deepEqual(upgradeNotes({ url: 'https://api.example.in/rates?callback=?', dataType: 'json' }), ['jsonp-promotion']);
assert.deepEqual(upgradeNotes({ url: '/srv/rates', dataType: 'json', data: 'cb=?' }), ['jsonp-promotion']);
assert.deepEqual(upgradeNotes({ url: '/srv/rates', dataType: 'json', data: { q: '=?' } }), []);
});
test('leaves an explicit JSONP request and a plain JSON request alone', () => {
assert.deepEqual(upgradeNotes({ url: 'https://stats.example.org/today?callback=?', dataType: 'jsonp' }), []);
assert.deepEqual(upgradeNotes({ url: '/srv/rates', dataType: 'json' }), []);
});
test('flags a script fetched without dataType "script", whatever its query string', () => {
assert.deepEqual(upgradeNotes({ url: '/widgets/rating.js' }), ['script-not-run']);
assert.deepEqual(upgradeNotes({ url: '/widgets/rating.js?v=3', dataType: 'text' }), ['script-not-run']);
assert.deepEqual(upgradeNotes({ url: '/widgets/rating.js?v=3', dataType: 'script' }), []);
assert.deepEqual(upgradeNotes({ url: '/srv/rates.json' }), []);
});
test('flags a script request with headers, unless scriptAttrs is set', () => {
assert.deepEqual(upgradeNotes({ url: '/widgets/map.js', dataType: 'script', headers: { 'X-Widget': 'map' } }), ['script-with-headers']);
assert.deepEqual(upgradeNotes({ url: '/widgets/map.js', dataType: 'script', headers: {} }), []);
assert.deepEqual(upgradeNotes({ url: '/widgets/map.js', dataType: 'script', headers: { 'X-Widget': 'map' }, scriptAttrs: { nonce: 'n' } }), []);
});
test('flags the jQuery 3 FormData settings only when both are false', () => {
assert.deepEqual(upgradeNotes({ url: '/admin/prices', processData: false, contentType: false }), ['formdata-settings']);
assert.deepEqual(upgradeNotes({ url: '/admin/prices', processData: false, contentType: 'text/csv' }), []);
});
test('lists every note that applies, in the order of the table', () => {
assert.deepEqual(upgradeNotes({ url: '/widgets/old.js?callback=?', dataType: 'json', processData: false, contentType: false }), [
'jsonp-promotion',
'script-not-run',
'formdata-settings',
]);
}); A hint
url.split('?')[0] is the path without the query string. Check typeof settings.data === 'string' before you search it, and count the headers with Object.keys(settings.headers ?? {}).length. Push the ids in the order of the table.
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 this lesson’s questions. They arrive one at a time and begin with your weakest concept; each choice comes with a reason, the wrong ones too:
Practice that adapts to you
Practise this lesson
10 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, freshers and 2–5 years: name the four Ajax changes of jQuery 4.
A JSON request with callback=? is no longer turned into JSONP, so JSONP needs dataType: 'jsonp' and is better
replaced with CORS. A script fetched from the page’s own origin without dataType: 'script' is no longer run. Async
script requests use a script element instead of XMLHttpRequest and inline code, so a strict Content Security Policy
allows them, unless headers are set. And FormData and other binary data are sent as they are, without the
processData: false and contentType: false that jQuery 3 needed.
Two harder questions from the module’s bank continue from here: a rates widget that stopped working after the upgrade, and a widget that a strict policy blocks. Decide your first step before you open the bank’s:
Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.
-
Mid to senior
After an upgrade to jQuery 4, an exchange-rates widget that calls $.getJSON('https://api.partner.example/rates?callback=?') shows nothing, and the console reports a CORS error. It worked for years on jQuery 3. What changed, and what do you do, both now and properly?
-
Mid to senior
A team turns on Content-Security-Policy: script-src 'self' after upgrading to jQuery 4. Most widgets load, but the map widget, loaded with $.ajax({ url: '/widgets/map.js', dataType: 'script', headers: { 'X-Widget': 'map' } }), is blocked. Why this one, and how do you fix it without weakening the policy?
Key takeaways
- jQuery 4 no longer turns
callback=?into JSONP; the call becomes a JSON request that needs CORS on the other server, and JSONP itself needsdataType: 'jsonp'. - CORS should name trusted origins from an exact list, never
*with cookies and never a copy of the request’sOrigin, withVary: Originon the answer. - A script runs only when the request asks for it with
dataType: 'script'or$.getScript(). - Async script requests use a script element, which
script-src 'self'allows, unless the request sends headers; attributes such as a nonce go throughscriptAttrs. - FormData and Blobs go to the server as they are on jQuery 4, while jQuery 3 throws without
processData: falseandcontentType: false. - A text audit finds most affected calls; read computed settings by hand and test the key pages on both versions.
Terms in this lesson
- Content Security Policy (CSP)
- A response header, Content-Security-Policy, that tells the browser which scripts and other resources a page may load and run. A policy such as script-src 'self' allows scripts from the page's origin and blocks inline code.
- JSONP
- A way to read data from another origin without CORS: the page adds a script element whose URL names a function, and the server answers with code that calls it with the data. The answer runs as code in the page.
References
- jQuery Core 4.0 Upgrade Guide: Ajax (OpenJS Foundation (jQuery))
- jQuery 4.0.0 release notes (OpenJS Foundation (jQuery blog))
- jQuery 4.0.0 source: src/ajax/script.js (OpenJS Foundation (jQuery))
- jQuery 4.0.0 source: src/ajax/binary.js (OpenJS Foundation (jQuery))
- jQuery.ajax() (OpenJS Foundation (jQuery API documentation))
- jQuery.getScript() (OpenJS Foundation (jQuery API documentation))
- .ajaxError(): the deprecated shorthand (OpenJS Foundation (jQuery API documentation))
- jQuery Core 3.0 Upgrade Guide: Ajax (OpenJS Foundation (jQuery))
- Fetch Standard: the CORS protocol (WHATWG)
- Cross-Origin Resource Sharing (CORS) (MDN Web Docs)
- Origin header (MDN Web Docs)
- HTML5 Security Cheat Sheet: Cross Origin Resource Sharing (OWASP Foundation)
- Content Security Policy Level 3 (W3C)
- Content Security Policy Cheat Sheet (OWASP Foundation)
- jsdom (jsdom project)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress